跳至主要內容
指南

實戰中的 Unix 時間戳:秒與毫秒、2038 問題,以及時區 bug

· 12 分鐘閱讀

每個生產系統最終都會記錄、儲存或傳輸 Unix 時間戳。而每個生產系統最終也都會因為它而產生 bug——日期顯示成 1970 年 1 月、排程的活動落在 49 年前的過去、API 整合中一邊期望毫秒另一邊卻送秒。這些 bug 很無聊、很普遍,而一旦你掌握真正支配時間戳運作的那少數規則,就能完全避免。

本指南涵蓋實務核心:用位數識別時間戳單位、2038 問題以及它目前的真實狀態、實際會發生的 bug 模式,以及在你可能使用的語言中取得時間戳的正確寫法。

Unix 時間戳是什麼(又不是什麼)

Unix 時間戳計算的是自 1970-01-01 00:00:00 UTC——也就是「Unix 紀元」——以來的秒數。接下來一切行為都取決於它的兩個特性:

  1. 它沒有時區。 時間戳 1785235837 在地球上的任何地方都同時指同一個瞬間。時區只在你把時間戳顯示給人類,或從本地時鐘元件構造時間戳時才介入。如果你要從本文記住一句話,就記住這句:時間戳是 UTC 的瞬間;時區是渲染層面的考量。
  2. 它忽略閏秒。 Unix 時間假設每天剛好 86,400 秒。當插入閏秒時,POSIX 時間實際上會重複或抹平它(行為依系統而異;Google 的「leap smear」把它分散到一整天之中)。對多數應用程式碼而言這沒影響;但對跨越閏秒的區間計算來說,你的「剛好 N 秒」假設可能差了一秒。

另外要注意,紀元的選擇意味著負的時間戳是合法的-1 是 1969-12-31 23:59:59 UTC。把負時間戳視為錯誤的程式碼,會在處理 1970 年之前日期(出生日期、歷史檔案)時壞掉——這是實際會出現的 bug 模式。

秒、毫秒、微秒:位數啟發法

時間戳來自不同的生態系時單位也不同,而誤判單位是最常見的時間戳 bug。快速的現場測試方法就是數位數:

單位範例(2026 年中)位數何時達到此寬度
178523583710自 2001-09-09 起為 10 位;2286 年起為 11 位
毫秒178523583700013自 2001-09-09 起為 13 位
微秒17852358370000001616 位
奈秒178523583700000000019在 2262 年超過 int64 範圍

當代(2001–2286)的經驗法則:

  • 10 位 → 秒。 永遠可以這樣假設。
  • 13 位 → 毫秒。 JavaScript 的 Date.now()、Java 的 System.currentTimeMillis(),以及大多數日誌/指標系統都會輸出這些。
  • 16 位 → 微秒。 Python 的 time.time() 乘出來之後、某些資料庫、某些追蹤系統。
  • 19 位 → 奈秒。 Go 的 time.Now().UnixNano(),這是個陷阱:19 位數在 2262 年之後會超出帶正負號 64 位元的最大值(9,223,372,036,854,775,807),但今天 19 位數還能放進 int64——剛好能放。如果你要儲存奈秒級時間戳,不要做任何預設還有 headroom 的運算。

除錯時一個快速的健全性檢查:把數字貼到時間戳轉換器裡,看看日期看起來合不合理。如果「秒」的解讀得到 1970 年,而「毫秒」的解讀得到一個合理的近期日期,你就在兩秒內識別出單位了。

對應的 bug 模式:把毫秒當成秒(或反過來)。對一個秒級時間戳多乘 1000,1785235837 就變成 1785235837000 秒——一個大約 56,000 年後的日期,多數渲染器會把它截斷或繞成垃圾。把毫秒級時間戳除以 1000,而它本來就是秒,你就會得到 1970-01-21——這就是為什麼這麼多壞掉的系統顯示 1970 年 1 月的日期。當你在生產環境看到 1970,先懷疑單位不匹配。

2038 問題

經典 Unix time_t帶正負號的 32 位元整數,計算秒數。它的最大值 2,147,483,647 對應 2038-01-19 03:14:07 UTC。一秒之後,帶正負號 32 位元計數器會繞回 −2,147,483,648,渲染為 1901-12-13 20:45:52 UTC。這就是「Y2038」或「Epochalypse」問題——結構上與 Y2K 完全相同,但根源是特定的 C 型別,而不是兩位數年份欄位。

