在前面的章节中,我们提到了 MVCC(多版本并发控制),它巧妙地通过"版本快照"解决了"读-写"冲突,实现了非阻塞读。
但如果两个事务同时执行 UPDATE 操作修改同一行数据,即 写-写(Write-Write) 场景,快照就没用了。这时候,MySQL 必须亮出它的铁腕手段------锁机制。
一、写-写冲突的核心问题:更新丢失
想象一下这个场景:
-
初始状态: 账户余额 100 元。
-
事务 A: 取出 100 元,准备扣款。它先读到 100,然后执行 100 - 100 = 0。
-
事务 B: 同一时刻取出 50 元,也先读到 100,执行 100 - 50 = 50。
-
结果: 如果事务 A 先提交,事务 B 后提交,B 的结果(50元)会覆盖 A 的结果(0元)。银行莫名其妙亏了 50 元。这种现象就叫 "更新丢失"。
二、MySQL 的解决方案:排他锁(X锁)
为了防止更新丢失,InnoDB 存储引擎采用了 行级锁(Row-level Locking)。
当一个事务准备修改(UPDATE/DELETE)一条记录时:
-
它会先尝试获取该行的 排他锁(Exclusive Lock,简称 X 锁)。
-
如果事务 A 拿到了锁: 它就可以进行修改。
-
如果事务 B 也想修改: 发现 X 锁已被 A 占用,事务 B 必须进入 阻塞等待 状态,直到事务 A 提交或回滚释放了锁。
结论: 在"写-写"场景下,事务是串行化执行的。只有拿到锁的事务才能操作。
三、更新丢失的两个分类
在数据库理论中,更新丢失分为两类。
1.第一类更新丢失(回滚丢失):
一个事务的回滚,把另一个已经提交的事务更新的数据给覆盖了。

在 InnoDB 中,这种情况绝对不会发生。
因为 UPDATE会加 排他锁(X 锁) ,事务 B 在事务 A 回滚之前根本无法修改该行数据。
2.第二类更新丢失(覆盖丢失):
一个事务基于旧数据计算新值并提交,覆盖了另一个事务已经提交的更新。
这是最常见的更新丢失。

虽然数据库有锁,但如果程序逻辑是"先读出来,在内存计算,再写回去",锁也救不了。
四、四大隔离级别下的写-写场景
在 InnoDB 中,为了防止"脏写(Dirty Write)",所有的隔离级别在修改数据时都会加锁。
也就是说,如果事务 A 正在修改某行,事务 B 想改同一行,必须得等 A 提交或回滚。
1. 读未提交(RU) & 读提交(RC)
在这两个级别下,写-写冲突的表现最直接:
-
锁定单行: 只要事务 A 执行了 UPDATE,该行就会被加上 记录锁(Record Lock)。
-
事务 B 的表现: 必须阻塞等待,直到 A 释放锁。
-
区别: 在这两个级别下,MySQL 基本只锁住被修改的那些行。
2. 可重复读(RR)/串行化(Serializable)
作为 MySQL 的默认级别,RR 在写操作上比 RC "霸道"得多。
-
间隙锁(Gap Lock)与 Next-Key Lock: RR 不仅锁住存在的记录,还会锁住记录之间的"间隙"。
-
写-写冲突升级:
-
在 RC 下,如果事务 A 修改了 ID=10 的行,事务 B 还可以插入 ID=11 的新行。
-
在 RR 下,如果事务 A 的写操作涉及范围(比如 WHERE id > 5),它会把整个范围都锁住。此时事务 B 想插入 ID=11 的记录也会被阻塞。
-
-
目的: 这是为了从根本上解决"幻读"问题,确保写操作的区间安全。
而串行化全线加锁,读也不让读。
以上两种的区别在于,RU/RC无法解决幻读,而RR/Serializable可以。
比如select * from Roles where id > 5;
RU/RC下仍可以插入,而RR/Serializable禁止插入,杜绝了两次搜索不一样的情况。
五、写-写死锁的情况
1.典型死锁场景:转账
假设有用户 1 和用户 2,两人同时互相转账。

死锁产生。
2.解决方案:在代码层进行"ID 排序"
无论谁给谁转账,我们的业务逻辑都强制要求:先锁 ID 小的,再锁 ID 大的。
这样,当事务 A 和事务 B 同时发生时,它们都会先去争抢 id=1 的锁。谁抢到了谁先走,没抢到的就在第一行等着,而不会去占着第二行的锁。这就变"环路等待"为"顺序排队"了。
-
修改后的逻辑:
-
接收到转账请求 (id1=1, id2=2)。
-
排序: 发现 1 < 2。
-
执行 UPDATE ... WHERE id = 1;
-
执行 UPDATE ... WHERE id = 2;
-