設計 MySQL 資料表時,經常會遇到這類欄位:

  • expires_at
  • valid_until
  • subscription_ends_at
  • coupon_expires_at
  • token_expired_at

很多人會問:這個欄位應該用 TIMESTAMPDATETIME,還是用 INT 存 Unix 時間戳?

工程上最常用、也最穩的答案是:

expires_at DATETIME(3) NULL

也就是說:長期業務過期時間優先用 DATETIME。不要用 MySQL TIMESTAMP 存可能超過 2038 年的過期時間;也不要為了繞開 TIMESTAMP 就預設改成 INT

MySQL TIMESTAMP 的 2038 限制

MySQL TIMESTAMP 的範圍有限。MySQL 官方文件中,TIMESTAMP 的最大值到:

2038-01-19 03:14:07 UTC

這就是常見的 32 位元 Unix 時間戳邊界。有人會口頭說「2037 年之後不安全」,本質上說的是接近 2038 邊界後就不適合拿它做長期業務欄位。

如果你的過期時間可能用於:

  • 會員訂閱
  • 終身方案
  • 軟體授權
  • 長期封鎖
  • 長期有效 API Key
  • 未來預約
  • 「永不過期」佔位

那就不適合用 TIMESTAMP

即使你現在只需要幾個月,資料庫欄位通常會活得比預期更久。一個叫 expires_at 的欄位,很可能後來被複用於更多業務。

DATETIME 範圍更適合業務時間

MySQL DATETIME 的範圍大得多,可到:

9999-12-31 23:59:59

所以它更適合會員、訂單、優惠券、授權、過期時間這類業務欄位。

範例:

CREATE TABLE subscriptions (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  expires_at DATETIME(3) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
    ON UPDATE CURRENT_TIMESTAMP(3),
  INDEX idx_expires_at (expires_at)
);

DATETIME(3) 表示儲存到毫秒。如果你確實需要微秒,可以用 DATETIME(6)。大多數 Web 業務,秒或毫秒已經足夠。

TIMESTAMP 還有時區轉換語意

TIMESTAMP 不只是「範圍小一點的時間型別」。MySQL 會把 TIMESTAMP 按 UTC 儲存,並在寫入和讀取時根據當前 session time zone 做轉換。

這對稽核欄位有時很方便:

created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

但如果欄位表示使用者或商家定義的業務截止時間,就可能帶來意外。

比如一家店的優惠券在本地時間 2027-01-01 00:00:00 過期,這不是單純的伺服器事件時間。它帶有業務時區含義。此時應該在應用邊界清楚處理時區,而不是讓資料庫 session 時區隱式改變結果。

能不能用 INT 存 Unix 時間戳?

通常不建議。

如果用有符號 32 位元 INT 存 Unix 秒,它同樣有 2038 問題。無符號 INT 雖然能延後上限,但會引入自訂約定,也不能解決「秒還是毫秒」「時區含義是什麼」等設計問題。

不推薦:

expires_at INT NOT NULL

問題包括:

  • 單位不明確:秒還是毫秒?
  • SQL 用戶端裡不可讀
  • 容易把毫秒和秒混著比較
  • 有符號 INT 仍然會遇到 2038 邊界
  • 時區語意仍然沒有被表達出來

如果你確實要用 Unix epoch,應該用 BIGINT,並把單位寫進欄位名稱。

推薦:

expires_at_epoch_seconds BIGINT NULL

或:

expires_at_epoch_ms BIGINT NULL

不要欄位名稱叫 expires_at,實際卻存整數。欄位名稱應該直接說明單位。

DATETIME 和 BIGINT Unix time 怎麼選?

適合用 DATETIME 的情況:

  • 人需要直接在 SQL 裡讀懂時間
  • 日期可能超過 2038 年
  • 欄位表示業務截止時間
  • 希望使用 MySQL 日期函式
  • 希望減少應用程式碼裡的單位混亂

適合用 BIGINT Unix time 的情況:

  • 多個系統之間統一傳 epoch 值
  • 日誌、事件流、分析系統已經使用 epoch time
  • 外部系統給的是毫秒或微秒時間戳
  • 你希望完全按數字排序和比較

對大多數應用業務資料表來說,DATETIME(3) 是更好的預設選擇。

「永不過期」怎麼表示?

不建議隨便用 9999-12-31 當魔法值,除非系統裡明確約定並長期一致處理。

更推薦:

expires_at DATETIME(3) NULL

NULL 表示永不過期或沒有過期時間。

如果業務上必須區分「未設定」和「永不過期」,可以增加布林欄位:

expires_at DATETIME(3) NULL,
never_expires TINYINT(1) NOT NULL DEFAULT 0

只有 UI 或業務邏輯真的需要區分時,才加這個布林欄位。

查詢範例

查詢仍然有效的記錄:

SELECT *
FROM subscriptions
WHERE expires_at IS NULL OR expires_at > UTC_TIMESTAMP(3);

查詢已過期記錄:

SELECT *
FROM subscriptions
WHERE expires_at IS NOT NULL
  AND expires_at <= UTC_TIMESTAMP(3);

如果你的應用約定 DATETIME 存 UTC,就用 UTC_TIMESTAMP() 比較。如果你存的是業務本地時間,就應該在應用層明確做時區轉換,不要混著用。

從 TIMESTAMP 遷移到 DATETIME

如果舊資料表已經是:

expires_at TIMESTAMP NULL

並且現在需要支援 2038 年以後的時間,可以遷移成:

ALTER TABLE subscriptions
  MODIFY expires_at DATETIME(3) NULL;

遷移前要注意:TIMESTAMP 受 session time zone 影響。你要先確認當前應用寫入和讀取時使用的時區,否則遷移後可能把「顯示值」和「真實業務含義」搞混。

建議遷移流程:

  1. 確認應用當前 MySQL session time zone。
  2. 抽樣匯出舊資料,對比顯示時間和業務預期。
  3. 增加接近 2038、超過 2038、長期未來日期的測試。
  4. 先在 staging 環境遷移。
  5. 驗證索引和過期查詢效能。

推薦規則

新建 MySQL 資料表時,可以按這個規則:

  • created_atupdated_at:可用 DATETIME(3);如果你明確接受 TIMESTAMP 的範圍和時區行為,也可以用 TIMESTAMP
  • expires_atvalid_untilsubscription_ends_at:預設用 DATETIME(3)
  • 外部系統傳來的 epoch 值:用 BIGINT,欄位名稱帶 _seconds_ms
  • 永不過期:優先用 NULL,不要隨便用 2038 或 9999 這種魔法日期。

如果你手裡有一個數字時間戳,不確定是秒還是毫秒,可以用 Unix 時間戳轉換工具 檢查它對應的 UTC 和本地顯示時間。