到了 2026 年,這個問題真正仍然影響的地方:

  • 32 位元嵌入式系統與韌體。 使用壽命長的裝置——工業控制器、汽車系統、醫療裝置、基礎設施——執行 32 位元核心或 32 位元 time_t ABI。這正是主要殘留風險類別,原因正是這些系統難以修補又容易被遺忘。
  • 舊的檔案格式與通訊協定規定了 32 位元秒計數。有些可透過把該欄位重新解讀為無正負號(把界線推到 2106 年)來解決;有些就只能是遺產。
  • 有紀元上限型別的資料庫。 著名的例子:MySQL 的 TIMESTAMP 型別由於把秒儲存為無正負號 32 位元整數,歷史上只到 2038-01-19 03:14:07 UTC。(DATETIME 範圍到西元 9999 年,不受影響。)如果你的綱要用 TIMESTAMP,請注意這個限制。

已經修好的部分:

  • 64 位元 Linux 與現代 64 位元系統使用 64 位元 time_t,可持續約 2,920 億年。
  • Linux 核心的 time64 工作(於核心 5.6、2020 年完成)以及 glibc 的 64 位元時間支援(glibc 2.34 的 _TIME_BITS=64)為 32 位元 Linux 使用者空間提供了遷移路徑,但必須用新的 ABI 重新編譯二進位檔——在 32 位元系統上的舊編譯二進位檔仍然脆弱。
  • JavaScript、Python、Go、Java都使用 64 位元(或 float64)時間表示,在任何與人類相關的時間範圍內都不會溢位。JavaScript 的 float64 毫秒數可以往任一方向撐約 28.5 萬年。

實務建議:如果你在 64 位元平台上用主流語言撰寫新程式碼,2038 不是你的問題。如果你維護嵌入式韌體、32 位元目標上的舊 C 程式碼,或滿是 INT 紀元欄位與 MySQL TIMESTAMP 欄位的綱構,現在就稽核——在 2020 年代安裝的系統到了 2038 年仍會繼續跑。

實際會發生的 bug 模式

1. 單位混淆(秒 vs 毫秒)

前面已涵蓋:最常見也最容易辨識(1970 年或西元 58000 年的日期)。按慣例修正:把你的變數與欄位以單位命名——created_at_msexpires_s——並在 API 邊界進行驗證。對「秒」欄位來說,本世紀中超出例如 10^910^10 秒的時間戳都值得懷疑。

2. 把本地時間誤當成 UTC

「時間戳沒有時區」這條規則的反面。經典表現:上海的伺服器與維吉尼亞的伺服器以不同方式計算「同一個」截止時間,因為其中一邊是用本地時鐘元件構造時間戳。任何走「本地日期/時間欄位 → 時間戳」這條路徑的程式碼,都必須明確指定這些欄位的時區。JavaScript 中的 new Date(2026, 6, 28) 代表瀏覽器本地時區的午夜,對每個使用者來說是不同的瞬間。

3. 日光節約時間的邊界

DST 會產生兩種壞掉的時鐘時間:

  • 不存在的時間。 在美國時區中,本地時間 2026-03-08 02:30 不存在——時鐘從 02:00 直接跳到 03:00。一個排程為「每天 02:30 跑」的 cron 排程器在那天要嘛跳過、要嘛重跑,或表現得依平台而異。
  • 模糊的時間。 在 2026-11-01,美國時區的本地時間 01:30 會發生兩次,因為時鐘往回撥。儲存「01:30」而不附偏移量或時區,無法分辨你指的是哪一次。

穩健的修法:用 UTC 安排週期性工作、把瞬間存為時間戳、只在顯示時轉為本地時間。如果你必須儲存未來的本地時鐘時間(例如「使用者的 9 AM 會議」),就把本地時間連同 IANA 時區名稱(America/New_York)一起儲存,並在渲染時才解析為時間戳——因為政府會更改 DST 規則,今天為 2028 年本地會議算出的時間戳,在法律改變後可能就不對了。

4. JavaScript 的月份從零開始

new Date(2026, 6, 28)7 月 28 日,不是 6 月 28 日——月份從 0 到 11,而日從 1 到 31。這種不一致產生了一整個世代的「差一個月」bug。緩解方式:要表達 UTC 時用 Date.UTC(...);為了清楚起見用 ISO 字串(new Date("2026-07-28T00:00:00Z"));或用一個函式庫(Temporal 在廣泛採用後,會以 1-index 編號的月份真正解決這個問題)。

