INSERT失败

凌晨两点十七分,失败我盯着屏幕上那行红字:ERROR 1062 (23000): Duplicate entry '4291' for key 'PRIMARY'。失败这已经是失败今晚第三次了。数据库里明明没有 id 为 4291 的失败记录,我查过,失败用 SELECT * FROM orders WHERE id = 4291 返回空集。失败可 INSERT 就是失败不让过。

事情得从三天前说起。失败运营那边跑来说订单表少了一批数据,失败我第一反应是失败代码里事务回滚没处理好。翻日志,失败发现那批订单的失败 INSERT 语句全被吞了,没报错,失败也没写进去。失败用的失败 INSERT IGNORE,主键冲突时静默跳过。问题在于,冲突的主键值在表里根本不存在。

我让 DBA 查了自增计数器。SHOW TABLE STATUS LIKE 'orders' 显示 Auto_increment 是 4290。但线上那张表的实际最大 id 是 4289。中间空了一个 4290,而 4291 要插进去,MySQL 说重复。这就像酒店前台告诉你 4290 房间有人住,你拿钥匙开门,里面空的,但系统就是不给你办入住。

DBA 老张抽了口烟,说可能是 innodb_autoinc_lock_mode 设成了 2。交错模式,批量插入时自增值会跳。跳掉的号不会补回来。可跳号跟主键冲突是两码事——跳号只是浪费 id,不会导致 INSERT 失败。除非,有人手动插入过 4290 又删了,而二级索引没清理干净?

我让他查 INFORMATION_SCHEMA.INNODB_SYS_TABLES,看索引状态。果然,orders 表有个联合唯一索引 (user_id, order_no),而 4290 那条被删的记录,在二级索引里留了残影。InnoDB 的 purge 线程还没回收,新插入的 4291 在检查唯一性时撞上了这个幽灵条目。删掉重建索引,问题消失。

但这事没完。我回头翻了代码,发现更蠢的地方:业务层捕获到 Duplicate entry 异常后,直接吞掉,连日志都没打。运维看到的现象是“订单丢了”,实际是“订单被数据库拒了,代码假装没事”。这种静默失败比报错还可怕。你根本不知道它什么时候在骗你。

后来我把所有 INSERT IGNORE 全改成 INSERT ... ON DUPLICATE KEY UPDATE,至少能更新个时间戳,知道有东西撞上来了。再后来,加了个监控,专门抓 Duplicate entry 但目标行不存在的 case。上线第一周就逮到两次,都是 purge 延迟导致的幻影冲突。

数据库不会无缘无故拒绝你。它说重复,就一定有东西重复——哪怕你 SELECT 看不见。可能是索引残影,可能是 purge 没跑完,也可能是你自己忘了某个唯一约束。别跟它犟,去查 SHOW ENGINE INNODB STATUS,去看 INNODB_SYS_INDEXES。真相在那些你不常翻的系统表里。

凌晨四点,我加完索引重建的脚本,顺手把那个吞异常的 catch 块改成 throw new RuntimeException(e)。编译,部署,看着日志里终于打出完整的堆栈。舒服了。