MySQL事务与锁机制详解:保障数据一致性
做了这么多年数据库相关工作,MySQL的事务和锁机制一直是被问得最多、也最容易翻车的一块。不管是开发写业务代码时遇到死锁报错,还是DBA排查线上锁等待导致的慢查询,归根结底都在跟这两个概念打交道。很多面试题喜欢问“MySQL怎么保证数据一致性”,说白了,答案就藏在这套事务隔离级别、MVCC快照读和各类锁的协同运作里。
这篇文章我从实际使用角度出发,把MySQL事务与锁机制完整梳理一遍。适合刚接触MySQL的开发者建立整体认知,也适合有一定经验的老手查漏补缺,特别是那些平时只在应用层写CRUD、没怎么关注过底层隔离和并发控制的同学,看完应该能少踩不少坑。
1. 事务机制:一致性从哪来
1.1 ACID到底在说什么
事务这概念,理论上讲就是一组SQL操作要么全部成功、要么全部失败,不能只执行一半。MySQL里通过ACID四个特性来保障这件事,但很多人背了这四个字母却不知道它们各自靠什么机制实现。
先拆开看:
- 原子性(Atomicity):一个事务里的所有操作,要么全部提交,要么全部回滚。靠的是undo log,事务执行过程中如果出错,MySQL会根据undo log里记录的反向操作把数据恢复到事务开始前的样子。
- 一致性(Consistency):事务执行前后,数据库的完整性约束不能被破坏。这个其实是终极目标,由其他三个特性共同支撑,再加上约束、触发器这些数据库自身规则。
- 隔离性(Isolation):多个事务并发执行时,互相之间不能产生干扰。靠的是锁机制和MVCC多版本并发控制。
- 持久性(Durability):事务一旦提交,结果就永久保存,即使数据库崩溃也不能丢。靠的是redo log,事务提交时先把日志刷到磁盘,数据页随后再落盘。
市面上常见的误区是把InnoDB的“双写缓冲”“redo log刷盘策略”当成事务机制本体,其实它们只是保障持久性的具体工程手段。理解ACID时心里要有一条线:每个特性都有对应的底层组件在支撑,而不是一个抽象概念悬在空中。
1.2 事务的提交与回滚流程
一个事务从begin到commit,中间发生了什么,这个流程很多人没仔细看过。我画个简化版描述:
开启事务后,每次数据修改操作都会产生undo log和redo log。undo log记录的是“如果回滚,该怎么改回去”;redo log记录的是“这次改了哪个页、改成了什么”。这两份日志各有各的用途,数据真正写进磁盘数据页之前,redo log会先落盘。
接下来是commit的关键点:MySQL采用两阶段提交来协调redo log和binlog的一致性。prepare阶段写redo log并标记状态,然后写binlog,最后commit阶段把redo log状态改为提交。这中间任何一个环节崩溃,MySQL都能通过日志判断是回滚还是继续提交,确保主从复制场景下数据不会出现主库和从库各执一词的情况。
实际工作中最常见的坑是:一个事务里执行了大量更新操作却不提交,导致undo log膨胀、锁持有时间过长,后续会话全部堵在锁等待上。我自己就处理过一次事故,开发在测试环境跑了一个批量更新脚本,循环几十万次,每次都开事务但不提交,结果数据库的连接池直接被耗尽,所有业务请求全部超时。所以事务不是开得越久越好,小而快才是原则。
1.3 隔离级别的默认值由谁决定
MySQL InnoDB默认的隔离级别是Repeatable Read(可重复读),和Oracle、PostgreSQL默认的Read Committed不一样。这个选择有历史原因:MySQL的binlog在statement格式下,只有在Repeatable Read级别才能保证主从复制的一致性。
不过要注意,MySQL的Repeatable Read在特定的加锁读场景下仍然可能出现幻读(后面会细说)。网上很多文章说“MySQL的RR级别完全解决了幻读”,这话只对了一半——快照读不会幻读,但如果用了SELECT ... FOR UPDATE或LOCK IN SHARE MODE这类当前读,在RR级别下如果不走唯一索引,幻读依然存在。
隔离级别的设置方式有两种,一种是set session/global transaction isolation level,另一种是在配置文件中写transaction-isolation = READ-COMMITTED。生产环境如果需要调整,运维上一般建议在配置文件中统一改,避免某个连接自己设置了不同级别导致行为不一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并发问题与隔离级别:脏读、不可重复读、幻读的真实场景
2.1 三种异常读现象逐个拆解
理论研究隔离级别时,教材上都会提脏读、不可重复读、幻读这三种现象。只背定义容易忘,我直接说几个实战中的例子。
脏读:事务A修改了一条数据但还没提交,事务B读到了这个修改后的值。然后事务A回滚了,事务B拿着一个不存在的值继续做业务逻辑。这个发生在Read Uncommitted级别下。现实中极少有人用这个级别,但面试时它是很好的引子。
不可重复读:事务A先查了一条记录,值还是100,事务B把它改成200并提交,事务A再查一次发现变成200了。同一个事务内两次查询结果不一致。这个发生在Read Committed级别下。
幻读:事务A查询某个范围的数据,比如SELECT * FROM orders WHERE amount > 100,第一次查出5条;事务B插入了一条新记录并提交;事务A再次执行同样查询,发现变成了6条。多出来的这一条就是“幻影行”。幻读和不可重复读的区别在于:不可重复读是同一行记录的内容变了,幻读是结果集的行数变了。
理解这三个现象后,隔离级别就不难梳理了:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 |
| Read Committed | 不会 | 可能 | 可能 |
| Repeatable Read | 不会 | 不会 | 可能(加锁读场景) |
| Serializable | 不会 | 不会 | 不会 |
2.2 隔离级别和锁的关系
隔离级别本质上是“性能”和“一致性”之间的权衡。级别越低,并发能力越高,但一致性风险越大;级别越高,一致性越强,但锁的粒度越大、并发越差。
Serializable级别在MySQL里最简单粗暴——所有读操作自动加共享锁,写操作加排他锁,事务之间完全串行。实际业务中几乎没人用这个级别,因为性能太差。如果你发现业务确实需要Serializable才能保证数据不出错,那大概率是业务逻辑本身设计有问题,比如没有给关键记录加锁导致并发控制失效。
在Read Committed和Repeatable Read之间选型时,我的建议是:默认保持Repeatable Read不要动,因为这是InnoDB调优过的最佳平衡点。MySQL的RR级别通过MVCC已经解决了大部分幻读问题,只有当前读场景需要依靠间隙锁来阻止幻读。如果遇到特殊业务需要更低的锁竞争,比如高频更新同一行记录的计数类业务,可以在确认无幻读隐患的前提下改用Read Committed,这样能减少间隙锁带来的锁冲突。
2.3 实操:怎么验证你的隔离级别
为了让大家对隔离级别有直观感受,我建议自己动手做个实验。MySQL命令行里执行:
sql复制-- 查看当前会话隔离级别
SELECT @@transaction_isolation;
-- 设置当前会话为Read Uncommitted
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 开两个终端窗口模拟并发
-- 终端A
BEGIN;
UPDATE t_user SET balance = balance - 100 WHERE id = 1;
-- 终端B(在另一窗口)
BEGIN;
SELECT balance FROM t_user WHERE id = 1; -- 如果级别是READ UNCOMMITTED,这里会看到扣减后的值
这个实验建议大家都亲手跑一遍,把脏读、不可重复读、幻读各复现一次,比看一百篇文章都有用。我在给团队做内部分享时,就让大家现场做这个实验,很多人做完之后对“为什么要在业务里显式加锁”这个问题的理解完全不一样了。
3. 锁机制全景:共享锁、排他锁、意向锁、行锁与表锁
3.1 InnoDB锁的种类与粒度
把锁机制拆分来看,InnoDB的锁大致分这么几个维度:
按锁类型分:共享锁(S锁)和排他锁(X锁)。S锁之间兼容,多个事务可以同时持有S锁读同一行;S锁与X锁互斥,X锁之间也互斥。
按锁粒度分:表级锁和行级锁。表锁在MyISAM里是主流,InnoDB则实现了真正意义上的行锁。InnoDB虽然默认走行锁,但在某些场景下也会上表锁,比如DDL语句、LOCK TABLES显式操作,或者索引失效导致的全表扫描。
按行锁实现方式分:Record Lock(记录锁,锁单条索引记录)、Gap Lock(间隙锁,锁一个范围但不含记录本身)、Next-Key Lock(临键锁,记录锁和间隙锁的组合,锁范围和记录)。
这里有个常见的认知误区:很多人以为InnoDB的行锁是“直接锁那一行数据”,实际上InnoDB的行锁是锁在索引上的。如果一个表没有主键或唯一索引,InnoDB会隐式生成一个聚簇索引用于行锁定。这带来一个重要推论:如果更新操作没有走索引,行锁会升级成锁表(准确说是锁住所有扫描过的索引记录,看起来跟锁表一样),并发能力急剧下降。
3.2 意向锁的作用机制
意向锁是表级锁,却和行锁紧密相关。它的出现是为了解决一个效率问题:当一个事务持有某行的X锁时,另一个事务想给整张表加X锁,数据库怎么快速判断能不能加?
如果没有意向锁,每次加表锁都需要扫描整张表的所有行锁,代价太高。有了意向锁,每个事务在申请行锁之前,会先给表加上意向锁。意向锁分两种:IS(意向共享锁)和IX(意向排他锁),它们之间互相兼容。表锁和行锁在加锁时,需要通过检查意向锁来判断是否有冲突。
实际使用中你很少会直接操作意向锁,它是InnoDB自动维护的。但理解它有助于排查死锁和锁等待问题,因为SHOW ENGINE INNODB STATUS里输出的锁信息中经常能看到IX、IS这些标记。
3.3 Record Lock、Gap Lock和Next-Key Lock怎么配合
这是锁机制里最绕、也最容易把锁等待问题分析错的地方。
先说Record Lock,它锁的是索引记录本身,在唯一索引查询命中时使用。比如UPDATE t_user SET name = 'a' WHERE id = 100,在id是主键的情况下,只锁id=100这一条记录。
Gap Lock锁的是索引记录之间的空隙,防止其他事务在这个空隙里插入新记录,从而避免幻读。在Repeatable Read级别下,SELECT * FROM t_user WHERE age BETWEEN 20 AND 30 FOR UPDATE这个查询如果没有命中任何记录,会给(20, 30)这个范围加上间隙锁,其他事务想插入age=25的记录就会被阻塞。
Next-Key Lock是前两者的结合:锁住记录本身,也锁住记录前面的间隙。它的加锁范围是左开右闭的区间。比如索引上有20、25、30三条记录,Next-Key Lock可能锁的是(20, 25]这个范围。RR级别下,当查询走非唯一索引时,InnoDB默认使用Next-Key Lock。
要特别提醒的是:Gap Lock只在Repeatable Read级别才生效,Read Committed级别下InnoDB会禁用间隙锁,只保留记录锁。这也是为什么有些业务从RR切到RC后,死锁概率反而降低——因为锁的范围小了,但幻读风险上升了。
3.4 常用锁场景速查表
| 操作 | 加的锁 | 说明 |
|---|---|---|
| SELECT ... 普通查询 | 无(MVCC快照读) | 不加锁,读历史版本 |
| SELECT ... LOCK IN SHARE MODE | S锁 | 允许其他事务读,阻止写 |
| SELECT ... FOR UPDATE | X锁 | 阻止其他事务读写该行 |
| INSERT / UPDATE / DELETE | X锁 | 自动加锁 |
| 全表扫描的UPDATE | 表锁(实质为多行X锁) | 索引失效时是性能杀手 |
| 范围查询(非唯一索引) | Next-Key Lock | RR级别下防止幻读 |
3.5 实操:查看当前锁信息
遇到锁等待问题时,第一件事不是重启数据库,而是查一下当前锁的状态。常用手段:
sql复制-- 查看当前有哪些锁等待
SELECT * FROM sys.innodb_lock_waits;
-- 查看当前事务状态
SELECT * FROM information_schema.INNODB_TRX\G;
-- 查看锁相关信息
SELECT * FROM performance_schema.data_locks\G;
这些查询能直接看到谁阻塞了谁、持有锁的事务ID和运行状态。我处理线上问题时的标准流程是:先通过SHOW PROCESSLIST找出阻塞的会话,再用KILL杀掉持有锁的事务,最后顺着慢SQL分析为什么锁范围这么大。这条排查链路建议每个DBA和资深开发都刻在脑子里。
4. MVCC多版本控制:一条数据的多个历史版本
4.1 版本链与ReadView的配合
MVCC(Multi-Version Concurrency Control)是InnoDB在RR和RC级别下实现高性能读的核心。它的大致逻辑是:每行数据背后有一个版本链,每个版本对应一个事务的修改,版本链上的每个节点都记录着事务ID。当一个事务执行快照读时,它会生成一个ReadView,用来判断哪些版本对当前事务可见。
ReadView的可见性规则,核心是四个部分:活跃事务ID列表、最小活跃ID、最大ID、当前事务ID。判断一条记录的版本是否可见时,根据记录上的事务ID与这几个值的大小关系来决定走哪个版本。这个过程虽然复杂,但结论很简单——在RR级别下,事务开始时的ReadView会一直复用,所以同一个事务内多次快照读结果一致;在RC级别下,每次读都会生成新的ReadView,所以能读到其他事务已提交的最新数据。
4.2 快照读与当前读
MVCC解决的是快照读(Snapshot Read)的问题,也就是普通SELECT语句。但前面提过的加锁读,SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及INSERT、UPDATE、DELETE这些写操作,属于当前读(Current Read),它们必须读最新版本的数据,所以走的是加锁逻辑,不能使用MVCC的快照。
这就是为什么RR级别下快照读没有幻读,但当前读会有幻读风险。理解了这层关系,后续看死锁和锁等待时才能准确判断是不是加锁读引发的。
4.3 实操:直观感受快照读与当前读
用经典例子演示:
sql复制-- 终端A
BEGIN;
SELECT * FROM t_user WHERE id = 1; -- 假设返回 balance=100
-- 终端B
BEGIN;
UPDATE t_user SET balance = 200 WHERE id = 1;
COMMIT;
-- 终端A再次查询
SELECT * FROM t_user WHERE id = 1; -- RR级别下还是100,快照读
-- 但当前读:
SELECT * FROM t_user WHERE id = 1 FOR UPDATE; -- 读到200,说明读的是最新版本
这个差异在业务逻辑里一旦被忽略,就会出现“明明读到了数据,但更新后覆盖了别人的修改”这类问题。比如一个余额扣减服务,先查出余额,业务层计算新余额,再UPDATE写回。如果查询是快照读,两个并发请求可能都读到同一个旧值,后提交的覆盖先提交的,钱就丢了。解决思路就是要用SELECT ... FOR UPDATE或直接UPDATE t_user SET balance = balance - 100 WHERE id = 1这种原子操作。
4.4 回滚段和undo log的清理
MVCC依赖undo log保留历史版本,因此长事务会让undo log不断膨胀。如果一个事务长时间不提交,它可能需要的旧版本数据就一直不能被purge线程清理,导致磁盘空间消耗和性能下降。
这里给一个生产环境的建议:监控information_schema.innodb_trx里的事务运行时间,超过阈值就告警。我自己见过因为一个没提交的事务让undo表空间涨到几百G的情况,最终解决只能重建表。这种事故只要在开发规范里写清楚“事务必须快速提交或回滚”,就基本可以避免。
5. Spring事务、分布式事务与MySQL锁的实际协作
5.1 事务注解失效场景
热搜词里有“springboot 事务失效场景”,说实话,这类问题在开发中太常见了。最常见的失效原因有这么几个:
同一个类内部方法调用,this.selfMethod()方式调用的方法,事务注解不生效,因为Spring AOP代理没有介入。正确做法是注入自身的代理对象,或者把方法拆分到不同的Service里。
异常被捕获后没抛出,方法里try-catch了异常但没重新抛出RuntimeException,事务管理器感知不到异常,自然不回滚。
方法不是public,Spring的@Transactional默认只对public方法生效。
自建数据源没有配置事务管理器,Spring Boot自动配置失效,导致注解完全没用。
这些坑排查起来不难,但能在代码评审阶段发现最好。
5.2 分布式事务的常见方案
分布式事务是另一个高频热词。微服务架构下,一个操作要跨多个服务和多个数据库,单一数据库的事务就管不住了。主流方案有:
- 两阶段提交(2PC):由事务协调者统一协调各参与者的提交和回滚。实现简单,但同步阻塞、协调者单点问题明显,性能较差,适合对一致性要求极高、并发量不高的场景。
- TCC(Try-Confirm-Cancel):业务层面的补偿事务,每个服务都要实现Try、Confirm、Cancel三个方法,把业务操作拆成预留、确认、撤销三步。性能比2PC好,但开发成本高,对业务侵入性强。
- 本地消息表方案:在业务库中建一张消息表,业务操作和写消息在同一个数据库事务里完成,通过异步任务把消息发给下游。这是最终一致性方案,实现简单,适合大多数支付、订单类场景。
- RocketMQ等消息队列的事务消息:消息队列中间件提供的事务半消息机制,本质上是本地消息表方案的升级版。
我的经验是,分布式事务没有银弹。如果业务能接受最终一致性,尽量用本地消息表或事务消息;如果必须强一致,2PC可以考虑,但要做好性能损失的准备。
5.3 补充问答:分布式事务与MySQL锁有关系吗
有关系,但关系不直接。分布式事务框架协调多个数据库节点时,每个节点内部仍然依赖MySQL自己的事务和锁来保证本地一致性。所谓Seata AT模式,就是通过代理数据源解析SQL、生成undo快照,在事务提交阶段统一提交或回滚,但每个参与节点上的锁管理仍然是由本地数据库完成的。
所以,学好MySQL本身的锁机制,是理解分布式事务方案的基础。如果连本地事务的隔离和锁都没搞明白,直接上手分布式事务只会更懵。
5.4 Spring事务传播行为速查
Spring的事务传播行为定义了事务方法被另一个事务方法调用时,事务如何扩散。常用的几个:
| 传播行为 | 说明 |
|---|---|
| REQUIRED | 默认值,有事务就加入,没有就新建 |
| REQUIRES_NEW | 挂起当前事务,新建独立事务 |
| NESTED | 嵌套事务,需要数据库支持savepoint |
| MANDATORY | 必须在事务中执行,否则抛异常 |
| NOT_SUPPORTED | 以非事务方式执行,挂起当前事务 |
| NEVER | 非事务执行,存在事务则抛异常 |
日常开发用得最多的就是REQUIRED和REQUIRES_NEW。REQUIRED的坑在于,如果多个Service方法在同一个事务里,某个环节出异常但被外层catch住了,整个事务可能还是回滚了,导致业务数据全没。REQUIRES_NEW则常用于日志、审计这类即使主事务失败也希望能独立记录的场景。
6. 死锁排查与实战案例:从日志定位到代码修复
6.1 死锁产生的四个必要条件
死锁的经典定义是:两个或多个事务互相持有对方需要的锁资源,谁都不释放,导致都无法继续执行。对应到MySQL里,四个条件缺一不可:
- 互斥条件:资源只能被一个事务持有
- 持有并等待:事务已经持有某个锁,同时又去申请另一个锁
- 不可剥夺:锁只能由持有者主动释放
- 循环等待:多个事务形成一个等待环路
6.2 实战案例一:两条SQL交叉更新导致死锁
假设两张表:订单表orders和库存表inventory。业务里有这么一段逻辑:事务A先更新orders再更新inventory,事务B先更新inventory再更新orders。当两个事务并发执行时:
sql复制-- 事务A
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE order_id = 100;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 200;
COMMIT;
-- 事务B(并发)
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 200;
UPDATE orders SET status = 'paid' WHERE order_id = 100;
COMMIT;
A持有orders锁请求inventory锁,B持有inventory锁请求orders锁,死锁形成。解决方法是统一加锁顺序,让所有事务都按同一顺序访问表。代码评审时发现这种交叉更新逻辑,应该第一时间打回去重写。
6.3 实战案例二:批量更新间隙锁竞争
业务场景是订单表按status字段批量更新,status上有普通索引。两个事务分别更新status=0和status=1的订单,在RR级别下,因为走的是非唯一索引,InnoDB会对扫描范围加Next-Key Lock。如果两个范围有重叠,就会在间隙锁上互相等待,最终死锁。
这种问题排查起来比较隐蔽,因为看着是两条互不相关的SQL,实际上被锁范围重叠了。处理办法有几个:把隔离级别改成Read Committed去掉间隙锁;或者改成按主键id逐条更新;或者更新时带上精确的唯一键条件缩小锁范围。
6.4 死锁日志怎么读
MySQL检测到死锁后,会在日志里输出详细事务信息,一般记录在MySQL错误日志中,或者通过SHOW ENGINE INNODB STATUS查看LATEST DETECTED DEADLOCK部分。日志里有两个关键段落:
TRANSACTION区块:两个事务各自持有哪些锁、正在等待哪些锁。WE ROLL BACK TRANSACTION标识:MySQL牺牲了哪个事务。
读日志时重点关注锁模式(record lock还是gap lock)、锁的索引名、等待的SQL语句。大多数死锁通过日志能直接定位到出问题的SQL,剩下的工作就是调整SQL执行顺序或加锁粒度。
6.5 避免死锁的经验清单
这些经验都是我一个个坑踩出来的:
- 保持事务短小:事务里只放必要的SQL,把耗时的外部调用移出事务。
- 统一访问顺序:多个表交互时,所有代码都按相同顺序更新。
- 合理使用索引:让UPDATE、DELETE的WHERE条件走索引,避免全表扫描导致锁范围过大。
- 降低隔离级别:如果业务允许,从RR降到RC能显著减少间隙锁死锁。
- 必要时重试:分布式系统里死锁偶尔出现是无解的,业务层捕获死锁异常后自动重试几次,是最后的兜底手段。
7. 索引与锁的联动:为什么索引失效会导致锁表
7.1 索引在行锁中的核心作用
前面反复提到行锁是锁在索引上的。为了把它说透,拿一个具体场景演示:
sql复制UPDATE t_order SET status = 'shipped' WHERE order_no = 'ORD123456';
如果order_no有唯一索引,这条SQL会精确命中一条记录,锁一行就结束。但如果order_no没有索引,或者order_no字段类型不匹配导致索引失效,MySQL只能全表扫描。扫描过程中,它把所有访问过的记录都加上了X锁,锁范围基本等于整张表,其他任何写操作都进不来。
这就是线上一个普通UPDATE语句拖垮整个库的典型原因。处理锁表问题时,第一个检查点永远是执行计划:
sql复制EXPLAIN UPDATE t_order SET status = 'shipped' WHERE order_no = 'ORD123456';
看type字段是const、ref还是ALL,如果出现ALL,基本就是全表扫描了。
7.2 隐式锁与插入意向锁
InnoDB还有一个机制叫隐式锁,专门用来降低INSERT时的加锁开销。新插入的记录在事务未提交前,不会被显式加锁,其他事务来查询时通过事务ID判断这条记录是否可见,如果不可见就等待。等到其他事务真的需要操作这条记录时,隐式锁才转换为显式锁。
插入意向锁则在多个事务同时往同一间隙插入数据时发挥作用。插入意向锁之间是互相兼容的,所以多个事务可以同时往同一个间隙的不同位置插入,只有在间隙本身被Gap Lock锁住时才会阻塞。这个机制保证了高并发插入场景下不会把插入操作完全串行化。
7.3 表级LOCK TABLES还有没有必要用
明确说,在InnoDB表上,LOCK TABLES ... WRITE这种操作应该尽量避免。它会把整张表锁住,所有并发读写全部阻塞,而且InnoDB本身有更好的行锁机制,完全没必要用表锁。真正需要担心的是ALTER TABLE这类DDL操作,8.0版本支持了原子DDL,但变更大表时依然会占用资源锁、影响线上业务。生产环境做大表变更时,推荐用gh-ost或pt-online-schema-change这类在线变更工具。
8. 生产环境常见锁等待和事务问题速查
8.1 高频问题清单
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 应用报Lock wait timeout exceeded | 事务持有锁时间过长,或锁范围过大 | 查INNODB_TRX找长时间未提交事务,优化SQL走索引 |
| 大量事务处于Running但无进展 | 锁等待堆积 | 查sys.innodb_lock_waits看阻塞链源头 |
| 批量更新耗时突然变长 | 出现了全表扫描更新 | EXPLAIN分析执行计划 |
| 死锁日志疯狂刷 | 事务间加锁顺序冲突 | 统一代码里操作的顺序,或缩小锁粒度 |
| 读多写少业务性能下降 | 锁竞争加剧 | 考虑读从库、增加缓存减少数据库并发 |
8.2 线上锁等待的真实处理过程
有一次线上告警,商品模块更新接口大面积超时。登录数据库后先看SHOW PROCESSLIST,发现几十个会话卡在UPDATE goods SET stock = stock - 1 WHERE goods_id = ?这条SQL上。再用INNODB_TRX查事务,发现有个事务已经运行了15分钟,一直在执行一个循环更新库存的操作但没提交。
当时没有直接kill这个长事务,因为不确定它是否在关键业务链路上,kill后应用能否正确回滚。先联系开发确认这是他们测试脚本误跑到了生产库,然后执行KILL杀掉会话,所有阻塞的请求立刻恢复。事后复盘根本原因:测试脚本在循环里开启了事务却没有及时提交,导致锁持有时间超出业务容忍范围。
8.3 日常监控建议
事务和锁的问题要做到提前发现,建议从这几方面监控:
- 事务运行时长:超过30秒的事务告警。
- 锁等待次数:
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits',这个值持续增长说明锁竞争在加剧。 - 锁等待时间:
Innodb_row_lock_time_avg平均值过高时,优先排查慢SQL。 - 慢查询日志:定期分析慢SQL,凡是全表扫描的写操作一律优化。
9. 事务隔离级别选择的工程建议
很多团队上来就用默认的RR,遇到死锁就改RC,改了又担心幻读,来回摇摆。我的建议是设计阶段就明确业务对一致性的要求,然后针对性选择。
如果业务场景是订单、支付、库存这类对资金和数据准确性要求极高的,需要防串改和防超卖,RR级别配合唯一索引再加必要的当前读锁定,是最稳妥的。
如果业务是论坛帖子、操作日志、计数统计这类数据错了也能容忍或可以通过补偿纠正的,用RC级别可以降低锁开销,提升并发吞吐量。
还需要注意一个点:改为RC级别后,binlog格式如果还是statement,主从复制可能出现不一致。8.0默认binlog是ROW格式,这个风险基本不存在了,但如果是5.7及以下版本,升级前要检查binlog_format配置。
10. 实操总结与长期积累建议
最后说说我怎么看待MySQL事务与锁机制的学习路径。很多初学者一股脑扎进死锁日志和隔离级别源码里,结果越看越乱。我的建议是倒过来,先从业务场景入手:用工具复现脏读、不可重复读、幻读,亲手把锁等待和死锁跑出来,再回头研究MVCC和锁的底层实现。有了具象认知,理论才真正消化得了。
工具方面可以多用用EXPLAIN、SHOW ENGINE INNODB STATUS、sys库的锁查询视图,这些都是排查问题的利器。团队内部可以把常见问题沉淀成文档,比如“事务注解失效的排查清单”“死锁日志解读模板”“线上锁等待标准处理流程”,这些实战积累比任何架构书都值钱。
写SQL时心里始终绷着一根弦:这条语句会锁多少行、锁多久、和哪些并发事务可能冲突。把这根弦绷住,很多坑就能在代码评审阶段避开,而不是等线上出问题了再去救火。
