MySQL 中 Unix time 字段怎么设计:INT、BIGINT、秒、毫秒与索引

有些场景下,在 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/ShanghaiAmerica/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 时间戳转换工具 判断它是秒还是毫秒,再写查询或迁移脚本。