5. 秒的浮點精度

Python 的 time.time() 回傳 float。float64 有 53 位元尾數;在當前紀元量級(約 1.79 × 10^9 秒)下,解析度足夠(約 240 奈秒),所以 Python 很少踩到這個坑——但同樣的推理確實會在以 float64 攜帶微秒或奈秒時間戳的系統中咬人:16 位數的微秒值需要 54 位元才能精確表示,所以 float64 微秒在今天已經不精確了。對任何比毫秒更細的東西用整數。

6. 解析字串時缺少偏移量

JavaScript 中的 new Date("2026-07-28") 會被解析為 UTC 午夜,但 new Date("2026-07-28 00:00:00")(沒有 T、沒有 Z)會被解析為本地午夜——這是基於字串格式細節的隱式時區翻轉。在序列化的日期時間中永遠要包含明確的 Z 或數值偏移量。優先使用帶偏移量的 ISO 8601:2026-07-28T10:00:00+08:00

正確取得時間戳:分語言版本

JavaScript / TypeScriptDate.now() 回傳毫秒;除以 1000 取得秒:

const ms = Date.now();                      // 1785235837123
const seconds = Math.floor(Date.now() / 1000); // 1785235837
// 從特定瞬間取得時,永遠明確指定 UTC:
const ts = Date.UTC(2026, 6, 28, 0, 0, 0) / 1000; // 月份從 0 開始!

Pythontime.time() 回傳 float 秒;任何會被儲存的東西,用帶明確 UTC 的 datetime

import time
from datetime import datetime, timezone

seconds = int(time.time())                    # 1785235837
ns = time.time_ns()                           # 奈秒,精確整數
ts = int(datetime.now(timezone.utc).timestamp())

Gotime 套件讓你能明確取得每種單位:

now := time.Now()
seconds := now.Unix()       // int64 秒
millis  := now.UnixMilli()  // int64 毫秒
nanos   := now.UnixNano()   // int64 奈秒 — 注意 int64 範圍

Java — 避免舊的 Date/Calendar;使用 java.time

long millis  = System.currentTimeMillis();
long seconds = Instant.now().getEpochSecond();
long nanos   = Instant.now().getNano(); // 秒中之奈奈秒,不是紀元奈秒!

Bash — GNU 與 macOS(BSD)的 date 在解析上有所不同:

date +%s                        # 當前紀元秒(兩者皆可)
date -d @1785235837             # GNU:時間戳 → 人類可讀日期
date -r 1785235837              # macOS/BSD:時間戳 → 人類可讀日期
date -u -d "2026-07-28 00:00:00 UTC" +%s   # GNU:日期 → 時間戳
date -u -j -f "%Y-%m-%d %H:%M:%S" "2026-07-28 00:00:00" +%s  # macOS

注意反覆出現的主題:每個正確的 API 要嘛直接給你紀元值,要嘛強制你標明時區。每個危險的 API 都讓你能省略它。

互動式處理時間戳

當你從日誌或 API 回應除錯時間戳時,別在腦中算數學。把它貼到我們的時間戳轉換器:它會以 UTC 與你的本地時區顯示瞬間,並自動判斷秒與毫秒——這正是上面描述的歧義檢查。它也能反向轉換(人類日期 + 時區 → 時間戳),用來建立測試資料。

總結

  • Unix 時間戳是自 1970-01-01 UTC 以來的秒數(或毫秒/微秒/奈秒)。它沒有時區,也沒有閏秒。
  • 位數在現代用位數用來識別單位:10 = 秒,13 = 毫秒,16 = 微秒,19 = 奈秒。
  • 2038 會在 2038-01-19 03:14:07 UTC 讓帶正負號 32 位元 time_t 溢位。64 位元系統與主流語言是安全的;嵌入式韌體、舊 32 位元二進位檔、MySQL TIMESTAMP 欄位是殘留風險。
  • 真實世界的 bug 模式:單位混淆、本地 vs UTC 錯誤、DST 邊界、JS 從 0 開始的月份、µs/ns 上的 float64 精度損失,以及解析時缺少偏移量的字串。
  • 把瞬間存為整數時間戳、以單位命名欄位、用 UTC 安排、只在顯示時轉為本地——並把時間戳轉換器加入書籤,用來處理那些還是漏掉的除錯。