為什麼資料庫裡會有 2899 年?一個假日期,和它離開系統之後的麻煩,數據中台能解這些麻煩?
主檔的失效日全都是 2899-12-31。它不是到期日,是「這一筆還沒結束」被硬塞進日期欄位之後的樣子。在那套系統裡一切正常,跨出邊界就只剩一個假日期——分析算出 873 年、BI 軸被拉開、AI 照字面理解。這篇談中台這一層該怎麼接。
資料庫歷史軌跡
寫上一篇文章的時候,我注意到一件事:很多資料庫會用 9999-12-31 這種不可能的未來日期,來表示「這筆還沒結束」。
再往下看才發現,資料庫裡的樣子其實是過往一層一層的技術疊出來的。沒有誰對誰錯,只是那些當年的做法,跟今天的工具不太合得來。
數據中台要怎麼設計,才能接住這些累積了幾十年的資料,讓後面的分析、報表、AI Agent 都用得下去?
這篇要想清楚的是:一個當年正確的設計,怎麼在資料跨出系統邊界的那一刻失去意義,以及數據中台這一層應該怎麼接。
有一套跑了幾十年的核心系統。它的主檔上有兩個欄位:eff_dat(生效日)和 stop_dat(失效日)。翻開資料,生效日各不相同,這很正常;但失效日全部長成同一個樣子:
| eff_dat | stop_dat |
|---|---|
| 2019-03-01 | 2899-12-31 |
| 2021-07-15 | 2899-12-31 |
| 2024-11-02 | 2899-12-31 |
我第一次看到這類資料,皺了一下眉:為什麼不用 NULL?用一個未來的日期,不就等於說它還沒失效嗎? 而且如果有人把它當成真的日期去算使用期間,那不就失真了?
後來理解,這個值不是亂填的。它是一條地平線:一個你標得出座標、卻永遠走不到的點。挑一個保證比所有業務日期都晚的日期填進去,意思就是「這一筆還沒結束」。
這種「拿一個不可能的值來代表某種特殊狀態」的做法有個名字,叫哨兵值(sentinel value)——站在資料邊界上的哨兵。它比關聯式資料庫還老,早在穿孔卡的年代就在用了。後面我會交替用「地平線」和「哨兵值」,講的是同一件事。
而這個假日期要解決的問題,是 NULL。
NULL 是資料庫裡表示「這一格沒有值」的專用標記。它跟填 0、填空白不一樣——0 和空白都是「有值,只是值長那樣」,NULL 是明確地說「這裡什麼都沒有」。
為什麼不乾脆用 NULL
「還沒失效」最直覺的寫法當然是 NULL——沒有失效日,就是沒有。問題出在 SQL 怎麼處理 NULL:x < NULL 的結果不是 TRUE 也不是 FALSE,是 UNKNOWN。而 WHERE 只留 TRUE。
所以只要用了 NULL,每一句查詢都得多帶一個分支:
-- 用 NULL:每一句都要記得補後面這半段
AND (o.order_date < c.stop_dat OR c.stop_dat IS NULL)
-- 用哨兵值:條件統一
AND o.order_date < c.stop_dat
漏寫那個 IS NULL 會發生什麼事?目前有效的那一列會整個消失在結果裡。 不是報錯,是安靜地少一筆。
所以哨兵值買到的東西很明確:每一句查詢都不必記得那件事。 在一套有幾百張表、經手過幾十位工程師、跨越幾十年的系統裡,這個交換划算得不得了——它把「靠每個人記得」換成了「不必記得」。
如果你讀過上一篇的結尾,會發現這正是同一條原則:靠紀律不會成功,靠結構才會。當年設計這兩個欄位的人,做的就是這件事。
後來才明白,這不是壞設計,是那個年代的正確答案。當時對分析和應用的需求,也遠不像今天這麼強。
其實這樣的設計還有幾個好處
- 排序和索引都成立:所有真實日期都排在這個哨兵值前面,範圍查詢、
BETWEEN、索引掃描全部正常運作。 - 人一眼就認得出來:它長得就不像真的:2899-12-31,某個 99 年的最後一天。看到它你就知道「這不是到期日,是『沒有到期日』」——雖然這件事沒有寫在任何地方,靠的是看過的人自己會意。

