订单表突然不写了,Java 这边只看到一行异常:
Duplicate entry '2147483647' for key 'order_info.PRIMARY'
这类报错我第一眼不会先去怀疑并发,也不会先翻业务代码。先看表结构。
SHOW CREATE TABLE order_info\G
CREATE TABLE `order_info` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`order_no` varchar(64) NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2147483647;
看到AUTO_INCREMENT=2147483647,基本就不用猜了。
这个表的主键是int,而且不是unsigned。MySQL 里INT的有符号范围到2147483647,INT UNSIGNED才能到4294967295;BIGINT的范围更大。这个范围不是展示长度决定的,int(11)那个 11 也救不了命,它不是“能存 11 位数字”。MySQL 官方手册里也写得很直,INT和BIGINT的边界就是数据类型本身决定的。([MySQL 开发者区][1])
AUTO_INCREMENT 正常生成值时,一般就是当前列最大值再加 1。插入时给NULL或者不写这个字段,MySQL 会自动拿下一个序列值。([MySQL 开发者区][2])
问题来了:当前已经是2147483647,下一个还能给谁?
这时候继续插入:
INSERT INTO order_info(user_id, order_no, created_at)
VALUES (10086, 'O202605200001', NOW());
线上常见结果就是:
ERROR 1062 (23000): Duplicate entry '2147483647' for key 'order_info.PRIMARY'
也可能碰到:
ERROR 1467 (HY000): Failed to read auto-increment value from storage engine
别被Duplicate entry带偏了。它不是你代码里手动传了重复 id,也不是雪花算法重复,也不是 MQ 重放。它就是自增列顶到头了,MySQL 已经没法给出一个合法的新值。
Java 侧一般长这样:
public Long createOrder(long userId, String orderNo) {
String sql = """
insert into order_info(user_id, order_no, created_at)
values (?, ?, now())
""";
try {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(conn -> {
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, userId);
ps.setString(2, orderNo);
return ps;
}, keyHolder);
return Objects.requireNonNull(keyHolder.getKey()).longValue();
} catch (DuplicateKeyException e) {
if (e.getMessage() != null && e.getMessage().contains("2147483647")) {
throw new IllegalStateException("order_info.id 自增值已经打满,别重试,先扩字段", e);
}
throw e;
}
}
这里我特意写了“别重试”。
很多系统一看到插入失败,就进重试队列。这个场景重试没有意义。你重试 10 次,就是打 10 次数据库,顺便把告警刷爆。自增值满了,不会因为你睡 3 秒再插一次就自己恢复。
真正要查的是这几个值:
SELECT
t.table_schema,
t.table_name,
t.auto_increment,
c.column_type
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 = 'trade'
AND t.table_name = 'order_info'
AND c.extra LIKE '%auto_increment%';
如果结果是这样:
table_name auto_increment column_type
order_info 2147483647 int
那就别在应用层绕了,改表。
小表可以直接:
ALTER TABLE order_info
MODIFY id BIGINT NOT NULL AUTO_INCREMENT;
但线上大表别这么莽。ALTER TABLE改主键类型,可能会重建表,锁表时间、磁盘空间、复制延迟都要看。尤其主键被一堆子表引用,或者二级索引很多,这个操作不是“改个字段”这么轻。
而且有个坑要记住:MySQL 用MODIFY改字段时,原来的属性不会自动完整保留。比如你原来是INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',你只写BIGINT,那UNSIGNED、默认值、注释这类属性可能就被你顺手丢了。官方文档也专门提醒过,改字段定义时要把需要保留的属性重新写全。([MySQL 开发者区][3])
所以我一般会先拿原结构,再手写完整 DDL:
ALTER TABLE order_info
MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单主键';
应用层也要一起看。Java 里如果还用Integer接主键,那数据库改成BIGINT只是改了一半。
这种代码也得改:
public class OrderRecord {
private Long id;
private Long userId;
private String orderNo;
public Long id() {
return id;
}
}
别数据库是BIGINT,Java DTO 还是Integer,接口 JSON 里再被前端当 number 随便处理。后面会变成另一类事故。
临时止血怎么办?
如果表里有很多历史垃圾数据,理论上可以归档删除,然后调整AUTO_INCREMENT。但这只在“最大 id 没有真的打满”的情况下有用。
SELECT MAX(id) FROM order_info;
ALTER TABLE order_info AUTO_INCREMENT = 1800000000;
如果MAX(id)已经是2147483647,删中间数据没用。AUTO_INCREMENT 看的是还能不能生成下一个合法值,不是看表里空了多少洞。id 用掉就是用掉了,中间删掉的 100 万个 id,不会自动拿回来继续发。
所以这个问题最好的处理时间,不是报错那天,而是还剩一截的时候。
我会加一个很土但很管用的巡检:
public void checkAutoIncrementHeadroom() {
String sql = """
select t.table_schema, t.table_name, t.auto_increment, c.column_type
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 c.extra like '%auto_increment%'
and t.table_schema not in ('mysql','sys','performance_schema','information_schema')
""";
jdbcTemplate.query(sql, rs -> {
String type = rs.getString("column_type");
long next = rs.getLong("auto_increment");
long max = switch (type) {
case "int" -> 2_147_483_647L;
case "int unsigned" -> 4_294_967_295L;
case "bigint" -> Long.MAX_VALUE;
default -> -1L;
};
if (max > 0 && next > max * 0.85) {
log.warn("auto_increment 快满了: {}.{}, next={}, type={}, used={}%",
rs.getString("table_schema"),
rs.getString("table_name"),
next,
type,
next * 100 / max);
}
});
}
这个检查不用多高级,每天跑一次就行。真等到插入失败,业务已经在门口排队了。
还有一点,别上来就把所有表都改成BIGINT UNSIGNED。小字典表、小配置表,用INT没问题。订单、流水、日志、消息、轨迹、行为记录这类持续增长表,建表第一天就别抠那几个字节。
主键类型选小了,前几年看不出来。
等它出事的时候,通常不是一张表出事,是写入链路、重试队列、复制延迟、报警一起跟着响。这个事故没什么玄学,就是建表时少想了一步。