
做数据库死锁测试绝大部分情况下都是被线上故障逼出来的我这次也不例外。起因是压测环境并发一跑到128死锁告警就开始刷屏一个小时累计触发上百次库存扣减和订单创建两类事务频繁回滚应用连接池里塞满了等待锁的请求。为了把这个问题彻底弄清我花了两个多星期从构造死锁场景、复现问题开始一路做到锁粒度优化和优化前后的对比验证。这份验证报告既是我自己的复盘也希望给正在被数据库并发锁困扰的同行一点参考。无论你是后端开发还是DBA只要线上出现过“Deadlock found when trying to get lock”这套测试思路和优化路径都可以直接拿来用。1. 压测现场被死锁逼出来的专项验证先交代一下业务模型。我们有一个库存表和一个订单表核心操作是“下单扣库存”。正常逻辑是先扣减库存再插入一条订单记录这两步必须放在同一个事务里否则会出现库存扣了但订单丢了的脏数据。涉及的表结构简化如下CREATE TABLE inventory ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sku_id VARCHAR(32) NOT NULL, warehouse_id VARCHAR(16) NOT NULL, stock_qty INT NOT NULL DEFAULT 0, UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, sku_id VARCHAR(32) NOT NULL, qty INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;环境是MySQL 8.0.36InnoDB引擎事务隔离级别用默认的可重复读RR。之所以不切读已提交RC是因为生产上就是RR验证报告必须贴近真实环境也要顺带观察间隙锁对死锁的影响。连接池用的HikariCP压测机和数据库同机房尽量避免网络抖动干扰锁等待数据。1.1 死锁复现的两条路径手动双会话与多线程压测先从最直观的手动复现开始。死锁的本质是循环等待你只需要把“两个事务各自握着一把锁然后又去争抢对方手里的那把锁”这个过程完整摆出来。终端一执行BEGIN; UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU001 AND warehouse_id WH001;终端二执行BEGIN; UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU002 AND warehouse_id WH001;此时两个事务各自拿到了SKU001和SKU002这两行库存记录的排他锁。接着终端一继续插入订单并尝试更新SKU002INSERT INTO orders(order_no, sku_id, qty) VALUES (ORD-001, SKU001, 1); UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU002 AND warehouse_id WH001;这条更新会被终端二的事务堵住。然后终端二也插入订单并尝试更新SKU001INSERT INTO orders(order_no, sku_id, qty) VALUES (ORD-002, SKU002, 1); UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU001 AND warehouse_id WH001;到这一步InnoDB的死锁检测机制会立刻发现循环等待然后把其中一个事务回滚另一个继续执行。手动复现的好处是时序完全可控适合用来理解死锁产生的过程。它告诉我们死锁测试的核心不是“撞大运”而是构造出确定的锁顺序冲突。手动能复现自动化压测脚本就好写了。我准备了一个Python脚本用多个线程模拟不同事务模板的混合流量。脚本的关键逻辑是一部分线程走“先扣库存、后写订单”的模板一部分线程走“先写订单、后扣库存”的模板再加上随机SKU选择让交叉时序在并发中被自然触发。import random import threading import pymysql CONFIG {host: 127.0.0.1, port: 3306, user: app, password: ***, database: db_test} SKUS [SKU%03d % i for i in range(1, 51)] DEADLOCK_COUNT 0 LOCK_WAIT_COUNT 0 def do_transaction(conn, order_no, sku, template): global DEADLOCK_COUNT, LOCK_WAIT_COUNT try: conn.begin() with conn.cursor() as cur: if template inventory_first: cur.execute( UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id%s AND warehouse_idWH001, (sku,)) cur.execute( INSERT INTO orders(order_no, sku_id, qty) VALUES (%s, %s, 1), (order_no, sku)) else: cur.execute( INSERT INTO orders(order_no, sku_id, qty) VALUES (%s, %s, 1), (order_no, sku)) cur.execute( UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id%s AND warehouse_idWH001, (sku,)) conn.commit() except pymysql.err.OperationalError as e: if e.args[0] 1213: # Deadlock found DEADLOCK_COUNT 1 elif e.args[0] 1205: # Lock wait timeout exceeded LOCK_WAIT_COUNT 1 conn.rollback()写压测脚本时有个小技巧为了快速暴露问题可以在两个SQL之间加一个1到5毫秒的随机延时把冲突窗口人为放大。这个sleep纯粹是测试阶段的放大手段生产代码千万不要加。不加延时时死锁可能需要并发到256才频繁出现加了之后64并发就能稳定复现。1.2 统计口径死锁次数和锁等待次数要分开看很多人在验证锁优化效果时只盯着死锁次数这不够。死锁是循环等待后被InnoDB主动回滚锁等待则是事务在排队等一把别人持有的锁两者性质不同。我同时采集了下面的指标指标来源说明死锁次数SHOW GLOBAL STATUS LIKE Innodb_deadlock_warns死锁检测触发次数的累加值锁等待次数SHOW GLOBAL STATUS LIKE Innodb_row_lock_waits行级锁等待的总次数平均锁等待时长SHOW GLOBAL STATUS LIKE Innodb_row_lock_time_avg单位毫秒TPS / p99压测脚本端统计事务完成数和请求耗时分布另外MySQL默认只在内存里保留最后一次死锁信息也就是SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK。要拿到全部死锁记录我在压测期间开了innodb_print_all_deadlocksON这样每次死锁都会写到错误日志方便事后统计和逐条分析。这个参数在日常开发环境建议常开生产环境死锁频率不高的话也建议开日志量在可控范围但它能救你于水火。2. 根因定位从 InnoDB 状态报告反向还原加锁现场复现出问题之后最忌讳的是看一眼SQL就凭感觉下结论。死锁的根因分析必须回到InnoDB的锁状态报告上去那里记录了事务持有哪把锁、在等哪把锁是还原现场的第一手材料。2.1 死锁检测机制InnoDB到底是怎么判定的InnoDB内部维护了一张锁等待的有向图每个事务是一个节点事务A等待事务B持有的锁就形成一条A指向B的边。每次加锁产生等待时InnoDB都会检查这张图里有没有环一旦发现环就是死锁。它不会干等着超时而是选择一个回滚代价较小的事务回滚释放它持有的锁让另一个事务继续执行。这个过程主要受两个参数影响参数默认值说明innodb_deadlock_detectON是否启用死锁检测不建议关闭innodb_lock_wait_timeout50秒锁等待超时时间超时后事务直接报错回滚有人会问死锁检测有开销并发极高时能不能关掉省点CPU我的建议是生产环境别关。如果关掉死锁检测两个事务相互等待时只能等innodb_lock_wait_timeout超时50秒对在线业务来说是不可接受的堆叠起来的线程会把整个服务拖垮。相比之下死锁检测回滚一个事务代价小得多。2.2 读死锁日志HOLDS THE LOCK 和 WAITING FOR 是关键词一次典型的死锁日志长这样我基于MySQL 8.0的输出稍作精简关键字段都保留了------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-01-15 14:23:08 0x7f3a1c0c9700 *** (1) TRANSACTION: TRANSACTION 29041, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1160, 1 row lock(s) MySQL thread id 83, OS thread handle 139763840000000, query id 17890 127.0.0.1 app_user updating UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU001 AND warehouse_id WH001 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 5 n bits 80 index uk_sku_warehouse of table db_test.inventory trx id 29041 lock_mode X locks rec but not gap *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 6 n bits 80 index uk_sku_warehouse of table db_test.inventory trx id 29041 lock_mode X locks rec but not gap *** (2) TRANSACTION: TRANSACTION 29044, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1 2 lock struct(s), heap size 1160, 1 row lock(s) MySQL thread id 87, OS thread handle 139763840500000, query id 17893 127.0.0.1 app_user updating UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU002 AND warehouse_id WH001 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 6 n bits 80 index uk_sku_warehouse of table db_test.inventory trx id 29044 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 5 n bits 80 index uk_sku_warehouse of table db_test.inventory trx id 29044 lock_mode X locks rec but not gap *** WE ROLL BACK TRANSACTION (1)怎么读这份日志我的习惯是三步第一步看TRANSACTION后面的SQL文本它告诉你这个事务是在哪条语句上出事的。上面事务1卡在更新SKU001事务2卡在更新SKU002。第二步看HOLDS THE LOCK(S)。事务1HOLD的是page no 5上uk_sku_warehouse索引的X锁事务2HOLD的是page no 6上同一个索引的X锁。两人各有各的地盘。第三步看WAITING FOR THIS LOCK TO BE GRANTED。事务1等在page no 6也就是事务2握着的那一行事务2等在page no 5也就是事务1握着的那一行。这就是教科书式的循环等待。日志末尾的WE ROLL BACK TRANSACTION (1)表示InnoDB最终回滚了事务1应用层会收到一个1213错误码。回滚哪个事务取决于它计算出的“代价”通常修改行数少、undo量小的事务会被牺牲掉。所以应用层绝对不能假设自己的事务一定会被保全1213错误码的捕获和重试是必须的。2.3 三条根因结论把死锁来源彻底拆开日志看多了之后我们把自己的死锁情况汇总了一下根因集中为三条。第一条加锁顺序不一致。业务代码里存在两种事务模板一种先扣库存再写订单一种先写订单再扣库存。两个事务如果同时操作不同SKU各自先拿了一把锁再申请对方手里的锁循环等待就成立了。这是最典型、也最容易修的死锁来源。第二条锁范围大于预期。排查EXPLAIN时发现部分库存更新SQL没有走uk_sku_warehouse索引而是走了全表扫描。前面说过InnoDB的行锁是挂在索引记录上的不走索引的更新会扫描并锁住大量匹配到的记录一个事务实际锁的“行”不是1行而是几千行。这时就算两个事务操作的是不同的SKU也可能因为扫描路径重叠而产生锁竞争。第三条RR隔离级别下间隙锁和Next-Key Lock参与交叉。默认RR下范围查询会带上间隙锁插入操作还会申请插入意向锁。锁的种类变多交叉点自然变多。这一条不是每个死锁都能命中但它解释了很多“更新语句明明没交集却还是死锁”的诡异现象。3. 锁粒度改造的三板斧顺序、索引、事务时长定位完根因接下来才是重头戏。锁粒度优化的目标不是简单把表锁换成行锁而是让每个事务实际锁住的索引记录数最少、持有时间最短、申请顺序全局一致。我按这三条原则做了三轮改造。3.1 先澄清一个误区行锁并不天然优于表锁先把锁粒度的基本概念理清楚。表锁的粒度是一张表并发能力差但开销小管理简单。行锁的粒度是一行记录并发能力强但每个记录锁都需要锁结构内存加锁和解锁的开销也更大。InnoDB选择行锁是因为OLTP场景大部分请求只涉及少量行平均下来收益大于成本。关键在于InnoDB的行锁不是直接锁在一行行数据上而是锁在索引记录上。一张表如果查询条件走不上索引存储引擎就只能把扫描过程中遇到的记录挨个加锁。你以为自己在做行级锁控制实际效果和表锁差不多甚至因为锁结构多开销比表锁还高。打个比方把一栋楼装上门禁系统你觉得每间房单独管理很精细但管理员开门时没看门牌号从一楼挨个试着开到了顶楼过程中整栋楼的所有房间都显示“已锁”。这就是索引失效时InnoDB干的事情。所以锁粒度优化的本质是保证管理员每次只经过目标房间而不是把门禁装得更密。3.2 第一板斧统一多表操作加锁顺序针对第一条根因改造方案很直接所有涉及库存和订单的事务统一走“先库存、后订单”的顺序。代码层面不靠自觉而是封装事务模板方法不允许业务方自由拼接SQL顺序。这里我踩过一个坑只统一业务入口还不够。有些定时任务和补偿任务直接写SQL绕过了服务层模板又把顺序打乱了。所以我还把所有直连数据库的脚本、批处理任务全部盘点了一遍凡是同时操作多张表的都固定顺序。涉及更多资源时可以维护一张全局资源顺序表例如按表名排序大家都按这张表的顺序加锁。统一顺序为什么有效因为死锁的四个必要条件里互斥、持有并等待、不可剥夺都是InnoDB的行锁机制客观决定的我们改不了但“循环等待”是可以打破的。所有事务都按同一顺序加锁等待关系只会是单向链形不成环死锁自然消失。3.3 第二板斧把 update 的 where 条件打到索引上针对第二条根因改造重点是索引。原始库存表只在主键id上有索引但业务上查询条件是sku_id warehouse_id。压测数据量到百万行之后这条update直接全表扫描。我加了一个联合唯一索引ALTER TABLE inventory ADD UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id);索引加完之后执行计划的变化非常直观。优化前是全表扫描mysql EXPLAIN UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_idSKU001 AND warehouse_idWH001; ------------------------------------------------------------------------------------------------------ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ------------------------------------------------------------------------------------------------------ | 1 | UPDATE | inventory | ALL | NULL | NULL | NULL | NULL | 1000000 | 0.00 | Using where | ------------------------------------------------------------------------------------------------------优化后命中了联合唯一索引rows直接从一百万降到1--------------------------------------------------------------------------------------------------------------- | id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra | --------------------------------------------------------------------------------------------------------------- | 1 | UPDATE | inventory | ref | uk_sku_warehouse | uk_sku_warehouse | 70 | const,const | 1 | 100.00 | NULL | ---------------------------------------------------------------------------------------------------------------加锁范围从“全表扫描命中的所有记录”缩小到“唯一索引定位到的1行”这一步是死锁次数大幅下降的最关键因素。这里我还想多说一句联合索引的字段顺序很重要必须把等值条件的字段放前面才能让索引快速收敛。验证阶段务必用EXPLAIN看实际计划而不是凭直觉认为“建了索引就一定会用”。3.4 第三板斧事务裁剪缩短锁的持有时间第三条根因靠隔离级别调整可以缓解但我这次没有改隔离级别而是先做了事务裁剪效果已经足够。RR对业务有正确性保证改RC需要团队评估我不愿意为了消死锁引入新的语义风险。事务裁剪的核心是锁持有时间越短死锁窗口越窄。很多事务里塞了大量无关操作最典型的是下面这种写法BEGIN; SELECT stock_qty FROM inventory WHERE sku_id SKU001 FOR UPDATE; -- 业务代码在这里做复杂校验、调用外部接口、计算优惠金额 UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU001; COMMIT;SELECT FOR UPDATE拿到锁之后Java代码里可能还要调用优惠服务、计算运费、写日志这些时间全是白花花的锁持有时间。正确的做法是事务外先准备好所有数据事务里只做必要的锁和更新外部调用一律放到事务外。大批量场景也要分片我压测时一次更新500条和一次更新5000条锁等待时长能差出好几倍。4. 优化前后数据对比并发从32到256的验证结果改造完成之后我用同一套压测脚本重新跑了一轮。数据来自我自己的测试环境不是硬件性能基准绝对值参考意义有限但优化前后是同一环境、同一脚本、同一数据量相对变化是可信的。4.1 测试口径先讲清楚压测前重置库存和订单表50个SKU每个SKU初始库存1000。每轮跑1000个事务线程数分别固定在32、64、128、256每种并发重复10轮取中位数。连接池大小跟随线程数调整TPS和p99从脚本端统计。这里有个容易忽略的细节压测前必须清理死锁相关的状态计数否则累加值会把上一轮的数据带进来统计结果会失真。4.2 死锁次数与核心性能指标优化前的数据说句实话惨不忍睹。并发256时10轮累计死锁500多次几乎每轮都在触发。优化后整体收敛到个位数级别并发线程数优化前死锁次数(10轮)优化后死锁次数(10轮)优化前TPS优化后TPS优化前p99(ms)优化后p99(ms)32202030211042376419024703160805412810613050424018579256502431804710430116锁等待的改善同样明显并发线程数优化前Innodb_row_lock_waits优化后Innodb_row_lock_waits优化前平均锁等待时长(ms)优化后平均锁等待时长(ms)6415428612.32.1128624133028.63.425618200102045.25.2需要说明的是这些数值对硬件配置、数据分布、SQL写法都极其敏感直接抄作业没有意义。真正有价值的结论是趋势死锁次数下降一个数量级以上锁等待次数下降一个数量级p99延迟下降70%左右。4.3 怎么解读这组数据p99从430ms降到116ms体感会非常明显。过去高并发时段接口经常超时现在尾部延迟被削掉一大截这比TPS数字的提升更能说明问题。但我必须提醒一句优化后并发256仍然出现了4次死锁。这4次谁干的我翻了全部死锁日志清一色是同一个SKU的热点行竞争——两个事务同时更新同一行库存记录。这是业务天然冲突跟锁顺序、索引覆盖率没关系。你没法通过锁粒度优化让两个事务同时写同一行还不打架只能通过重试机制兜底或者把热点行的串行化需求交给上层队列处理。另外我还观察到一个现象优化前并发从128升到256TPS几乎不涨从3050只爬到3180说明系统瓶颈已经从“执行事务”退化成了“排队等锁”。优化后同样的并发跨度TPS从4240涨到4710虽然涨幅也在收窄但至少没有出现平台期。这就是锁竞争对吞吐量的真实压制。5. 锁相关的几个高频坑和一套能落地的日常治理思路验证报告写到这里优化的结论已经明确。但我不想停在数据层面锁问题真正的难点在于日常防不胜防。下面这几个坑是我在实际工作中反复遇到的都跟锁粒度和死锁直接相关。5.1 批量更新的顺序坑夜间跑批是死锁高发场景。两个批处理任务同时在更新库存表任务A按sku_id升序处理任务B按id升序处理。它们的目标行集合有重叠但加锁顺序完全不同跑着跑着就互相踩脚。解决方式很朴素批量任务统一排序规则按同一个字段升序处理。最稳妥的做法不是直接对UPDATE语句加ORDER BY而是先把要更新的主键或唯一键按固定顺序查出来再按这个顺序分批执行更新。这样无论多少个任务并发加锁顺序都是一致的。# 示例先按固定顺序取出要更新的主键再按顺序更新 ids select_ids_order_by_sku(SKU001, WH001, limit1000) for id_ in ids: update_stock_by_id(id_, -1)5.2 1213错误码必须捕获并重试死锁优化做得再好也做不到绝对零死锁。热点行竞争、突发流量、临时表查询都可能在某个瞬间触发循环等待。所以应用层必须把1213当成一种正常情况对待。重试要有限次、带随机退避避免所有线程同步重试造成二次锁风暴。我常用的Python模板是for i in range(3): try: with conn: with conn.cursor() as cur: cur.execute(update_sql, params) break except pymysql.err.OperationalError as e: if e.args[0] 1213 and i 2: time.sleep(random.uniform(0.01, 0.05)) continue raise注意不是所有失败都能重试。如果是锁等待超时1205多半是当前SQL本身太重或者长时间持锁盲目重试意义不大需要先排查SQL执行计划。只有1213死锁回滚重试才是有明确收益的。5.3 监控比事后排查重要得多死锁问题最怕的是“不触发就永远不知道”。我在这次专项验证之后把下面几项落成了常态化监控一是innodb_print_all_deadlocksON保证每一次死锁都进错误日志二是定期采集错误日志里的死锁段落到监控平台死锁次数超过阈值就告警三是用performance_schema.data_lock_waits观察当前锁等待情况四是慢查询日志里重点看lock_time高的SQL。这套组合拳的价值在于死锁不再靠用户投诉才发现而是能在压测阶段、灰度阶段就被拦截。等它到了生产上大规模爆发再牛的分析手段也是救火。5.4 哪些场景值得绕开数据库锁最后一个话题什么时候不该硬扛数据库锁。纯热点计数型的场景比如库存扣减、秒杀、点赞计数数据库行锁的竞争天然激烈锁粒度优化只能缓解不能根除。这类场景更合适的方案是Redis预扣库存 异步对账 MQ队列串行化把高频写操作从数据库里剥离出来。还有乐观锁CAS写法适合简单的扣减校验UPDATE inventory SET stock_qty stock_qty - 1 WHERE sku_id SKU001 AND stock_qty 1;影响行数为1表示扣减成功为0表示库存不足。这个方案没有锁等待也不会有死锁代价是并发竞争时大量请求更新失败需要上层快速返回或重试。但我也要泼一盆冷水不是所有死锁都要用缓存和消息队列去解。引入一套新的链路意味着数据一致性、缓存失效、消息重复处理的复杂度全部跟着上来。数据库锁虽然看着笨重但它的确定性是很大的优势——事务要么提交要么回滚语义清晰。对大多数普通业务场景来说做好顺序统一、索引下推、事务裁剪这三件事已经能把死锁问题压到足够低的水平。经历过这轮专项验证之后我最大的变化是线上再报数据库死锁第一反应不再是去调死锁检测参数而是去看这条update到底锁了多少行、几个事务的加锁顺序能不能统一、锁在事务里被持有多长时间。锁粒度优化不是某个数据库配置项能一劳永逸解决的它需要把代码路径、索引设计和事务边界放在一起同时改对。最后再分享一个小技巧任何锁相关的优化方案上线前都先做一轮带死锁统计的压测验证用数据说话比什么都可信。