先说一次真实的线上事故。业务方凌晨两点发来消息:活动商品明明有库存,用户下单却频繁报“库存不足”。查日志发现同一件商品的库存字段被多个并发事务覆盖,两个扣减操作同时读到旧值,导致最终库存比实际销售多扣了不少。这种场景就是典型的数据一致性问题,也是MySQL事务和锁机制存在的根本意义。
这篇文章围绕MySQL事务与锁机制展开,重点拆解数据一致性是怎么被破坏的、隔离级别如何取舍、InnoDB的锁到底锁什么、MVCC和当前读如何配合,以及应用层事务注解和死锁排查的完整链路。适合正在准备数据库方向面试的后端开发、刚接手线上MySQL的DBA,以及所有被“数据对不上”折磨过的同学。
1. 先看清数据一致性从哪里被破坏的
很多人背过ACID,知道事务有原子性、一致性、隔离性、持久性,但一遇到真实并发场景就懵。问题不在于不懂概念,而在于没有把“不一致”具体到某一种可复现的操作序列上。
1.1 一个并发扣库存的丢失更新现场
假设有一张库存表:
sql复制CREATE TABLE inventory (
product_id INT PRIMARY KEY,
stock INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO inventory VALUES (1001, 10);
两个用户同时下单购买同一商品,后台代码各自执行:
sql复制UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
直觉上会觉得两条UPDATE是先后执行的,最终库存从10变成8。但真实数据库里两个事务可能这样交错执行:
| 时间线 | 事务A | 事务B | 库存实际值 |
|---|---|---|---|
| T1 | BEGIN | 10 | |
| T2 | BEGIN | 10 | |
| T3 | 读到stock=10 | 10 | |
| T4 | 读到stock=10 | 10 | |
| T5 | stock=9,提交 | 9 | |
| T6 | stock=9,提交 | 9 |
两个事务都认为自己的扣减是成功的,但最终库存只减了1,而不是2。更隐蔽的是,如果程序里先查询库存再在Java代码里做减法,最后再UPDATE,问题会进一步放大:
java复制// 典型的错误写法
int stock = selectStock(productId); // SELECT stock FROM inventory WHERE product_id = ?
if (stock <= 0) { throw new RuntimeException("库存不足"); }
updateStock(productId, stock - 1); // UPDATE inventory SET stock = ? WHERE product_id = ?
这就是丢失更新。两个事务基于同一个旧值计算结果,后提交的一方覆盖前提交的一方的结果。要解决它,最直接的方法就是让两个事务串行执行,或者让后执行者基于最新值做运算。MySQL的锁机制和乐观锁方案都是为了处理这类场景。
1.2 脏读、不可重复读与幻读到底长什么样
丢失更新之外,还有三类经典异常。
脏读是事务A读到了事务B尚未提交的数据。如果事务B回滚,事务A就用了一个根本不存在的结果做后续判断。比如商品库存从10改成5,事务B还没有提交,事务A就按5去生成采购计划,结果B回滚,A就白忙一场。
不可重复读是同一事务内两次查询同一行,得到不同的结果。事务A第一次查到单价100元,事务B把单价改成了80元并提交,事务A再查一次变成80元。A在同一个事务里看到同一个商品两个价格,业务报表就没法做了。
幻读针对的是范围查询。事务A执行了SELECT * FROM orders WHERE status = '待支付',第一次查到100条,事务B插入了一条新的待支付订单并提交,事务A再查一次变成101条。多出来的那条记录就像幻觉一样。
这三类异常加上丢失更新,构成了并发事务需要解决的四大问题。不同隔离级别对它们的容忍程度不同,所以不能笼统地说“开了事务就一定安全”,要看你选了什么隔离级别。
1.3 一致性的边界:单库事务不等于分布式一致
MySQL的ACID一致性是在单个数据库实例内保障的。它通过回滚机制、约束条件和锁来保证事务从一个合法状态切换到另一个合法状态,不破坏业务规则。但如果你的事务涉及多个MySQL实例、MySQL和Redis、或者订单服务加库存服务两个独立应用,单库事务就管不住了。
这也是为什么热词里总会出现“分布式事务”“分布式事务一致性”。它们解决的是跨资源的一致性问题,需要TCC、SAGA、本地消息表或最终一致性方案。理解MySQL事务和锁机制时,务必先把边界画清楚:InnoDB的锁只对本实例内的行和表生效,跨库跨服务的原子性超出了它的管辖范围。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 隔离级别的取舍:从读未提交到可串行化的真实代价
事务隔离级别是连接“一致性需求”和“数据库性能”的中间层。SQL标准定义了四个级别,但不同数据库实现并不完全一致,MySQL的InnoDB在默认隔离级别下还有自己的特殊行为。
2.1 四种隔离级别解决了什么、漏掉了什么
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本,不加锁 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 每次读都生成新的快照 |
| REPEATABLE READ | 不可能 | 不可能 | 部分可能(InnoDB下基本不可能) | 首次读生成快照,事务内复用 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 所有读都加锁,基本串行 |
因为InnoDB使用了MVCC和间隙锁,在默认的REPEATABLE READ下已经能避免大部分幻读,所以很多面试题里会专门追问“MySQL的RR到底会不会幻读”。标准答案是:在当前读场景下,RR通过临键锁阻止了幻读;在快照读场景下,MVCC本身就让事务看不到其他事务新插入的数据。真正还没被完全禁掉的,是某些锁与快照混合的场景,实际生产中较少遇到。
READ UNCOMMITTED基本只在做数据调研、批量探查时有人用。它不生成快照,总能读到别的事务没提交的最新值,性能高但唯一价值就是“快”,作为业务事务的隔离级别是在赌人品。
READ COMMITTED是很多互联网团队的最终选择。它避免了脏读,但一个事务内两次SELECT可能拿到不同结果。Oracle默认就是RC,很多从Oracle迁移过来的团队沿用这个习惯。
SERIALIZABLE则把读写都变成互斥队列。你能想象有多慢,除非是银行转账、账务调整这类低并发高安全场景,否则不要在OLTP核心链路上开启。
2.2 为什么InnoDB默认RR,而很多人会改成RC
如果仔细看过MySQL的官方文档就知道,InnoDB的默认隔离级别是REPEATABLE READ。原因之一是MySQL在主从复制架构下,早期版本在RC级别使用基于语句的binlog时,会出现主从数据不一致的问题。
举个例子,主库执行一条DELETE FROM orders WHERE status = '待支付',如果这条语句在从库回放时,因为数据状态发生了变化而删除了不同行,就会导致主从不一致。RR级别下,事务通过间隙锁让这类语句锁住一个范围,从库回放时只要SQL执行顺序一致,结果就能对齐。RC级别配合ROW格式的binlog也能解决这个问题,但很多旧系统在升级前并不具备条件。
另一个原因是MVCC让RR在快照读场景下并不比RC慢太多。普通SELECT不加锁,读的是undo log里的版本链,两边成本接近。真正的差距出现在写入热点:RR因为要加间隙锁和临键锁,锁范围更大,死锁概率更高。
所以现在的趋势是:如果主要靠行锁、业务能用乐观锁控制并发,可以显式切到RC,减少锁等待和死锁。如果你的业务有大量范围更新、范围查询且需要严格的一致性快照,保留RR更稳。
2.3 修改隔离级别:参数和SQL两种方式
全局修改可以在配置文件里加:
ini复制[mysqld]
transaction-isolation = READ-COMMITTED
运行时动态调整:
sql复制SET GLOBAL transaction_isolation = 'READ-COMMITTED';
SET SESSION transaction_isolation = 'READ-COMMITTED';
注意MySQL 5.7之后系统变量从tx_isolation改成了transaction_isolation,旧版本用SET tx_isolation也可以,但新版本里tx_isolation已经废弃。单独一个事务也可以指定:
sql复制SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 业务SQL
COMMIT;
配置完成后,要验证当前会话是否生效,执行SELECT @@transaction_isolation;。生产环境修改隔离级别前,务必先确认应用连接池中的连接是否会被旧会话携带的级别影响,最好在发布窗口内同时重启应用或等待连接池回收连接。
3. InnoDB锁机制拆解:锁的粒度远比你想的细
MySQL的锁可以分为全局锁、表级锁和行级锁。日常开发里最常打交道的是InnoDB的行级锁,但它分为好几种子类型,理解它们才能看懂死锁日志。
3.1 从全局锁到行锁:一张表看清楚
| 锁类型 | 范围 | 主要场景 | 注意事项 |
|---|---|---|---|
| 全局锁 | 整个实例 | FLUSH TABLES WITH READ LOCK,备份用 |
锁住期间所有写操作阻塞 |
| 表级锁 | 单张表 | MyISAM默认、DDL变更、显式LOCK TABLES | 开销小但并发粒度粗 |
| 元数据锁 | 单张表 | DDL与DML并发时 | 长事务会卡住后续所有DDL |
| 意向锁 | 表级别标记 | 行锁与表锁的快速冲突检测 | IS/IX之间不互斥 |
| 记录锁 | 单条索引记录 | SELECT ... FOR UPDATE |
行锁的真实载体是索引 |
| 间隙锁 | 索引区间 | RR下防止幻读 | 锁住间隙,不允许插入 |
| 临键锁 | 记录+前方间隙 | RR默认的行锁策略 | 左开右闭区间 |
| 插入意向锁 | 间隙内的插入意图 | 多个插入并发时 | 间隙锁冲突时等待 |
全局锁和表锁看名字就懂,但对InnoDB来说,行锁才是主角。不过在业务高峰期执行一条ALTER TABLE,即使加了ALGORITHM=INPLACE,也可能因为长事务持有MDL导致后面所有查询全部排队。这种情况排查起来非常隐蔽,因为SHOW PROCESSLIST里只看到一堆查询处于Waiting for table metadata lock。
3.2 行锁是索引的附属品:不走索引会出大事
InnoDB的行锁并不是直接锁“物理行”,而是锁在索引记录上。这意味着如果你的UPDATE、DELETE条件没有命中任何索引,InnoDB只能扫描聚簇索引的所有记录,然后对每一条符合条件的记录加锁。扫描过程中,即使最终只更新一条,其他被扫描的未更新记录也会被锁住。
我见过一次线上故障:运营人员执行了一条UPDATE user_points SET points = points + 100 WHERE user_name = 'xxx',但user_name列没有索引。这个表有400万行,这条UPDATE在RR级别下相当于把所有扫过的行都加了排他锁,业务侧其他用户积分变更全部卡死,数据库的线程占用直接拉满。
正确的做法是给查询条件加索引:
sql复制ALTER TABLE user_points ADD INDEX idx_user_name (user_name);
如果确实无法建索引,更新前先SELECT主键,然后按主键分批更新,把锁的粒度降下来。这个案例也解释了为什么面试官总喜欢问“行锁一定会锁行吗”——不走索引时,它锁的范围可能接近整张表。
3.3 间隙锁与临键锁:防幻读的关键
临键锁是记录锁和间隙锁的组合。假设表里有一列order_no,当前数据有1001、1005、1010三条,且列上有普通索引。RR级别下,事务执行:
sql复制SELECT * FROM orders WHERE order_no > 1002 FOR UPDATE;
InnoDB不仅锁住1005和1010两条记录,还会锁住(1002, 1005)这个区间和(1005, 1010)区间,以及1010之后的间隙。也就是说,另一个事务想插入一条order_no=1006的记录,会被阻塞。这样第一个事务再次范围查询时,不会出现多出来的记录。
间隙锁有个容易被忽略的特性:它锁的是“某个区间内不能插入新记录”,而不是锁某条具体记录。所以即便你查询的区间里没有数据,比如WHERE order_no BETWEEN 100 AND 200,也可能把100到200之间的间隙全部锁住,阻塞别人向里面插入。
对唯一索引的等值查询,InnoDB会做优化:如果能定位到唯一记录,临键锁会退化成记录锁,不再锁间隙。如果唯一索引等值查询没命中记录,也同样会加间隙锁,阻止任何满足条件的记录插入。
3.4 意向锁到底在干什么
意向锁是表级别的一个标记,目的是快速判断“这张表是否已经有人持有行锁”。比如事务A锁了表里某一行,事务B想对整个表执行LOCK TABLES ... WRITE,如果没有意向锁,B得逐行检查有没有行锁,代价太高。
实际执行时,事务A给某一行加X锁之前,会先给表加IX锁。事务B想加表级X锁时,发现表上已经存在IX锁,立刻知道有行锁存在,于是等待。意向锁之间不会互相阻塞,所以多个事务可以同时持有属于自己的IX锁,分别锁不同行,这也是InnoDB高并发的基础。
4. MVCC与锁的配合:快照读和当前读各有各的活
InnoDB大幅提升并发能力靠的是MVCC多版本并发控制。它让“读”不阻塞“写”,“写”不阻塞“读”,两者通过版本链隔离开,而不是像老式数据库一样用一把大锁把读写全部串起来。
4.1 undo log版本链与ReadView
每一行记录除了业务数据,还有两个隐藏列:DB_TRX_ID记录最后修改它的事务ID,DB_ROLL_PTR指向上一个版本的undo log记录。每次UPDATE不会原地覆盖数据,而是生成一个新版本,并把旧版本链到undo log中。
当一个事务执行普通SELECT时,会生成一个ReadView(一致性视图)。ReadView里记录了当前活跃事务的ID集合,用来判断版本链上的哪个版本对当前事务可见。具体判断规则可以通俗理解为:版本的事务ID如果是自己,可见;如果是未提交的其他事务,不可见;如果是已经提交且比ReadView早的,可见。
4.2 不同隔离级别下快照读的差别
READ COMMITTED下,事务内每次SELECT都会新建ReadView,所以能读取到其他事务已提交的新版本,导致不可重复读。REPEATABLE READ下,只在事务内第一次SELECT时生成ReadView,后续都复用这个快照,所以其他事务提交了也看不到,天然解决了不可重复读。
举个例子:
sql复制-- 事务A
BEGIN;
SELECT stock FROM inventory WHERE product_id = 1001; -- 读到10
-- 事务B
UPDATE inventory SET stock = 8 WHERE product_id = 1001;
COMMIT;
-- 事务A再查
SELECT stock FROM inventory WHERE product_id = 1001; -- RR下还是10,RC下读到8
COMMIT;
这就是为什么RR能在不加锁的情况下保持一个事务内的一致性视图。对报表类逻辑特别友好,一份事务里所有查询都基于同一个数据库快照。
4.3 当前读:真正触发加锁的操作
普通SELECT是快照读,不加锁。但UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE都是当前读,读取的是记录的最新版本,并且会对读取到的记录加锁。
为什么UPDATE不能用快照读?因为如果要基于最新数据做修改,就必须看到事务启动后其他事务已提交的变更,否则又会出现丢失更新。当前读就是拿最新版本加锁、修改、生成新版本。
假设我在代码里用SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE,事务B再执行同样的语句就会阻塞,直到事务A提交或回滚。这保证了库存扣减前,其他写事务无法修改同一行。这种方式是悲观锁,适合冲突概率高的场景。
MVCC和锁配合的底层逻辑是:读写分离。读走版本链,不加锁;写走当前读,加锁串行。因此同一行数据可以同时有一个写事务在修改,多个读事务在查历史版本,互不干扰。
sql复制BEGIN;
SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE;
-- 确认库存足够后
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
COMMIT;
如果业务并发量高但冲突少,可以用乐观锁方案:给表加一个version字段,更新时比对版本号。
sql复制UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 1001 AND version = 5;
如果影响行数是0,说明版本变了,业务上需要重试或提示用户。
5. 到了应用层:事务边界、传播行为与常见的坑
数据库层的锁机制最终要由应用层的代码来调用。Spring的@Transactional是绝大多数Java后端使用事务的方式,但它并不是“无脑加注解就安全了”。我踩过的坑几乎都集中在事务边界控制上。
5.1 显式事务和自动提交的正确姿势
MySQL默认开启autocommit,每条SQL语句执行完自动提交。如果业务操作是单条UPDATE,天然原子。但大多数业务需要多条SQL共同完成一个动作,比如先扣减库存再生成订单,这两条SQL必须放在同一个事务里。
JDBC手动控制事务:
java复制Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
updateStock(conn, productId, -1);
insertOrder(conn, userId, productId);
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
conn.close();
}
Spring环境下更简单的做法是加@Transactional。
5.2 事务传播行为:七种组合别乱选
Spring事务传播行为定义了“当前方法遇到已有事务时怎么办”。面试常考,实际也容易踩坑。
| 传播行为 | 含义 | 使用建议 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建 | 默认值,大部分业务选它 |
| REQUIRES_NEW | 挂起当前事务,新建一个独立事务 | 日志记录、消息发送等不希望跟随主事务回滚的场景 |
| NESTED | 有事务则创建Savepoint嵌套事务 | 分段回滚,部分成功部分回滚 |
| SUPPORTS | 有事务就加入,没有就非事务执行 | 查询方法可选 |
| NOT_SUPPORTED | 必须非事务执行,有事务先挂起 | 事务内尽量别做长时间外部调用 |
| MANDATORY | 必须已有事务,否则报错 | 强制调用方开启事务 |
| NEVER | 必须没有事务,否则报错 | 确保不在事务内执行的场景 |
最常见的坑有两个。第一个是REQUIRES_NEW的使用者以为能独立提交,但主事务随后回滚时,新事务已经提交,数据就无法撤回。第二个是同类内部方法调用导致传播行为失效,因为Spring事务基于AOP代理,this.method()调用不会经过代理对象。
5.3 自调用、异常被吞和长事务
Service类里常见这种写法:
java复制public void createOrder(OrderDTO dto) {
saveOrder(dto);
deductStock(dto.getProductId());
}
如果createOrder和deductStock在同一个类中,且只在createOrder上加了@Transactional,deductStock中的SQL就会以非事务方式独立提交,之前辛苦设计的原子性直接失效。解决方案是拆分Service,或者把事务放到一个独立的入口方法上。
另一个隐蔽问题是异常被吞:
java复制@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
try {
// 扣库存
int result = inventoryMapper.deductStock(dto.getProductId());
if (result == 0) {
throw new RuntimeException("库存不足");
}
} catch (Exception e) {
log.error("扣库存失败", e);
// 异常被吞,事务照样提交
}
}
Spring默认只在RuntimeException和Error时回滚,如果方法里抛出CheckedException,事务不会回滚。需要显式配置rollbackFor = Exception.class,同时要确保不要在事务方法里自行捕获异常后吞掉。事务内一旦捕获异常并正常返回,事务框架就认为执行成功,直接提交。
长事务也经常出现在事务里调用远程接口、执行批量循环更新、或者锁了太多行。事务开启时间越长,持有锁的时间越长,其他事务等待的概率越高。我见过有同事在一个事务里循环调用第三方供应商接口几十次,单事务耗时十几秒,直接把数据库连接池占满,最终拖垮了整个应用。正确做法是把远程调用放在事务外,事务内只做本地数据操作。
6. 锁等待与死锁排查:从现象到结论的完整链路
锁机制解决了一致性问题,也引入了新的故障类型:锁等待超时和死锁。下面是完整的排查链路,遇到类似问题可以直接照做。
6.1 先确认是不是锁等待
业务报错日志里看到Lock wait timeout exceeded; try restarting transaction,说明事务等待其他事务释放锁超过了innodb_lock_wait_timeout默认的50秒。此时需要查清楚谁持有锁、持有了多久。
查询正在运行的事务:
sql复制SELECT * FROM information_schema.innodb_trx\G
这个表里有trx_id、trx_state、trx_started、trx_mysql_thread_id、trx_query等字段。重点看trx_state为RUNNING或LOCK WAIT的记录,还有启动时间特别早的事务,大概率是它一直占着锁不提交。
在MySQL 8.0中,锁的明细可以查:
sql复制SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G
通过data_locks能看到锁类型、锁模式、锁所在的库表、索引名称和具体记录。通过data_lock_waits能拼出谁在等谁。找到源头后,如果确认是遗留事务,需要联系对应应用或者DBA处理,必要时KILL掉会话。
sql复制-- 根据trx_mysql_thread_id找到会话后
SHOW PROCESSLIST;
KILL <thread_id>;
6.2 一个死锁场景复盘
死锁的报错通常是Deadlock found when trying to get lock; try restarting transaction。InnoDB检测到死锁后,会选择回滚其中一个事务,让另一个继续。应用程序如果没做重试,用户就会看到偶发失败。
两个事务操作多行数据时顺序不一致最容易触发死锁:
| 时间线 | 事务A | 事务B |
|---|---|---|
| T1 | UPDATE inventory SET stock=stock-1 WHERE product_id=1001;(锁1001) |
|
| T2 | UPDATE inventory SET stock=stock-1 WHERE product_id=1002;(锁1002) |
|
| T3 | UPDATE inventory SET stock=stock-1 WHERE product_id=1002;(等待B释放1002) |
|
| T4 | UPDATE inventory SET stock=stock-1 WHERE product_id=1001;(等待A释放1001) |
InnoDB检测到循环等待后,回滚其中较小的事务。查看死锁详情要用:
sql复制SHOW ENGINE INNODB STATUS\G
输出中的LATEST DETECTED DEADLOCK部分会清楚列出两个事务各自持有的锁和等待的锁,还会显示最后执行的SQL。根据这些信息,可以还原出业务代码中到底哪两条SQL发生了交叉。
这类死锁的常见解法是固定多个资源的访问顺序。比如商品A和商品B都在一次操作中扣库存,就按product_id从小到大更新,两个事务都先锁1001再锁1002,就不再产生循环等待。
查看死锁变量的命中情况:
sql复制SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits';
如果死锁次数本身不高,应用层做好重试即可:
java复制@Retryable(value = CannotAcquireLockException.class, maxAttempts = 3, backoff = @Backoff(delay = 100))
public void deductStock(int productId) {
// 扣库存逻辑
}
6.3 减少锁冲突的常规思路
从数据库层面减少锁冲突,有几个方向:尽量让WHERE条件命中索引,缩小加锁范围;隔离级别在业务允许时使用READ COMMITTED,减少间隙锁;控制单事务处理行数,避免大批量UPDATE一次锁几万行;把查询历史和事务隔离要求不高的读操作放到从库,减少主库写锁竞争。
从业务设计层面,可以采用异步化削峰、令牌桶限流降低并发写;也可以把热点库存行拆成多个子库存行,减少单行锁竞争。比如活动库存拆成50个子库存桶,每次随机扣一个桶,最终一致性通过汇总字段保证。只要商品库存量不是极高的场景,这类优化带来的复杂度明显高于收益,不建议盲目拆分。
排查数据一致性问题的整体顺序应该是:先看应用日志有没有锁等待超时,再看事务是否长时间未提交,然后查锁明细定位会话,最后根据死锁日志修正SQL顺序或事务边界。不要一上来就重启数据库,重启能暂时清掉所有锁,但也会让所有未提交事务回滚,还可能掩盖真正的坏SQL。
最后分享一个排查工具的使用习惯。我每次定位死锁时间线时,都会把SHOW ENGINE INNODB STATUS的输出保存成文件,再对照业务日志里的调用链路逐条核验。死锁信息里会显示事务ID、持有锁和等待锁的索引记录,比如space id 21 page no 4 n bits 80 index PRIMARY这样的字段,看着枯燥,但它们能精准定位到底冲突在哪一行。配合performance_schema.data_locks里返回的OBJECT_NAME和INDEX_NAME,基本能还原完整过程。别只盯着“死锁”两个字,真正的价值在它给出的锁序里。
