一次死了276次的事务
上个月我在压测退款单追回脚本,表不算大,tx_fund_flow只有3万行,主键是自增id,核心字段有一条唯一索引uk_biz_no(biz_no)。脚本做的事是把一批退款单的状态从ORIGINAL改成ROLLBACK_LOCKED,同时往操作日志表写一条流水。就是这样一个看起来根本不该触发的锁竞争,当并发线程开到8、每线程每批扫200个biz_no时,数据库开始刷屏Lock wait timeout exceeded。我把SHOW ENGINE INNODB STATUS\G拉出来看,LATEST DETECTED DEADLOCK里面全是一个叫insert intention的等待,业务SQL跟死锁点八竿子打不着。后来我直接把会话隔离级别从REPEATABLE READ切到READ COMMITTED,同一个脚本压了20轮,零死锁,TPS从810涨到1970。这件事让我确定了一个判断:MySQL默认的RR隔离级别,是被过度神化的历史包袱,今天大批的无谓死锁都来自它。
为什么默认是RR?是因为当年倒行逆施的statement复制
隔离级别这个词是ANSI在1970年代为System R设计的,但InnoDB真正落地的RR和标准语义还有很大距离。在InnoDB里,RR下普通的SELECT走MVCC快照,不阻塞也不能写,但每个UPDATE和DELETE都是当前读,必须加锁,而且锁不只在被命中的记录上,还会覆盖记录前后的“空洞”——这就是next-key lock。为什么要锁空洞?因为MySQL要把事务中执行的SQL原封不动写进binlog,早期binlog_format只有STATEMENT一种格式,在主库上两个事务可能并发地往同一个区间插数据,复制到从库后SQL执行顺序完全变了,数据当然会主从不一致。解决办法就是在源头上禁止这两个事务同时往那个区间写,于是间隙锁诞生。可是业务真的需要付出这样的代价吗?现在生产环境用ROW格式binlog是基本事实,ROW记录的是每行变更前和变更后的完整镜像,从库回放只认行,不认SQL顺序,statement复制那套理由早就不成立了。剩下的RR唯一优势就是在单个事务里反复SELECT不会看到我自己的更新之外的新提交行,即防幻读。然而为了这个应用比例很低的语义,所有写事务都被迫用大得多的代价锁住那些根本不存在的数据。
看看PostgreSQL和Oracle的选择,我们不孤单
PostgreSQL的隔离级别实现和MySQL完全是两种哲学。它的READ COMMITTED通过行锁和可见性判断实现,UPDATE/INSERT不会去锁一段不存在的范围;REPEATABLE READ则是真正的快照隔离,事务第一次查询的瞬间就生成快照,之后读到的东西始终一致,但写在遇到冲突时会立刻由引擎抛错,而不是通过间隙锁把别人挡在外面。Oracle默认就是READ COMMITTED,靠undo回滚段提供一致性读,想防幻读时用SELECT ... FOR UPDATE这种方式显式地加“范围锁”,而不是默默给每个UPDATE都加。这两家都在向我们证明:防止写数据碰撞的最佳手段不是让无关写排队,而是让写者之间只因为实际数据冲突而互相等待,并且用版本历史或重试机制来解决读一致性。MySQL的InnoDB却总是用力过猛,明明有undo版本链可以看历史版本,却还是把“同时写一个不存在键”当成了不可饶恕的事情。从Oracle迁到MySQL的团队第一件事通常就是把默认隔离级别改成RC,否则连简单批量UPDATE都跑不起来,他们还得安抚老板说数据库做的是“更安全”的选择。
从RR改到RC,怎么改才不翻车
先确认binlog_format,执行SHOW VARIABLES LIKE 'binlog_format';必须为ROW。如果不是,就要在my.cnf里配好binlog_format=ROW,重启后生效。然后用SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;把新连接切成RC,这只是临时的,想要长期生效需要在my.cnf里写transaction-isolation=READ-COMMITTED。这一步做完,等于把InnoDB里最危险的next-key lock丢掉了,但你要面对一个问题:原来某些依赖RR隐式防幻读的事务会现出原形。比如有一个财务日切任务,事务里先执行SELECT count(*) FROM trade WHERE status=1,拿到行数后再逐条做状态更新,RR下无论外面有多少新交易插入,Count结果永远是事务开始那一下;切到RC以后,第二次查询就可能看到新提交的交易行,导致更新的总行数和Count不一致。这种业务不能用“保留RR”来解决,正确做法是把UPDATE写成带状态条件的原子操作,再根据affected_rows判断是否要重试,或者给行加版本号然后用CAS条件更新,这才是并发语义里标准且收敛的解法。
调试锁冲突和死锁,比盲目调timeout重要的三件事
遇到死锁如果你第一反应是改小innodb_lock_wait_timeout,你就掉进了假优化。超时只是让事务更快回滚,原罪还在那里。你应该先看SHOW ENGINE INNODB STATUS\G里最近一次死锁记录,找类似lock_mode X locks gap before rec insert intention waiting的描述,这里出现gap就说明RR下的间隙锁在捣乱。如果业务必须保留RR,就得缩小锁粒度:把SQL改成直接基于主键或唯一键命中,例如UPDATE orders SET status='LOCKED' WHERE order_id=12345,不要用WHERE user_id=? AND status=?这种不能确定行归属的宽条件。其次,审视事务里是否混入了与主业务无关的写操作,有一个常见死锁模型就是先更新主表,再插入关联日志表,另一个事务却反过来插入日志再更新主表,这种互相等待与隔离级别无关,靠调整语句顺序即可解决。最后,在单索引热更新场景你也可以关闭innodb_deadlock_detect来获得吞吐量提升,但必须同时把innodb_lock_wait_timeout设置成一个足够小的时间,让可能的死锁通过超时释放,而不是无限挂起。我在一个只有两行数据的update压力测试里关掉detect,TPS提升了22%,但长时间的锁等待让端到端延迟从8毫秒飙升到700毫秒,这个绝对值不是谁都能承受。
结论:默认RR是legacy,不是best practice
MySQL官方不敢把默认隔离级别改成RC,因为要向后兼容非常老版本的binlog回放逻辑,也怕各种存量业务突然崩溃。但作为开发者,我们完全可以把默认RR理解为一份历史保鲜膜,它有保质期的。如果你的业务没有在同一个事务里需要两次SELECT都要看到同一条新数据这种硬需求,就勇敢地用RC+行锁,配合binlog_format=ROW和适当的重试机制。我自己经历过那次切换后,线上锁等待总量降低了约九成,批量任务不再频繁超时,DBA半夜被叫醒的次数也直线下降。别再神话RR了,它只是MySQL成长过程中的一次妥协,不是数据库正确性的真理。当你下个星期再一次被莫名其妙的死锁折腾时,希望这篇文章能让你想起一个排查方向:先看清自己是不是待在REPEATABLE READ这间没有窗户的旧房子里。