時間與時區的坑:我在金流與跨國系統踩過的雷
後端工程師,主力 Go,做過交易所撮合引擎、金流串接與高併發系統。
做後端久了會發現,最容易被低估的就是「時間」。它看起來只是一個欄位、一個 timestamp,但時間牽涉到時區、日光節約、自然日的定義、排程、量測,每一個都能讓你在半夜被叫起來查帳。這篇講我在金流對帳、跨國活動、報表統計裡實際踩過的雷,以及我後來定下的幾條鐵則——因為時間錯一秒、錯一天、錯一個時區,在錢的世界裡都是要負責的。
第一條鐵則:儲存一律用 UTC,顯示才轉當地時區
這是我所有原則裡最不肯讓步的一條。資料庫、log、訊息佇列、跨服務傳遞,一律用 UTC;只有在最後要呈現給人看的那一刻,才轉成使用者所在的時區。
道理很簡單:UTC 是全世界唯一一個沒有歧義、不會跳動、不會因為政策改變的時間基準。當地時間會變,UTC 不會。
我第一次真正體會到這件事,是早期一個系統把「台灣時間」直接存進資料庫。當時想得很單純:反正使用者都在台灣,存當地時間最直覺,查起來也好懂。問題是後來這個系統要接一個香港的合作方,再後來有歐洲的節點要對接。那一刻整個資料庫的時間欄位全部變成謎——這個 timestamp 到底是哪個時區?是寫入的人所在的時區,還是伺服器的時區?沒有人說得準,因為當初沒有把時區資訊存下來。
只要你存的是「沒有時區資訊的當地時間」,未來任何一次跨時區的需求都會讓你陷入考古。 UTC 則永遠只有一種解讀。轉換邏輯集中在顯示層,往內都是乾淨的 UTC,這是我反覆驗證過最不會出事的架構。
那「使用者所在時區」存哪裡
存 UTC 不代表丟掉時區資訊。使用者偏好的時區,我會另外存一個欄位(例如 Asia/Taipei 這種 IANA 時區名稱,而不是 UTC+8 這種偏移量)。為什麼是時區名稱而不是偏移量?因為偏移量會隨日光節約變動,今天 +8 不代表半年後還是 +8。存 IANA 名稱,系統才有辦法在任何一天算出正確的偏移。
資料庫該存 timestamptz 還是 timestamp
這題我有切身之痛,特別是用 PostgreSQL 的人一定要搞清楚,因為這兩個型別的名字會騙人。
- timestamp(without time zone):它存的是一個「沒有時區概念」的時間值。你存進去什麼,拿出來就是什麼,資料庫完全不做任何時區轉換。它不知道、也不在乎這個時間是哪個時區。
- timestamptz(with time zone):名字會讓你以為它把時區存進去了——它沒有。它實際上是把你給的時間正規化成 UTC 存起來,讀出來時再依連線的時區設定轉換顯示。它存的是一個「絕對的時間點」。
我踩過的痛是這樣:早年一個服務用了 timestamp,應用層送進去的是 UTC 時間,看起來一切正常。直到有人在資料庫直接下 SQL 比較時間、或用了 now() 這類函式,而資料庫連線的時區設定是當地時間——於是同一張表裡,有些值是 UTC,有些值混進了當地時間,而型別本身完全不會阻止你,因為它根本不帶時區語意。等對帳對不上才發現,已經有一批資料的時間意義是錯的,要回頭一筆一筆推算當初是哪個來源寫的。那種感覺很差,因為你連「該怎麼修」都要先做考古。
我的原則:需要表達一個絕對時間點的欄位(建立時間、交易時間、log 時間),一律用 timestamptz。 它會強制走 UTC 正規化,把「這到底是哪個時區」的歧義從根本上消滅。只有少數真的跟時區無關的概念——例如「每天早上九點的營業開始時間」這種牆上時間的規則——我才會考慮用不帶時區的型別,而且會特別註記。
MySQL 也有類似的陷阱:DATETIME 不帶時區、不做轉換;TIMESTAMP 會依 session 時區轉換但範圍只到 2038 年。觀念是一樣的:你要很清楚每個欄位存的到底是「絕對時間點」還是「牆上時間」,兩者不能混。
前後端怎麼傳時間
API 之間傳時間,我只認一種格式:ISO 8601,而且一定帶時區資訊。例如 2026-06-17T08:30:00Z 或 2026-06-17T16:30:00+08:00。那個結尾的 Z 或 +08:00 是命脈,不能省。
我見過太多介接因為時間字串害死人,最常見的就是傳一個 2026-06-17 16:30:00 這種沒有時區資訊的字串。這個字串本身沒有錯,錯在它沒有意義——收到的人只能用猜的。前端猜它是當地時間,後端當它是 UTC,雙方各自解讀,差了八個小時,而且因為兩邊都不會報錯,這種 bug 會一路潛伏到使用者反映「時間怎麼差八小時」才爆出來。
我的幾條傳輸原則:
- 後端回給前端,一律回帶時區的 ISO 8601(通常是 UTC 的 Z 結尾)。 怎麼顯示是前端的事,後端只負責給一個明確無歧義的絕對時間點。
- 前端送給後端,也帶完整時區資訊。 不要送「使用者選的那個當地時間字串」就算了,要嘛轉成 UTC,要嘛帶上偏移。
- 顯示層轉換交給前端的時間函式庫或瀏覽器的 Intl API,用使用者的時區把 UTC 轉成當地時間顯示。中間任何一層都不要自己用「加減幾小時」的土法去轉時區,那是災難的開始。
特別提醒一個前端常見的雷:JavaScript 的 Date 物件解析沒帶時區的字串時,行為在不同情境下不一致,有時當 UTC、有時當當地時間。所以我的習慣是,從後端拿到的時間一定帶 Z 或偏移,讓解析沒有模糊空間。
UTC+8 很單純,有 DST 的時區才是地獄
如果你的系統只服務台灣,老實說時間問題簡單很多,因為台灣是固定的 UTC+8,沒有日光節約時間(DST)。一年到頭偏移量都一樣,不會有「某一天突然多一小時或少一小時」的問題。中國、香港、新加坡也都沒有 DST,東亞這塊算是時間處理的舒適圈。
真正的地獄是有 DST 的時區:美國、歐洲、澳洲的大部分地區。它們一年會切換兩次時間,而切換的那兩天會出現兩個極度反直覺的現象。
不存在的時刻(spring forward)
進入夏令時間那天,時鐘會往前跳一小時。以美國某些地區為例,凌晨兩點直接跳到三點——也就是說,那天的兩點到三點之間,這一個小時的本地時間「不存在」。
這會出什麼事?假設你有個排程設定在每天凌晨兩點半跑,在切換那天,兩點半這個時刻根本不存在,你的排程可能就不執行,或者執行時間被系統默默挪動。如果這個排程是金流的日結、是清算、是發放,漏跑一天就是事故。
我也踩過使用者填表單填了一個不存在的本地時間,程式去解析它的時候,不同函式庫的處理方式不一樣,有的丟例外、有的自動往後挪一小時,行為不一致就埋了雷。
重複的時刻(fall back)
退出夏令時間那天相反,時鐘往回撥一小時,於是同一段本地時間會出現兩次。凌晨一點到兩點這一小時,會走過一遍,然後撥回去再走一遍。
這對金流是惡夢。如果你用本地時間當交易的時序依據,在這一小時內發生的交易,你光看本地時間根本分不出哪筆先、哪筆後——因為有兩個「凌晨一點半」。對帳、排序、冪等判斷全部會出問題。
這就是為什麼儲存與排序一律要用 UTC。 UTC 沒有 DST、沒有跳動,凌晨一點半就只有一個,時序永遠是單調清楚的。本地時間只活在顯示層,絕不拿來當系統內部的排序與比較依據。
報表與統計:換日點到底是哪個時區的午夜
這是我認為時間問題裡最容易被忽略、但影響最大的一塊。報表跟統計都要「按自然日分天」,但問題來了:一天從哪一刻開始?午夜是哪個時區的午夜?
舉個真實場景。我做過一個服務於台灣使用者的系統,所有時間都正確地以 UTC 儲存(這是對的)。但有人寫日報表時,直接用 UTC 的午夜當換日點去 group by 日期。結果呢?台灣的早上八點是 UTC 的凌晨零點。也就是說,台灣使用者在早上八點之前發生的交易,會被算到「前一天」去。
對使用者來說這完全不合理。一個台灣人早上七點下的單,他認知裡那就是「今天」的交易,結果報表把它算進昨天。財務看報表覺得每天的數字都怪怪的,跟使用者自己記的對不起來,信任就崩了。
我後來定下的原則:儲存用 UTC,但統計分天要用使用者所在的時區(台灣就是 UTC+8)來決定換日點。 也就是說,要先把 UTC 時間轉成台灣時間,再依台灣的午夜去切自然日。在 PostgreSQL 裡這件事可以用 AT TIME ZONE 'Asia/Taipei' 之類的方式做,但重點不是語法,是你得很清楚地意識到:換日點是一個業務決策,不是技術預設。
跨日交易歸哪天
延伸下去還有更細的問題:一筆在台灣時間半夜十一點五十九分發起、但系統處理完成已經是隔天凌晨零點零一分的交易,算哪一天?
這沒有標準答案,完全看業務怎麼定義。我的做法是先跟財務、營運把規則講死:用「發起時間」還是「完成時間」當歸屬依據,然後整個系統統一。最怕的是各個報表各用各的——有的用發起、有的用完成,於是兩張報表的當日總額永遠對不起來,然後大家花一整個下午查「為什麼差這幾筆」,最後發現只是定義不一致。時間的歸屬規則要當成業務規格寫清楚,不是讓寫 SQL 的人各自發揮。
跨國活動的換日更麻煩
做過跨國的搶購活動,這題又上一層。活動說「每天限量」,那這個「每天」是哪個時區的每天?如果你的使用者橫跨台灣、歐洲、美洲,用 UTC 的午夜換日,對某些地區的使用者來說換日點會落在很奇怪的本地時間,可能是傍晚、可能是中午。
我的處理是:換日點必須是一個明確寫進活動規格的決定,通常會選一個主要市場的時區,或乾脆統一用 UTC 並在前端清楚告訴使用者「每天的重置時間是當地的幾點」。不管選哪個,重點是明確、寫死、前後端一致、而且讓使用者知道,而不是讓它變成一個事後才被發現的隱藏行為。
定時任務與排程的時區問題
排程系統(cron 之類)有個常被忽略的細節:它依據的是哪個時區? 很多人裝好系統就用,根本沒注意伺服器的時區設定是什麼。
我的原則:
- 伺服器與容器的系統時區,我一律設成 UTC。 全公司所有機器都是 UTC,log 才能對得起來,跨機器追問題才不會被時區搞瘋。
- 如果某個排程在語意上必須對齊本地時間(例如「每天台灣時間早上九點發報表」),那這個排程要明確指定它的時區,而不是依賴伺服器時區。 現代的排程工具大多支援為任務指定時區,要用上。
- 遇到 DST 的時區,排程要特別小心前面提過的「不存在的時刻」與「重複的時刻」。 設在切換時段的排程可能漏跑或跑兩次。重要的排程我會避開那個敏感的凌晨時段,或者設計成冪等的——就算多跑一次也不會重複扣款、重複發放。
冪等這件事我要多強調一句:任何會動到錢的排程,都必須假設它「可能被執行不只一次」。 DST、retry、人為手動觸發,都會造成重跑。設計時就當作會重跑,用唯一鍵去擋重複,這比事後補救成本低太多。
量測耗時,別用牆上時鐘
前面講的都是「時間點」的問題,最後講一個「時間長度」的坑,觀念完全不同。
要量測一段程式跑了多久——例如記錄一個請求的處理耗時——千萬不要用牆上時鐘(wall clock)去相減。 牆上時鐘就是平常我們看的那種會顯示日期時間的時鐘,它的問題是它會被調整:NTP 校時會把它往前或往後撥,系統管理員可能手動改它,甚至閏秒也會動到它。
如果你在請求開始時記一個牆上時鐘時間、結束時再記一個,中間剛好遇到 NTP 把時鐘往回撥了幾百毫秒,你算出來的「耗時」可能變成負數,或是莫名其妙地暴增。我看過監控圖表上突然冒出一根「耗時負兩秒」的請求,查了半天才意識到是時鐘被校了。
量測耗時要用單調時鐘(monotonic clock)。 單調時鐘的特性是它只會往前、不會回頭,而且不受 NTP 校時與手動調整影響。它不代表任何「現在幾點」,它只保證「兩次讀取之間的差,是真實流逝的時間」。各語言都有對應的單調時鐘 API,Go 的 time 套件甚至預設就在 Time 值裡帶了單調時鐘讀數,兩個時間相減會自動用單調部分,這設計很貼心。
閏秒
順帶講閏秒。為了讓原子時間跟地球自轉對齊,偶爾會插入一個閏秒,讓那一分鐘變成六十一秒。對絕大多數應用來說你可以不用太在意,但你要知道它存在,因為:一是它又是一個「牆上時鐘可能不平順」的理由,再次證明量測耗時要用單調時鐘;二是有些基礎設施會用「閏秒抹平(leap smearing)」的方式,把那一秒分散到一段時間裡慢慢補,如果你的不同服務一個有抹平、一個沒有,那段時間它們的時鐘會有微小差異。對一般業務影響不大,但對需要精確時序的金融場景,值得知道這件事的存在。
小結
時間處理的所有原則,其實都收斂到同一個核心:對內用一個沒有歧義的絕對基準(UTC + 單調時鐘),對外才依使用者的時區與認知去呈現與分天。 儲存一律 UTC、欄位用 timestamptz、傳輸用帶時區的 ISO 8601、量測耗時用單調時鐘、統計分天用使用者所在時區、會碰錢的排程做成冪等——這幾條我幾乎是用踩過的坑換來的。
時間看起來只是一個欄位,但它其實是一連串你必須親手做的決定。你越早把這些決定講清楚、寫死、前後端一致,半夜被叫起來查帳的機率就越低。