MySQL 过期时间字段怎么选:DATETIME、TIMESTAMP 还是 Unix 时间戳?

设计 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 和本地显示时间。