有些情境下,在 MySQL 裡用 Unix time 存時間是合理的。

比如資料來自外部 API、訊息佇列、日誌、行動端 SDK、埋點系統、事件流,大家都用 epoch time 表示同一個 UTC 時刻。這種情況下,數字時間戳更利於跨系統比較和排序。

但資料表結構必須把約定寫清楚。最常見的坑是:用 INT 存會超過 2038 年的值、秒和毫秒混用、欄位名稱叫 expires_at 實際卻存一個整數。

欄位名稱必須寫出單位

不要把單位藏在程式碼記憶裡。

推薦:

expires_at_epoch_seconds BIGINT NULL

或者:

expires_at_epoch_ms BIGINT NULL

不推薦:

expires_at INT NOT NULL

expires_at 這個名稱通常表示一個日期時間值。如果實際存的是整數,就應該在欄位名稱裡寫出 epoch_secondsepoch_ms 或類似後綴。

INT 通常不適合作為預設選擇

MySQL 有符號 INT 最大值是 2147483647。如果把它解釋成 Unix 秒,範圍會卡在 2038 年邊界。

所以這種設計很脆弱:

created_at_epoch_seconds INT NOT NULL

INT UNSIGNED 雖然能把範圍延後,但仍然是一套自訂約定,也不能存毫秒。它可以用於短期相容或歷史系統,但不適合作為新資料表時間欄位的預設選擇。

新資料表如果要存 Unix time,優先用 BIGINT

秒還是毫秒?

MySQL 的 UNIX_TIMESTAMP()FROM_UNIXTIME() 預設處理的是 Unix 秒。JavaScript、很多行動端 SDK 和埋點系統常用毫秒。

單位一定要體現在欄位名稱裡:

event_time_epoch_seconds BIGINT NOT NULL
event_time_epoch_ms BIGINT NOT NULL

不要把毫秒存到一個叫 event_timeevent_time_epoch 的欄位裡,然後靠團隊記憶。

常見位數:

  • 10 位:秒級時間戳,例如 1721385000
  • 13 位:毫秒級時間戳,例如 1721385000000
  • 16 位:微秒
  • 19 位:奈秒

如果值是 13 位,不能直接丟給 MySQL FROM_UNIXTIME() 當秒處理。展示時要除以 1000。

事件資料表設計範例

事件日誌適合用 BIGINT 存時間,並給時間欄位建索引:

CREATE TABLE user_events (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  event_name VARCHAR(80) NOT NULL,
  event_time_epoch_ms BIGINT NOT NULL,
  payload JSON NULL,
  INDEX idx_event_time (event_time_epoch_ms),
  INDEX idx_user_time (user_id, event_time_epoch_ms)
);

查詢某個時間範圍:

SELECT *
FROM user_events
WHERE event_time_epoch_ms >= 1721347200000
  AND event_time_epoch_ms < 1721433600000
ORDER BY event_time_epoch_ms;

這種查詢可以很好地利用 idx_event_time,因為比較的是原始數字。

過期時間欄位設計範例

如果是業務過期時間,很多時候 DATETIME(3) 更合適。但如果你的系統契約已經統一使用 epoch milliseconds,也可以一致地存 BIGINT

CREATE TABLE api_tokens (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  token_hash VARBINARY(32) NOT NULL,
  user_id BIGINT UNSIGNED NOT NULL,
  expires_at_epoch_ms BIGINT NULL,
  revoked_at_epoch_ms BIGINT NULL,
  created_at_epoch_ms BIGINT NOT NULL,
  UNIQUE KEY uk_token_hash (token_hash),
  INDEX idx_expires_at_epoch_ms (expires_at_epoch_ms),
  INDEX idx_user_expires (user_id, expires_at_epoch_ms)
);

查詢有效 token:

SELECT *
FROM api_tokens
WHERE revoked_at_epoch_ms IS NULL
  AND (expires_at_epoch_ms IS NULL OR expires_at_epoch_ms > 1721385000000);

這裡 NULL 表示永不過期或沒有過期時間。通常比存一個很遠的魔法數字更清晰。

用產生欄提高可讀性

如果開發者需要在 SQL 用戶端裡直接看懂時間,可以加一個產生欄。

秒級:

