第三部分:事务与并发控制 —— 数据一致性的守护者
当多个用户同时读写数据库时,必须保证数据的一致性,事务和锁机制就是为此设计的。
3.1 事务的 ACID 特性回顾
原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败回滚。
一致性(Consistency):事务执行前后,数据库从一个一致状态变为另一个一致状态(完整性约束未被破坏)。
隔离性(Isolation):并发执行的事务之间互不干扰。
持久性(Durability):事务一旦提交,其结果永久保存(即使系统崩溃)。
数据库通过 日志(Redo Log 保证持久性,Undo Log 保证原子性和回滚)和 锁+MVCC(保证隔离性)来实现 ACID。
3.2 隔离级别与现象
SQL 标准定义了四种隔离级别,从低到高:
三种并发问题的解释:
脏读:一个事务读取了另一个未提交事务修改的数据。如果后者回滚,读到的就是无效数据。
不可重复读:同一事务内两次读取同一行数据得到不同结果,因为另一个事务修改并提交了该行。
幻读:同一事务内两次查询返回的记录数量不同,因为另一个事务插入或删除了符合条件的数据。
MySQL InnoDB 默认隔离级别是 REPEATABLE READ,它通过 MVCC(多版本并发控制)+ Next-Key Lock(间隙锁+行锁)解决了幻读问题,实际上达到了接近 SERIALIZABLE 的效果但性能更好。
3.3 MVCC —— 无锁的高并发读
MVCC 是 InnoDB 和 PostgreSQL 等数据库实现高并发的核心机制。其思想是:写操作不阻塞读操作,读操作只读数据的一个快照。
3.3.1 InnoDB 中的 MVCC 实现
InnoDB 为每一行数据增加了两个隐藏字段:
DB_TRX_ID:最后修改该行的事务 ID。
DB_ROLL_PTR:指向 Undo Log 中该行的旧版本链。
当一个事务开始时,会获得一个全局递增的事务 ID。执行 SELECT 时,事务只能看到以下版本的数据:
修改该行的事务 ID 小于当前事务 ID(已提交的)且没有活跃事务修改。
或者修改该行的事务 ID 在当前事务的 Read View 中被标记为已提交。
Read View 是一个数据结构,记录当前系统中所有活跃(未提交)事务的 ID 列表。事务开始时刻生成 Read View(RR 级别下只在第一个 SELECT 时生成,并复用整个事务;RC 级别下每次 SELECT 都重新生成)。
3.3.2 示例解释 MVCC 如何避免不可重复读
假设有行数据初始值为 (id=1, balance=100),trx_id=10。
事务 A(trx_id=20)开始,执行 SELECT balance FROM account WHERE id=1,此时生成 Read View,看到活跃事务无,所以读到 balance=100。
事务 B(trx_id=21)开始,更新 balance=200,并将旧版本(100)写入 Undo Log,新行 trx_id=21。
事务 A 再次 SELECT balance,因 Read View 仍是在事务开始时生成的,事务 21 是活跃事务且大于 Read View 的低水位,所以 A 仍看到旧版本(通过 Undo 链找到版本 trx_id=10),实现了可重复读。
3.4 锁机制:行锁、间隙锁、Next-Key Lock、表锁
InnoDB 支持多粒度锁,其中最重要的是行锁和间隙锁。
3.4.1 行锁(Record Lock)
行锁只锁住索引记录。注意:如果查询没有使用索引,InnoDB 会退化为表锁(实际上是锁住所有行,效率极差)。
3.4.2 间隙锁(Gap Lock)
间隙锁锁定一个范围(两条索引记录之间的间隙),但不包括记录本身。主要用于防止幻读。例如 SELECT * FROM user WHERE id BETWEEN 10 AND 20 FOR UPDATE,会锁住 id 在 (10,20) 之间的间隙,防止其他事务插入 id=15 的新记录。
3.4.3 Next-Key Lock = 行锁 + 间隙锁
InnoDB 在 REPEATABLE READ 级别下执行范围查询时,会使用 Next-Key Lock,锁定记录本身及记录前的间隙,彻底防止幻读。
3.4.4 意向锁(Intention Lock)
表级别的锁,表明事务将要或正在持有行锁。目的是为了在加表锁时快速检测是否有行锁,避免逐行检查。
3.5 死锁的检测与处理
死锁指两个或多个事务互相持有对方需要的锁,导致无限等待。InnoDB 会自动检测死锁(通过等待图),并选择回滚一个事务(通常是更新行数较少的事务),让另一个继续执行。应用程序需要处理好重试逻辑。
死锁示例与避免
-- 事务1
BEGIN;
UPDATE account SET balance=balance-100 WHERE id=1;
UPDATE account SET balance=balance+100 WHERE id=2;
COMMIT;
-- 事务2
BEGIN;
UPDATE account SET balance=balance-50 WHERE id=2;
UPDATE account SET balance=balance+50 WHERE id=1;
COMMIT;
如果两个事务几乎同时执行,事务1锁住了id=1,事务2锁住了id=2,然后各自尝试锁另一个 id,死锁发生。
避免策略:
所有事务按照相同的顺序访问资源(如总是先更新 id=1 再 id=2)。
尽量使用较低的隔离级别(如 RC,没有间隙锁,死锁概率降低)。
控制事务大小,快速提交。
使用 SELECT ... FOR UPDATE 提前锁定需要的行。
3.6 应用层事务最佳实践
尽量缩短事务:不要在一个事务中执行用户输入、远程调用等耗时操作,这些会长时间持有锁,增加死锁风险和并发下降。
避免在事务中进行大规模 SELECT:如果只是读取数据且不要求强一致性,可以在事务外执行。
合理设置超时:innodb_lock_wait_timeout 控制行锁等待时间,避免个别事务阻塞整个系统。
来源:
https://fndvx.cn/