第三部分:事务与并发控制 —— 数据一致性的守护者
多个用户同时读写数据库,数据一致性怎么保证?这就得靠事务和锁机制了。
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 会自动检测死锁(靠等待图),然后挑一个事务回滚(一般是更新行数少的那个),让另一个继续执行。应用程序得处理好重试逻辑。
死锁例子与避免
-- 事务1BEGIN;UPDATE account SET balance=balance-100 WHERE id=1;UPDATE account SET balance=balance 100 WHERE id=2;COMMIT;-- 事务2BEGIN;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 控制行锁等待时间,别让个别事务堵了整个系统。
