設計 MySQL 資料表時,經常會遇到這類欄位:
expires_atvalid_untilsubscription_ends_atcoupon_expires_attoken_expired_at
很多人會問:這個欄位應該用 TIMESTAMP、DATETIME,還是用 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 影響。你要先確認當前應用寫入和讀取時使用的時區,否則遷移後可能把「顯示值」和「真實業務含義」搞混。
建議遷移流程:
- 確認應用當前 MySQL session time zone。
- 抽樣匯出舊資料,對比顯示時間和業務預期。
- 增加接近 2038、超過 2038、長期未來日期的測試。
- 先在 staging 環境遷移。
- 驗證索引和過期查詢效能。
推薦規則
新建 MySQL 資料表時,可以按這個規則:
created_at、updated_at:可用DATETIME(3);如果你明確接受TIMESTAMP的範圍和時區行為,也可以用TIMESTAMP。expires_at、valid_until、subscription_ends_at:預設用DATETIME(3)。- 外部系統傳來的 epoch 值:用
BIGINT,欄位名稱帶_seconds或_ms。 - 永不過期:優先用
NULL,不要隨便用 2038 或 9999 這種魔法日期。
如果你手裡有一個數字時間戳,不確定是秒還是毫秒,可以用 Unix 時間戳轉換工具 檢查它對應的 UTC 和本地顯示時間。