探讨在数据库设计中,主键应该选择 int 还是 bigint。通过分析存储成本、系统风险以及现代工程的统一规范,解释为什么在大多数场景下“无脑 bigint”才是降低心智负担和系统风险的最优解。
先给结论:2026 年的新表,时间字段默认选 DATETIME——没有 2038 年上限、存进去什么读出来就是什么、不受连接时区干扰。TIMESTAMP 只在一个场景不可替代:同一份数据要按访问者所在时区展示。至于"谁更快、谁省空间"这些老黄历差异,MySQL 8 时代基本可以忽略。
下面把这个结论拆开讲透。
一、本质区别:一个存字面时间,一个存 UTC 秒
DATETIME 存的是字面时间:写入 2026-08-26 10:30:00,磁盘上打包的就是这个时刻的二进制表示,读出来原样返回,跟时区无关。
TIMESTAMP 存的是一个 UTC 时刻:写入时按当前会话时区换算成 UTC 秒数,读取时再按会话时区换算回来。注意"会话时区"四个字——它是连接级变量,同一个库、同样的数据,不同的连接配置能读出不同的显示值。
| 维度 | DATETIME | TIMESTAMP |
|---|---|---|
| 存储语义 | 字面时间 | UTC 秒数,读取时按时区换算 |
| 占用空间 | 5 字节 | 4 字节(均不含小数秒) |
| 有效范围 | 1000-01-01 ~ 9999-12-31 | 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07(UTC) |
| 受 time_zone 影响 | 否 | 是 |
| 索引 / 排序行为 | 一致 | 一致 |
二、时区:TIMESTAMP 唯一的真本事,也是最大的坑
跑一段 demo 就明白(MySQL 8.x):
CREATE TABLE t_time_demo (
dt DATETIME,
ts TIMESTAMP
);
SET time_zone = '+08:00';
INSERT INTO t_time_demo VALUES ('2026-08-26 10:30:00', '2026-08-26 10:30:00');
SET time_zone = '+00:00';
SELECT * FROM t_time_demo;
-- dt = 2026-08-26 10:30:00 (原样返回)
-- ts = 2026-08-26 02:30:00 (换算成 UTC 展示)
做全球化产品,这个特性很香:柏林用户和上海用户看同一条"发布时间",各自看到本地时间。
但更多项目里,它是事故来源:
- 连接串没显式设时区,测试环境和生产的
time_zone不一致,同一行数据两边"长得不一样",肉眼核对全是坑 mysqldump默认按 UTC 导出 TIMESTAMP 列(--tz-utc开关),导出文件里的字面值和你在 +08:00 会话里查出来的差 8 小时,不了解机制的话,核对时全是"数据错了"的错觉- 按天做统计聚合,时区不同导致"今天"的边界漂移,凌晨的单子被算进前一天
DATETIME 所见即所得,这些坑天然不存在。展示层的时区转换交给应用——PHP 里 Carbon 一行 setTimezone() 的事,职责还更清晰。
三、2038:不是段子,是倒计时
TIMESTAMP 的上限是 2038-01-19 03:14:07 UTC——32 位 Unix 秒数到顶。从 2026 年算,只剩 12 年。
"12 年挺远"是错觉,两类字段今天就会踩雷:
生日、纪念日这类字段。 1980 年出生的人 2040 年满 60 岁;一张 20 年期的合同 2046 年到期。这些值往 TIMESTAMP 列里写,直接报错:
mysql> INSERT INTO t_ts (ts) VALUES ('2040-01-01');
ERROR 1292 (22007): Incorrect datetime value: '2040-01-01' for column 'ts' at row 1
严格模式下写入直接失败,不是截断、不是警告,是失败。只要字段的时间范围可能碰到 2038 年以后,DATETIME 就是唯一安全解。
四、性能:老黄历可以扔了
"TIMESTAMP 底层是整数所以查询快"——这句话在 MySQL 5.6.4 之后就不成立了。从那个版本起,两类时间类型都是紧凑二进制打包存储,走同一套 B+ 树索引,范围扫描、排序、比较行为一致。
真实差异只剩字节宽度:DATETIME 5 字节、TIMESTAMP 4 字节。算笔账:一张 5000 万行的表,时间索引每个条目差 1 字节,整棵索引差约 48 MB——在内存几十 GB 起步的服务器上,这点差距在查询耗时里测不出来。
选型时,性能这一栏可以直接划掉。
五、TIMESTAMP 的两大历史特权,MySQL 8 都没收了
早年间大家用 TIMESTAMP,一半原因是能白嫖两个魔法:不写默认值就自动 DEFAULT CURRENT_TIMESTAMP,不写更新行为就自动 ON UPDATE CURRENT_TIMESTAMP。
现在这两个特权都没了:
- 自动更新:MySQL 5.6.5 起 DATETIME 同样支持这两个子句,写法完全对称
- 零值魔法默认:MySQL 8.0 起
explicit_defaults_for_timestamp默认开启,TIMESTAMP 不再自动获得任何默认行为,必须显式声明
-- 8.0 之后,两种类型的建表写法完全对称
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
顺手说 Laravel:migration 里 $table->timestamps() 建出来的是两列 TIMESTAMP NULL。多数业务不用恐慌——created_at 只存"当下",真出问题也是 2038 年实时到点才出。但如果是打算长期维护的产品,把关键业务表的时间列换成 datetime() 是低成本的未来保险。
六、还有第三个选项:int 时间戳
拿这个博客自己举例:articles、files 表的 created_at 用的就是 int unsigned 存 Unix 秒。
- 优点:跨语言最通用、宽度固定、unsigned 能存到 2106 年
- 缺点:裸查不可读(一串
1756181040)、日期函数要先FROM_UNIXTIME()换算
适合"机器消费为主"的字段:埋点、审计、缓存版本号。要给人看的业务时间,还是老老实实用 DATETIME。
七、决策表
| 场景 | 推荐 | 一句话理由 |
|---|---|---|
| 常规业务时间(下单、发布、审批) | DATETIME | 所见即所得,无 2038 |
| 生日 / 纪念日 / 证照到期 | DATETIME(必须) | 范围必然穿越 2038 |
| 全球化产品,按访问者时区展示 | TIMESTAMP | 唯一不可替代的场景 |
| created_at / updated_at 审计列 | DATETIME 或 int | 别依赖已消失的魔法默认 |
| 海量大表的机器消费字段 | int unsigned | 便宜、通用、撑到 2106 |
总结成一句:默认 DATETIME;明确要"按访问者时区展示"才 TIMESTAMP;机器消费的极端场景用 int。
FAQ
Q:老表已经用了 TIMESTAMP,要马上迁移吗?
不用急,但别拖过 2030。改列类型是表重建操作(COPY 算法),大表会锁很久——用 pt-online-schema-change 或 gh-ost 在低峰期做,预留回滚窗口。
Q:要存毫秒、微秒怎么办?
两种类型都支持:DATETIME(3) 存毫秒、TIMESTAMP(6) 存微秒,代价是每档小数多占 1~3 字节。注意部分老版本 JDBC 驱动对小数秒的支持有坑,先升级驱动再上。
Q:DATETIME 不带时区,信息不就丢了?
会丢——但 TIMESTAMP 也没好到哪去:它存的是"UTC 时刻",同样不含"这是哪个时区的本地时间"这层语义。正解是团队约定库里统一存一个时区(全 UTC 或全 +08:00),展示层再转。