凌晨一點二十,群組訊息把我叫醒:營運貼了張後台截圖,三月那批訂單的「建立時間」整欄變成 1970-01-21 00:13:20。值班的前端爬起來看程式碼,五分鐘就定位到了——介面回傳的 create_time 是 10 位秒數,而他寫的 new Date(create_time) 把這個數字當成毫秒。同一個 1700000000,按秒讀落在 2023 年 11 月,按毫秒讀就掉回 1970 年 1 月。那次之後,我們組把時間戳相關的約定寫成一張卡片釘在 wiki 首頁,新人接手排查基本上不用再問人。
一、位數就是精度:先學會一眼判斷單位
時間戳本身只是一個整數,含義完全取決於它被按什麼單位產生出來。我的第一判斷依據永遠是數位數,而不是猜後端用了哪門語言:
| 位數 | 單位 | 典型來源 | 與秒級的關係 |
|---|---|---|---|
| 10 位 | 秒 | PHP time()、Go Unix()、Linux date +%s | 基準 |
| 13 位 | 毫秒 | JavaScript Date.now()、Java currentTimeMillis() | 秒 × 1000 |
| 16 位 | 微秒 | Go UnixMicro()、Python time_ns()//1000 | 秒 × 1000000 |
| 19 位 | 奈秒 | Python time_ns()、Go UnixNano() | 秒 × 10⁹ |
再補一條值域檢查:2020-01-01 到 2030-01-01 之間,秒級落在 1577836800 到 1893456000,毫秒級是 1577836800000 到 1893456000000。把 13 位當 10 位解析,日期會飛到公元五萬年附近;反過來把 10 位當 13 位,就掉回 1970 年。我遇到的「時間顯示異常」基本上都落在這兩種。
二、差的 8 小時不在時間戳裡,在讀它的那一層
時間戳是從 1970-01-01 00:00:00 UTC 起算的絕對量,不帶任何時區資訊。同一個數字在倫敦還是上海都是同一個值,分歧只發生在「把整數翻譯成人能讀的日期」這一步。以 1700000000 為例,它的 UTC 時刻是 2023-11-14 22:13:20,換成台北時間是 2023-11-15 06:13:20,正好差 8 小時。
實際專案裡造成 8 小時錯覺的四個常見位置,值得挨個排除:
- 容器預設時區是 UTC、宿主機是 Asia/Shanghai,日誌牆鐘時間和業務看到的差 8 小時。
TZ=Asia/Shanghai date -d @1700000000和不帶TZ各跑一次,就能確認錯在哪一層。 - MySQL 的
FROM_UNIXTIME()按工作階段時區輸出。先SELECT @@session.time_zone,再和UNIX_TIMESTAMP()雙向核對,欄位本身往往沒錯。 - PHP 沒配
date_default_timezone時按 UTC 走,date()輸出整體平移。 - 前端
new Date(ts).toISOString()永遠回傳 UTC 且末尾帶 Z,toLocaleString()按系統時區渲染。同一份資料在兩個函式裡「不一致」,是除錯裡最常見的自我懷疑。
三、我現在的排查順序,以及在哪一步用上工具
- 先數位數定單位,寫下「這批數字按秒還是按毫秒」的結論,避免後面兩個人各按一種理解算。
- 再定基準:我們最後把規矩壓成一句——資料庫裡只存 10 位 UTC 秒,介面欄位名後綴寫清單位(
create_time_ms),呈現層負責轉本地時區。 - 雙向驗一次:拿一個已知時刻(比如「昨晚 23:07」)轉成時間戳,再轉回來,看是否落回同一個值。這一步能同時暴露單位和時區兩處錯。
- 批次核對時,我把可疑數字貼進時間戳轉換,它會自己分辨秒級還是毫秒,同時給出台北時間、UTC、ISO 8601 和「幾天前」這類相對描述,省掉我心算時區偏移;日期反推時間戳也在同一頁做,頁面上還備了幾種語言取時間戳的寫法,換語言時直接抄那一段。
- 要算兩個時間點的間隔,或推「往後 90 天是哪天」,用時間加減計算,結果連星期幾和時間戳一起給出,比手動除以 86400 可靠。
四、容易忽略的邊界情況
這幾條踩過之後才會主動檢查,寫在這裡當清單用:
| 邊界 | 具體表現 | 處理方式 |
|---|---|---|
| 32 位溢出 | 有號 32 位存秒級,到 2038-01-19 03:14:07 UTC 翻負;MySQL TIMESTAMP 欄位上限也是這個時刻 | 欄位改 BIGINT,遷移前先用 SELECT MAX() 看餘量 |
| 負數時間戳 | 1970 年之前是負值,部分資料庫函式和圖表庫顯示成空或直接報錯 | 歷史檔案用日期字串存,時間戳只留給近幾十年業務 |
| 閏秒 | 時間戳不表達閏秒,對齊 UTC 秒的排程器在閏秒當天可能看到重複的一秒 | 關鍵計時走單調時鐘(CLOCK_MONOTONIC),別拿牆上時間做倒數 |
| 日光節約時間 | 美國 2024-03-10 02:00 直接跳到 03:00,這一小時的本地時間不存在;11-03 回撥又重複一小時 | 存 IANA 時區名(如 America/New_York),別只存 UTC+4 這種固定偏移 |
| 同秒並發 | 拿時間戳當檔名或快取 Key,同一秒內的請求會互相覆蓋 | 毫秒級再拼隨機後綴,或用 UUID |
跨國排會議是另一類需求:真正要的往往不是時間戳,而是「對方現在算不算工作時間」。這種我會先看世界時間裡兩城的目前時刻和時差,定下來再把時刻轉成時間戳寫進行程介面。
五、常見疑問
Q:轉換出來差 8 小時,是時間戳錯了嗎?
幾乎不是。先確認解析用的時區,再確認位數單位;時間戳數值本身沒變,變的只是渲染它的這一層。
Q:JavaScript 裡到底該乘 1000 還是除 1000?
看輸入位數。JS 的 Date 只認毫秒,拿到 10 位要乘 1000;反過來把 Date.now() 發給一個按秒解析的介面,就要除 1000 並取整。最穩的辦法是在欄位名裡寫清單位。
Q:怎麼快速判斷一批混亂的日誌時間是秒還是毫秒?
取最大值數位數,再用值域對照:2020 到 2030 年之間,10 位是秒、13 位是毫秒。同一份日誌裡兩種混用並不罕見,先分桶再統一轉換。
Q:2038 問題離我們遠嗎?
只要還有 32 位整數欄位在跑老系統就不算遠。成本主要在改欄位型別和重建索引,越晚動越麻煩,最好在下一輪資料表結構調整裡順手做掉。
回頭看那次故障,真正的修復不是那一行 new Date(),而是「誰產生數字誰標註單位、誰呈現時間誰負責時區」這條約定終於落進了介面文件。以後再看到 1970 或者公元五萬年,先數位數,再看時區,兩件事按順序查完,絕大多數時間錯亂都到不了第二步。