但這設計只有自己人看得懂
前面說「看到 2899 你就知道那不是真的到期日」——但這句話有個前提:你得是看過的人。
那這個約定到底寫在哪裡?
- 不在 schema 裡。 欄位型別就是 DATE,沒有任何地方標註「2899 代表還沒結束」
- 不在約束裡。 沒有 CHECK、沒有外鍵,資料庫本身不知道這個值有特殊意思
- 不在資料字典裡。 就算有字典,通常也只寫「eff_dat:生效日期」
- 它寫在應用程式的程式碼裡,寫在幾支報表的 CASE WHEN 裡,寫在資深同事的腦袋裡
過去那套系統被設計出來的時候,資料不會離開它。查詢由它自己的程式發出,報表由它自己的模組印出。在那個世界裡,「約定寫在程式碼裡」就是最合理的地方——因為讀這份資料的,永遠只有這些程式。
換句話說:約定的作用範圍,等於當時預期的讀者範圍。
問題是讀者變了。
今天要讀這份資料的,還有數據中台的 ETL、BI 工具、資料科學家的 notebook,以及 AI Agent。這些讀者有一個共同點:它們拿不到那份沒有被寫下來的知識。
約定只在寫下它的那套系統裡有效。跨出去一步,它就只是一個日期。
這也是為什麼「去問老員工」不會是解法。不是因為他們不願意講,而是沒有人有辦法把所有約定講完——它們散在幾十年、幾百張表裡,通常只有踩到的那一刻才會被想起來。
而且這件事只會越來越嚴重,因為讀者只會越來越多、越來越遠。二十年前那份資料的讀者是同一個機房裡的程式;今天它的讀者,可能是一個從來沒進過這棟樓的 Agent。

這樣的設計會發生怎樣的麻煩?
先講一件公道話:這套設計到今天還在正常運作。在那套系統裡面,每一句 SQL 都寫得對,每一張報表都跑得出來。
麻煩全部發生在資料離開那套系統的時候。
一、凡是「算多久」的問題,答案不可信
哨兵值不是日期,但它長得像日期,所以它會被拿去算。
- 「這類項目平均有效多久?」→ 每一筆的結束日都是 2899,算出來全都是八百多年。平均值被這些列一拉,整個沒有意義。
- 「哪些快到期了?」→ 用
stop_dat - CURRENT排序,那些「永遠有效」的列算出來是還有三十幾萬天,永遠排在最後面。這個結果剛好是對的,但它是碰巧對——換一個問法(例如「今年內到期的有幾個」)就開始出錯。
這一類最危險,因為它不報錯。數字跑得出來,格式正確,小數點後兩位,看起來就是一個答案。
二、BI圖表和日期維度會爆炸
BI 工具處理日期時,常見的做法是依資料裡最早與最晚的日期建一張日期表。當最晚的日期是 2899,這張表要從最早一筆資料一路涵蓋到 2899——八百多年——而你真正需要的,可能只有最近五年。
折線圖也一樣:X 軸被兩端拉開,真實資料全部擠在中間很窄的一條。
三、資料搬不出去(這一類會報錯,算是幸運的)
這是最具體的一種:有些工具根本裝不下這兩個日期。
- pandas 的預設時間型別
datetime64[ns],範圍是 1677-09-21 到 2262-04-11。2899 超出上界——而且這個坑到今天還在:在 pandas 2.3 上,單獨建一個Timestamp('2899-12-31')會成功(它會自動降到秒的解析度),但只要你把它讀成一欄資料,to_datetime還是走datetime64[ns],直接丟OutOfBoundsDatetime。而「把資料讀成一欄」正是 ETL 每天在做的事。 - SQL Server 的
smalldatetime,範圍是 1900-01-01 到 2079-06-06。2899 塞不進去。
這一類反而該慶幸。它會爆、會停、會有人來查——比起上面那種安靜給你一個錯數字,會報錯的問題便宜太多了。
錯誤的方向是不對稱的:荒謬到一眼看穿的錯,是幸運的;看起來完全正常、只是意思已經不同的錯,才會活很久。
四、兩套系統合不起來
真正卡住事情的是這一條。
另一套系統可能用 9999-12-31,可能用 NULL,也可能用別的值。當你想把兩邊的資料疊起來看——所謂「全機構一張表」——你會發現這件事在此之前不是慢,是做不出來。因為「還在有效中」這句話,在兩邊長得完全不一樣。
五、看得懂它的,只有這套系統裡的人
新人接手,通常要踩過一次坑,才知道 2899 是什麼意思。
AI 更慘。你讓一個 Agent 去查資料,它看到 stop_dat = 2899-12-31,會照字面理解成「這筆到 2899 年才到期」。它不會皺眉,因為它沒有那個「這個值怪怪的」的直覺——那個直覺是人待久了長出來的,從來沒有被寫進任何文件。
這五件事的共同點是:它們全都發生在那套系統的邊界之外。 而這個約定,作用範圍就到邊界為止。
順帶一提:這件事不只發生在日期欄位
如果你翻一下同一套系統的其他欄位,會看到字串欄位常常填著空白——不是 NULL,是一個真的空白字元。
那是同一個決定的另一種長相:當一套系統把 NOT NULL 當成預設,它就得為每一種型別發明一個「代表沒有」的值。 日期是假日期,字串是空白,數字可能是 0。
而空白比假日期更難抓,因為假日期長得很怪、會讓人皺眉,空白長得像正常資料。這個更大的題目我留到下一篇,這一篇先把日期講完。
數據中台該怎麼應對與處理?
哨兵值是來源系統的實作細節,不該外洩到分析層。
它跟資料庫用什麼字元集、主鍵是不是流水號一樣,屬於「那套系統怎麼把事情做出來」的細節。分析的人需要知道的是「這筆現在還有效嗎」,不需要知道那套系統用哪個假日期表示「有效中」。
我們舉例說明,假設小明是這套系統裡的一個客戶。他在主檔裡長這樣:
| customer_id | name | city | eff_dat | stop_dat |
|---|---|---|---|---|
| C001 | 小明 | 台北 | 2019-03-01 | 2899-12-31 |
翻譯成人話:小明 2019 年入會,到現在還沒退。
現在有個分析師來問:「小明的會員資格還有多久到期?」
直接查來源,他會這樣寫:
SELECT stop_dat - TODAY AS days_to_expiry
FROM customer WHERE customer_id = 'C001';
-- 回:318,974 天,大約 873 年
不報錯,格式正確,數字很具體。而且如果報表上只顯示「到期倒數 318,974」,沒有單位、沒有上下文,多數人不會多看一眼。
接下來的五個步驟,就是要讓數據中台外面的人和工具,不必知道 2899 是什麼意思,也能把數字算對。

