大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
去年帮一个团队看故障,订单表突然开始报主键冲突。开发说程序在重试,重试多了撞上重复键。我看了一眼表结构,id INT NOT NULL AUTO_INCREMENT,再看当前最大值,2147483647。这个数太有辨识度了,它就是 int 有符号的上限。那张表跑了七年,从零开始一格一格走到头,走到最后一天才开始报错。自增耗尽这件事最好玩的地方就在这:它在建表那天就注定了,只是账要七年后才结。
一、先算清上限
MySQL 的整数类型,上限是固定的。知道这张表一天写多少行,就能反推它还能跑多久。下面这张表可以直接对照,注意有符号和无符号差一倍。
| 类型 | 有符号上限 | 无符号上限 | 日均 100 万行能撑多久 |
|---|---|---|---|
| TINYINT | 127 | 255 | 十几秒 |
| SMALLINT | 32767 | 65535 | 不到一小时 |
| MEDIUMINT | 8388607 | 16777215 | 八天 |
| INT | 2147483647 | 4294967295 | 五年十一个月 |
| BIGINT | 9223372036854775807 | 18446744073709551615 | 用不完 |
INT 有符号是 21.47 亿。日均 100 万行的话,五年十一个月到顶。听着很久,可这套系统第七年才出事,说明前期量小,后面几年在加速。所以别用"平均日增"算,用最近三个月或者最近一个季度的日增算,那个数才是真的。
还有一个成倍消耗的坑:双主或者多写节点。两台都写,auto_increment_increment 设成 2,一台拿奇数一台拿偶数,21 亿的空间就只剩 10.7 亿。步长设置成 5,就只剩 4.3 亿。这个参数在建库时随手设的,做余量计算时最容易漏。
二、到顶之后会发生什么
不会突然全部停摆,表现分三种,一种比一种难查。
第一种是直接报错。插入时分配不到新号,MySQL 返回的是主键冲突,1062 Duplicate entry '2147483647' for key 'PRIMARY'。它每次都拿上限那个值去试,所以报出来的是重复键,不是"号用完了"。这个错还有人会误判成程序重试导致的重复提交,方向一偏,半天就过去了。
第二种是静默丢单。业务代码里有 try...catch,捕获异常记了一条日志,接口照样返回成功。用户以为下单成功,库里没有这行。这种最要命,等你发现的时候,缺口已经积累了几千单,还得靠对账一条条补。
第三种最隐蔽,是号段被复用。有人为了救火,把表清空、把 AUTO_INCREMENT 改回 1,想让号从头开始。表里还有数据的时候,这个 ALTER 根本不会生效,计数器只肯往上走;一旦表被清空,它就真的从 1 开始。空出来的号会被新数据占用,同一个 id 在缓存里、在日志里、在外部对账文件里指向两笔不同的业务,之后怎么查都对不上。号段一旦复用,历史数据的引用关系就断了,这件事比丢单更难收拾。
顺带说清版本差异。8.0 把自增计数器持久化进了 redo log,重启之后不会倒退,8.4 也一样。5.7 重启后会把计数器重置成 max(id)+1,如果最大值那几行被删了,重启就会复用已经用过的号。所以 5.7 的库更要注意,一次重启就可能埋一个雷。
三、余量怎么算
三条 SQL 就够,跑完把数记到巡检表里,每月看一次。
-- 1) 当前自增位与表的行数规模
SELECT TABLE_NAME, AUTO_INCREMENT, TABLE_ROWS, DATA_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY AUTO_INCREMENT DESC
LIMIT 20;
-- 2) 主键当前最大值(比 AUTO_INCREMENT 更准,尤其是刚删过数据)
SELECT MAX(id) AS max_id FROM t_order;
-- 3) 近 30 天的日增,用来算还能撑多少天
SELECT ROUND(COUNT(*) / 30) AS per_day
FROM t_order
WHERE created_at >= NOW() - INTERVAL 30 DAY;
余量 = 上限减 MAX(id)。可用天数 = 余量除以日增。落到行动上有三档:余量超过两年,记个水位告警就行;余量在半年以内,立项排期;已经到顶,先止血再迁移。
止血有个临时办法,把列改成无符号,上限直接翻一倍,ALTER TABLE t_order MODIFY id INT UNSIGNED NOT NULL AUTO_INCREMENT。代价还是重建表,只是重建完之后能多撑几年。它还有个前提:应用层得接得住这个值。Java 里 id 还是 int 的表,改完这里照样报错,只不过错从数据库挪到了应用。所以这招只适合"先把今晚过去"的场景,不能当长期方案。
-- 水位告警:按主键实际类型取上限,超过 70% 就报(BIGINT 不参与,比值永远接近 0)
SELECT c.TABLE_NAME,
c.COLUMN_TYPE,
t.AUTO_INCREMENT,
ROUND(t.AUTO_INCREMENT / CASE
WHEN c.DATA_TYPE = 'tinyint' AND c.COLUMN_TYPE LIKE '%unsigned%' THEN 255
WHEN c.DATA_TYPE = 'tinyint' THEN 127
WHEN c.DATA_TYPE = 'smallint' AND c.COLUMN_TYPE LIKE '%unsigned%' THEN 65535
WHEN c.DATA_TYPE = 'smallint' THEN 32767
WHEN c.DATA_TYPE = 'mediumint' AND c.COLUMN_TYPE LIKE '%unsigned%' THEN 16777215
WHEN c.DATA_TYPE = 'mediumint' THEN 8388607
WHEN c.DATA_TYPE = 'int' AND c.COLUMN_TYPE LIKE '%unsigned%' THEN 4294967295
WHEN c.DATA_TYPE = 'int' THEN 2147483647
END * 100, 1) AS used_pct
FROM information_schema.TABLES t
JOIN information_schema.COLUMNS c
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA
AND c.TABLE_NAME = t.TABLE_NAME
WHERE t.TABLE_SCHEMA = DATABASE()
AND c.EXTRA LIKE '%auto_increment%'
AND c.DATA_TYPE IN ('tinyint', 'smallint', 'mediumint', 'int')
HAVING used_pct > 70
ORDER BY used_pct DESC;
这里有个口径要留意。8.0 里 information_schema.TABLES.AUTO_INCREMENT 是缓存值,默认 24 小时刷新一次。要拿准数,跑之前 ANALYZE TABLE,或者在会话里 SET information_schema_stats_expiry = 0。不然你看到的余量,可能是昨天的。
四、改类型的真实代价
MODIFY id BIGINT 这句话写起来一行,执行起来是另一回事。改列类型连 ALGORITHM=INPLACE 都不支持,只能走 ALGORITHM=COPY。COPY 的意思是建一张同结构的新表,把数据一行行拷过去,拷完再替换。这段时间里,这张表的写入全部阻塞,所有二级索引跟着重建,磁盘还要留出接近一张表的空间。亿级的表,几个小时起步。
| 方案 | 怎么做 | 代价 | 什么时候用 |
|---|---|---|---|
原生 ALTER |
ALGORITHM=COPY,建新表、逐行拷、拷完替换 |
写入全程阻塞,IO 翻倍,所有二级索引重建,主从延迟拉长,尾部要拿 MDL | 能接受停写,或者有足够长的维护窗口 |
| gh-ost / pt-osc | 建影子表,拷数据,追增量,最后切换 | 需要额外一份空间,切换瞬间仍有短暂锁,工具本身要调 | 大表的主流做法 |
| 新表双写 | 建新表,代码双写,回填历史,切读 | 工程量最大,但可控,随时能退 | 核心交易表,不敢赌窗口 |
选哪条,看两件事:这张表能停多久,以及主从延迟能容忍多久。能接受停写两小时、从库追得上,原生 ALTER 最省事。一分钟都停不了,就走 gh-ost。已经在做架构改造,顺手一起做双写。
有个细节必须提前确认:这张表上有没有外键。MySQL 要求外键两侧的列类型完全一致,主表一改,约束会先把 DDL 拦下来,报外键不兼容。有外键的表,得先拆约束再改,改完重建。这个顺序搞反,DDL 会在第十分钟失败,然后回滚,白等一小时。
五、要跟着改的,不止这张表
我第一次帮人做这件事,只算了主库的重建时间,没算从库。第二天凌晨,从库回放这条 COPY DDL,SQL 线程卡在那里,延迟告警响了一路。后来整理了一份清单,每次动手前照着过一遍。
- 从库不用单独改,但要盯它回放这条 DDL 要多久、磁盘够不够,SQL 线程卡住得有人管
- 独立报表库、逻辑备份恢复出来的实例,不在同一条复制链路上,得单独改
- 异构同步链路的目标端、分片中间件的路由规则与分片键范围配置
- 有外键关联的子表,类型必须同步,否则约束先把 DDL 拦下来
- 应用层的实体、DTO、接口出入参,
int到long是硬改 - 缓存 key 的拼接方式、序列化格式,老数据里存的是 int
- 导出脚本、对账脚本、数据分析的落库表结构
这里面最容易被忘的是缓存和序列化。id 从 21 亿变成 30 亿,如果某处用了 32 位有符号序列化,读出来就是负数。而这种错不会在改表当天暴露,要等新号真正越界才出现,可能是一个月后。
六、预防:建表那一刻就定下来
新表一律 BIGINT UNSIGNED,没有例外。一行多四个字节,一亿行大约四百兆;每个二级索引的叶子还要再存一份主键,这个数得往上加。跟一场亿级重建比,这笔账不用算。
自增不要拿来做业务单号。它不连续,回滚和批量插入都会跳号;它可预测,看号的大小就能反推业务量;它还绑在单库上,多写节点只能靠步长硬凑。业务单号要的是唯一、可读、可追溯,自增只占一个单表内的唯一。
水位监控要提前建。上面那段 SQL 塞进巡检脚本,每月跑一次,把用量超过 70% 的表列出来。这条成本最低,收益最直接,我现在的库里固定有这么一项。
七、避坑清单
AUTO_INCREMENT 的当前值不能只看 information_schema,它是缓存值,删过数据的表要用 MAX(id) 复核。两个数差得远,说明有大量空洞,真实余量比看上去少。
双主、多写节点的库,余量要按步长折算。auto_increment_increment=2 就是打对折,这个数很容易被漏掉,建库时设的,几年后没人记得。
最后一条是我自己踩的。有次在测试环境复现号段耗尽,我把表清空,又把 AUTO_INCREMENT 改回 1,重新灌了一批数据。当时对上了,觉得验证成功。上线前我照同样的做法在生产演练了一次,两千多条订单的 id 和历史归档数据撞上了,对账直接差钱。从那以后我明白,自增号段是"一次性资源",删掉的行不会把号还给你。演练也一样,宁可用新表造,也不要动生产表的计数器。
写在最后
自增耗尽不是容量问题,是类型选错了一次、几年后来收账。它的特点是可预测:上限固定,日增可测,余量能算。所以它属于少数"完全能提前发现"的故障。
这篇不讨论主键该选自增还是 UUID,那是建表时的选择。这里只讲已经选了 int、并且已经到顶之后,这笔账怎么结。号段能扩,类型不能,这是两件事。
真到了要改的那天,别急着写 ALTER。先数清楚有多少张表、多少个子表、多少处代码在用它。改表是半天的事,改全链路是两周的事。
那张表最后没走原生 ALTER,换了在线工具重建,切过去之后 id 接着往下走,业务没停。
你们库里最老的那张表,主键是什么类型?算过还能撑几年吗?把这个数发到评论区,我帮你核一遍预算。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