MySQL 过期时间字段怎么选:DATETIME、TIMESTAMP 还是 Unix 时间戳?
设计 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 和本地显示时间。