第一步:ODS 原封不動
ODS(Operational Data Store,營運資料儲存層)是中台最靠近來源的那一層。照抄,一個字都不要改。
理由不是懶,是你必須留得住證據。哪天報表對不起來要回頭追查,你得能證明「來源當初就長這樣」。如果在進門的時候就順手轉換掉,那條路就斷了。
轉換要做,但做在下一層。
小明在這一步:那一列原封不動抄進來,stop_dat 還是 2899-12-31。哪天有人回頭問「873 年是怎麼算出來的」,你翻得出來源當初就長這樣。
而且要能對得起來:ODS 裡 stop_dat 是 2899-12-31 的有幾列,dim 層 valid_to 是空的就該有幾列。留著不比對,出事的時候你只是多了一份一樣看不懂的資料。
第二步:建一張對照表(這步是關鍵)
最直覺的做法是在 ETL 裡寫死判斷:
CASE WHEN stop_dat = DATE '2899-12-31' THEN NULL ELSE stop_dat END
能動,但它會害死你。因為半年後接第二套系統,那套用的是 9999-12-31;再半年第三套用 NULL 混著用。每接一套就回去改一次程式、改完要重測,而那些寫死的日期會散在幾十支腳本裡。
改成一張設定表:
| 來源系統 | 表 | 欄位 | 哨兵值 | 代表意思 |
|---|---|---|---|---|
| 核心系統 | customer | stop_dat | 2899-12-31 | 還沒結束 |
| 另一套系統 | member | valid_to | 9999-12-31 | 還沒結束 |
轉換邏輯讀這張表,本身不認得任何特定日期。新系統接進來只要加幾列設定,程式一行都不用改。
小明在這一步:轉換程式並不認得 2899 這個日期,它是查到這張表的第一列,才知道該把它當成「還沒結束」。
注意這張表的粒度細到欄位,不是只到系統。因為在一套跑了幾十年的系統裡,同一個 2899 在 A 表可能是「這份合約沒有終止日」,在 B 表可能是「這個帳號還沒停用」——粒度粗一格,就會把兩件事混成一件。
還有一件事值得注意:這張表跟型別無關。 它記的是「哪個欄位的哪個值代表沒有」——日期只是最好講的例子,同一張表接得住字串、接得住數字。
這是同一條原則的又一次應用:真相從可以驗證的結構裡取,不要靠人在程式裡記得。 寫死的 CASE WHEN 是紀律,設定表是結構。
第三步:dim 層統一表示
dim 層(dimension,維度層)是數據中台整理好、要給人查的那一層。對照表把各家哨兵翻譯完之後,數據中台自己只保留一種表示法,並且額外給一個 is_current 旗標。
分工很清楚:日期欄位負責回答「某個時間點的狀態」,旗標負責回答「現在」。後者是最高頻的問題,給它一個能直接建索引的布林值,比每次都做日期比對划算。
小明在 dim 層變成這樣:
| customer_key | customer_id | name | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 1001 | C001 | 小明 | 2019-03-01 | (空) | Y |
valid_to 是空的——不是 2899,是真的空。數據中台在這裡誠實地說了一句:這一筆沒有到期日。
你可能會想問:等一下,前面不是說 NULL 有三值邏輯的問題嗎?數據中台怎麼又用回 NULL 了?
因為條件變了。
來源系統有幾百支程式在讀那張表,每一支都要記得補 IS NULL 那個分支——在那個條件下,用哨兵值划算。數據中台不一樣:讀 dim 層的不是幾百支程式,是一層 ETL(把資料從來源搬進數據中台、途中做整理的程式)加一層 view(一段存起來的查詢,別人查它就像查一張表),那個分支只要寫在那一層裡,寫一次,之後所有人都不會再碰到。
同一個取捨,在不同的約束下有不同的正確答案。不是當年那些人錯了,是條件變了。
第四步:交付層包一層 view
前面三步做完,數據中台裡面乾淨了,但取數的人還是可能繞過去直接查底層。
所以最後一哩是:在交付層包一層 view,讓取數的人根本碰不到哨兵值。 不是叫大家小心,是讓他們沒有機會接錯。
以小明為例,這層 view 長這樣:
CREATE VIEW v_customer AS
SELECT customer_id, name,
valid_from,
CASE WHEN valid_to IS NULL THEN NULL
ELSE valid_to - TODAY END AS days_to_expiry,
is_current
FROM dim_customer;
現在那位分析師再問一次「小明的會員資格還有多久到期?」——他查 v_customer,days_to_expiry 回來是空的。
數據中台給的答案是「沒有到期日」。而「沒有到期日」比 873 年正確。
這才是這一層真正的價值:它不是把資料變乾淨,是把錯的答案變成沒有答案。
拿到錯答案的人不會回頭查,沒有答案的人才會——而會回頭查的那個人,最後才做得出對的決策。
第五步:讓中台自己找出你還不知道的哨兵值
哨兵值有一個統計上的指紋:它會在同一個欄位裡出現異常多次。 真實的生效日應該散在各個日期上,哨兵值卻會佔掉一大塊。
所以可以做一個檢查:掃每個日期欄位的值分布,只要某一個日期佔掉超過某個比例的列,就標記出來給人看一眼。
這招會幫你找到你原本不知道存在的哨兵值——那些沒有人告訴過你、只寫在某支舊程式裡的約定。門檻要用自己的資料去調,沒有通用的數字。
小明在這一步:就算沒有任何人告訴你 2899 是什麼意思,這個檢查也會把它挖出來——因為 customer 表裡有很高比例的列,stop_dat 都是同一天。真實的失效日不會這樣。
不過這個檢查給的是候選清單,不是判決書——有些高頻值本來就是合法的,最後還是要由懂那個欄位的人決定。
五步走完,同一個問題
| 怎麼問「小明的會員資格還有多久到期?」 | 得到的答案 |
|---|---|
| 直接查來源 | 318,974 天,約 873 年 |
| 數據中台的交付層 | 沒有到期日 |
前者不報錯,後者不好看。而後者是對的。
如果源頭可以調整,還需要數據中台嗎?
你可能會問:既然來源端還在持續產生這種資料,為什麼不回去把來源改掉? 嗯,我一開始也是這樣想的——乾脆從源頭改掉算了。實際去理解之後才知道,改不動。
一套跑了幾十年的系統,動一個欄位的語意,牽動的回歸測試範圍大到沒有人敢批。
正因為改不動,數據中台這層翻譯不是過渡方案,它是長期方案。 它不會有拆掉的一天,它就是那套系統與外面世界之間的翻譯層。承認這件事,才會把它當成正式的東西來設計、來維護——而不是當成一個總有一天會清掉的權宜之計。
有了數據中台統一標準後,從「做不出來」變成「做得出來」
前面那五步聽起來像工程整理——把髒東西清乾淨。但真正的重點是清乾淨之後做得到的事。這三件事在此之前不是慢,是根本沒有答案。
先對一次帳:那五個麻煩,解掉了幾個
| 困擾與麻煩 | 數據中台能解決嗎? |
|---|---|
| 一、「算多久」的答案不可信 | 解掉——交付層不會再給你一個假日期拿去算 |
| 二、BI 日期維度被拉長 | 解掉——進 BI 的已經是翻譯過的值 |
| 三、資料搬不出去(型別裝不下) | 解掉——極端值在中台這一層就被換掉了 |
| 四、兩套系統合不起來 | 解掉——這正是那張對照表存在的理由 |
| 五、只有自己人看得懂 | 只解一半——中台把約定寫下來了,但只寫下它接進來的那些。沒接進中台的系統,約定還是在別人的腦袋裡 |
所以標題那個問號的答案是:能,但範圍只到中台接得到的地方。 中台不是把知識憑空變出來,它是把原本口耳相傳的東西,搬到一個機器讀得到的地方。
解掉之後呢?有三件原本做不到的事,變得做得到了。
一、兩套系統第一次疊得起來
「全機構一張表」這種需求,通常會被當成整合工程的問題——接口、排程、對帳。但實際卡住它的,往往是更小的東西:兩邊對「還在有效中」的寫法不一樣。
哨兵值統一之後,兩套來源才第一次能放進同一個 WHERE 條件裡。這不是效能改善,是從「做不出來」變成「做得出來」。
二、「有效期多長」這個問題第一次有答案
前面說過,這類問題原本算出來是八百多年。也就是說,這個問題在此之前是沒有答案的——不是答案不準,是根本問不了。
而它牽動的是一整類問題:平均有效期多長、什麼時候該續、哪些項目異常短命。這些是成效評估的基本盤。
三、AI 取數變得可能
你讓一個 AI Agent 去查資料,它看到 stop_dat = 2899-12-31,會怎麼理解?照字面理解——這筆到 2899 年才到期。然後它會很有自信地把這個結論寫進答案裡,語氣通順、格式完美。
人類分析師不會犯這個錯。因為看過幾次之後,他會自動在心裡補一刀:「喔那個是無限大的意思。」
問題是——那一刀從來沒有被寫下來過。 它活在資深員工的腦袋裡,活在幾支舊程式的 CASE WHEN 裡,活在新人踩坑之後的口耳相傳裡。它從來不在 schema 裡,不在資料字典裡,不在任何 AI 讀得到的地方。
所以「讓 AI 直接查資料庫」這件事,卡住的往往不是模型不夠聰明,是它拿不到那一刀。
而數據中台做的事,說穿了就是:把那一刀寫下來。 寫成對照表、寫成統一的表示法、寫成交付層那個 view。寫下來之後,它才第一次同時被人和機器讀得到。
(有人會說,那把規則寫進 AI 的提示或 skill 裡不就好了?那確實比什麼都不寫好——它把隱性知識變成了顯性知識。但它是 context,不是約束:模型可能讀到,也可能沒讀到,而且它不會跟著資料走到別的工具去。這條線下一篇再談。)
結語
來源改不動,所以數據中台這一層做的不是補救歷史,是止血——每天新進來的資料,每天翻譯一次。
真正該帶走的不是「哨兵值不好」。哨兵值當年是對的,今天在那套系統裡也還是對的。該帶走的是這一句:
約定只在寫下它的那套系統裡有效。
寫完這篇之後,我心裡冒出一個問題:這種事,真的只發生在時間欄位嗎?
也想順便問你——你的系統裡,有沒有這種「長得像資料的約定」?
不一定是日期。可能是某個狀態碼,9 代表作廢;可能是某個代碼表,空白代表未分類;可能是某個數字欄位,-1 代表尚未計算。它們在系統裡都運作得很好,因為讀它們的程式知道規矩。
但只要哪天這份資料要被別人讀——被 BI 讀、被數據中台讀、被 AI 讀——那些規矩就得先被寫下來。
延伸閱讀
如果你也走在自己的路上,歡迎訂閱《小穗步電子報》——我會把每一次的覺察與練習,寫成信寄給你。→ 訂閱小穗步電子報