CREATE TABLE jobs (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  run_at_epoch_seconds BIGINT NOT NULL,
  run_at_utc DATETIME
    GENERATED ALWAYS AS (FROM_UNIXTIME(run_at_epoch_seconds)) STORED,
  INDEX idx_run_at_epoch_seconds (run_at_epoch_seconds),
  INDEX idx_run_at_utc (run_at_utc)
);

毫秒級:

CREATE TABLE jobs_ms (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  run_at_epoch_ms BIGINT NOT NULL,
  run_at_utc DATETIME(3)
    GENERATED ALWAYS AS (FROM_UNIXTIME(run_at_epoch_ms / 1000.0)) STORED,
  INDEX idx_run_at_epoch_ms (run_at_epoch_ms)
);

注意:FROM_UNIXTIME() 回傳值會受 MySQL session time zone 影響。如果你希望展示穩定為 UTC,應讓應用和遷移連線設定 session time zone 為 +00:00,或者在應用層做轉換。

WHERE 裡不要對欄套函式

這種寫法可能慢:

SELECT *
FROM user_events
WHERE FROM_UNIXTIME(event_time_epoch_ms / 1000.0) >= '2026-07-19 00:00:00';

因為函式套在欄上,MySQL 很可能不能高效使用原始數字索引。

更推薦先把查詢邊界轉換成 epoch,再比較原始欄位:

SELECT *
FROM user_events
WHERE event_time_epoch_ms >= 1784419200000
  AND event_time_epoch_ms < 1784505600000;

也就是說,在應用程式碼裡先算好 epoch 邊界,再傳給 SQL。

時區規則

Unix time 表示一個基於 UTC 的絕對時刻,本身不存時區。

這對機器事件很方便,但對業務日曆概念不一定夠。比如「一家門市優惠券在當地時間午夜過期」,可能需要同時儲存:

  • 本地日期時間
  • 門市時區
  • 用於比較的 UTC 時刻

如果只存 epoch milliseconds,你知道那個瞬間,但不知道它來自哪個業務時區。業務需要時,可以額外儲存 timezone_name,例如 Asia/TaipeiAmerica/New_York

從 INT 秒遷移到 BIGINT 毫秒

如果舊資料表用 INT 存秒,可以這樣遷移:

ALTER TABLE api_tokens
  ADD COLUMN expires_at_epoch_ms BIGINT NULL;

UPDATE api_tokens
SET expires_at_epoch_ms = expires_at_epoch_seconds * 1000
WHERE expires_at_epoch_seconds IS NOT NULL;

ALTER TABLE api_tokens
  ADD INDEX idx_expires_at_epoch_ms (expires_at_epoch_ms);

然後修改應用程式碼,讓新寫入統一使用毫秒。遷移視窗內可以暫時保留兩個欄位,但不要長期雙寫而不清理。

可以加一個約束防止秒/毫秒混寫:

ALTER TABLE api_tokens
  ADD CONSTRAINT chk_expires_at_epoch_ms
  CHECK (
    expires_at_epoch_ms IS NULL
    OR expires_at_epoch_ms BETWEEN 946684800000 AND 4102444800000
  );

這個範例允許 2000-01-01 到 2100-01-01 之間的毫秒值。實際專案要按業務範圍調整。

推薦使用規則

適合用 BIGINT Unix time 的情境:

  • 值來自日誌、SDK、事件流或分散式系統
  • 需要快速按時間範圍做數字查詢
  • 多個服務統一使用 epoch milliseconds
  • 不依賴 MySQL 日期函式作為主要介面

更適合用 DATETIME(3) 的情境:

  • 人需要直接在 SQL 裡讀寫時間
  • 欄位是業務截止時間,不是機器事件
  • 需要很遠的未來日期
  • 希望預設可讀,並使用 MySQL 日期函式

如果決定在 MySQL 裡存 Unix time,至少把三件事寫清楚:

  1. 秒還是毫秒
  2. 新資料表用 BIGINT,不要預設 signed INT
  3. 欄位表示 UTC 絕對時刻,還是帶業務本地時間含義

拿到一個數字時間戳時,可以先用 Unix 時間戳轉換工具 判斷它是秒還是毫秒,再寫查詢或遷移腳本。