為什麼資料庫裡會有 2899 年?一個假日期,和它離開系統之後的麻煩,數據中台能解這些麻煩?

主檔的失效日全都是 2899-12-31。它不是到期日,是「這一筆還沒結束」被硬塞進日期欄位之後的樣子。在那套系統裡一切正常,跨出邊界就只剩一個假日期——分析算出 873 年、BI 軸被拉開、AI 照字面理解。這篇談中台這一層該怎麼接。

分享
為什麼資料庫裡會有 2899 年?一個假日期,和它離開系統之後的麻煩,數據中台能解這些麻煩?

資料庫歷史軌跡

寫上一篇文章的時候,我注意到一件事:很多資料庫會用 9999-12-31 這種不可能的未來日期,來表示「這筆還沒結束」。

再往下看才發現,資料庫裡的樣子其實是過往一層一層的技術疊出來的。沒有誰對誰錯,只是那些當年的做法,跟今天的工具不太合得來。

數據中台要怎麼設計,才能接住這些累積了幾十年的資料,讓後面的分析、報表、AI Agent 都用得下去?

這篇要想清楚的是:一個當年正確的設計,怎麼在資料跨出系統邊界的那一刻失去意義,以及數據中台這一層應該怎麼接。

有一套跑了幾十年的核心系統。它的主檔上有兩個欄位:eff_dat(生效日)和 stop_dat(失效日)。翻開資料,生效日各不相同,這很正常;但失效日全部長成同一個樣子:

eff_datstop_dat
2019-03-012899-12-31
2021-07-152899-12-31
2024-11-022899-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 年的最後一天。看到它你就知道「這不是到期日,是『沒有到期日』」——雖然這件事沒有寫在任何地方,靠的是看過的人自己會意。
時間軸上,真實的業務日期擠在最左邊一小段(大約 2000 到 2040 年),其餘一大段完全沒有資料,直到最右邊 2899-12-31 那條紅線。那條線是哨兵值,代表「還沒結束」,不是真的到期日
真實的業務日期擠在左邊一小段,右邊那條線是哨兵值 2899-12-31——它不是到期日,是「沒有到期日」被硬塞進日期欄位之後的樣子。

但這設計只有自己人看得懂

前面說「看到 2899 你就知道那不是真的到期日」——但這句話有個前提:你得是看過的人。

那這個約定到底寫在哪裡?

  • 不在 schema 裡。 欄位型別就是 DATE,沒有任何地方標註「2899 代表還沒結束」
  • 不在約束裡。 沒有 CHECK、沒有外鍵,資料庫本身不知道這個值有特殊意思
  • 不在資料字典裡。 就算有字典,通常也只寫「eff_dat:生效日期」
  • 它寫在應用程式的程式碼裡,寫在幾支報表的 CASE WHEN 裡,寫在資深同事的腦袋裡

過去那套系統被設計出來的時候,資料不會離開它查詢由它自己的程式發出,報表由它自己的模組印出。在那個世界裡,「約定寫在程式碼裡」就是最合理的地方——因為讀這份資料的,永遠只有這些程式。

換句話說:約定的作用範圍,等於當時預期的讀者範圍。

問題是讀者變了。

今天要讀這份資料的,還有數據中台的 ETL、BI 工具、資料科學家的 notebook,以及 AI Agent。這些讀者有一個共同點:它們拿不到那份沒有被寫下來的知識。

約定只在寫下它的那套系統裡有效。跨出去一步,它就只是一個日期。

這也是為什麼「去問老員工」不會是解法。不是因為他們不願意講,而是沒有人有辦法把所有約定講完——它們散在幾十年、幾百張表裡,通常只有踩到的那一刻才會被想起來。

而且這件事只會越來越嚴重,因為讀者只會越來越多、越來越遠。二十年前那份資料的讀者是同一個機房裡的程式;今天它的讀者,可能是一個從來沒進過這棟樓的 Agent。

系統邊界左右對照:邊界之內,資料庫裡的 2899-12-31 加上程式碼裡的約定「2899 等於還沒結束」,讀出來是「這一筆還有效」;邊界之外只拿得到資料本身,約定拿不到,同一個值讀出來就變成「2899 年才到期」
系統邊界之內,約定由程式碼補上,一切正常;跨出邊界,讀者手上只剩一個日期。

這樣的設計會發生怎樣的麻煩?

先講一件公道話:這套設計到今天還在正常運作。在那套系統裡面,每一句 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_idnamecityeff_datstop_dat
C001小明台北2019-03-012899-12-31

翻譯成人話:小明 2019 年入會,到現在還沒退。

現在有個分析師來問:「小明的會員資格還有多久到期?」

直接查來源,他會這樣寫:

SELECT stop_dat - TODAY AS days_to_expiry
  FROM customer WHERE customer_id = 'C001';
-- 回:318,974 天,大約 873 年

不報錯,格式正確,數字很具體。而且如果報表上只顯示「到期倒數 318,974」,沒有單位、沒有上下文,多數人不會多看一眼。

接下來的五個步驟,就是要讓數據中台外面的人和工具,不必知道 2899 是什麼意思,也能把數字算對。

中台翻譯層的四欄流程:來源系統各自用 2899-12-31、9999-12-31、NULL 表示還沒結束;經過 ODS 營運資料儲存層原封不動照抄;再由哨兵值對照表翻譯、dim 維度層統一表示並加上 is_current 旗標;最後交付層包一層 view,分析師、BI 與 AI Agent 只看得到乾淨的答案
多套來源、各自的哨兵值,經過對照表翻譯成統一表示,交付層再包一層 view,讓取數的人只看得到乾淨的答案。

第一步: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 混著用。每接一套就回去改一次程式、改完要重測,而那些寫死的日期會散在幾十支腳本裡。

改成一張設定表:

來源系統欄位哨兵值代表意思
核心系統customerstop_dat2899-12-31還沒結束
另一套系統membervalid_to9999-12-31還沒結束

轉換邏輯讀這張表,本身不認得任何特定日期。新系統接進來只要加幾列設定,程式一行都不用改

小明在這一步:轉換程式並不認得 2899 這個日期,它是查到這張表的第一列,才知道該把它當成「還沒結束」。

注意這張表的粒度細到欄位,不是只到系統。因為在一套跑了幾十年的系統裡,同一個 2899 在 A 表可能是「這份合約沒有終止日」,在 B 表可能是「這個帳號還沒停用」——粒度粗一格,就會把兩件事混成一件。

還有一件事值得注意:這張表跟型別無關。 它記的是「哪個欄位的哪個值代表沒有」——日期只是最好講的例子,同一張表接得住字串、接得住數字。

這是同一條原則的又一次應用:真相從可以驗證的結構裡取,不要靠人在程式裡記得。 寫死的 CASE WHEN 是紀律,設定表是結構。

第三步:dim 層統一表示

dim 層(dimension,維度層)是數據中台整理好、要給人查的那一層。對照表把各家哨兵翻譯完之後,數據中台自己只保留一種表示法,並且額外給一個 is_current 旗標。

分工很清楚:日期欄位負責回答「某個時間點的狀態」,旗標負責回答「現在」。後者是最高頻的問題,給它一個能直接建索引的布林值,比每次都做日期比對划算。

小明在 dim 層變成這樣:

customer_keycustomer_idnamevalid_fromvalid_tois_current
1001C001小明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_customerdays_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 讀——那些規矩就得先被寫下來。


延伸閱讀

如果你也走在自己的路上,歡迎訂閱《小穗步電子報》——我會把每一次的覺察與練習,寫成信寄給你。→ 訂閱小穗步電子報

Read more