事务与并发控制
事务是数据库保证数据一致性的核心机制,它将多个操作打包成一个原子单元,要么全部执行,要么全部不执行。理解事务的实现,需要深入预写日志、锁机制和多版本并发控制这三个关键技术。
ACID 的实现
原子性(Atomicity)
原子性要求事务中的操作要么全部成功,要么全部失败回滚。这通过预写日志(Write-Ahead Logging, WAL)实现。WAL 的核心原则是:任何对数据页的修改,必须先记录日志,成功落盘后才能修改数据页。这样即使系统崩溃,也能通过重放日志恢复未完成的事务或回滚已部分执行的事务。
日志包含事务 ID、操作类型(INSERT/UPDATE/DELETE)、修改前镜像(undo log)、修改后镜像(redo log)。修改前镜像用于回滚,修改后镜像用于恢复。日志按追加方式写入,是顺序写,性能远好于随机写。日志文件定期切换,旧日志在 checkpoint 后可以被删除或重用。
一致性(Consistency)
一致性是事务的目标,而非手段。数据库提供原子性、隔离性和持久性,由应用程序保证事务执行前后数据库处于一致状态。例如转账事务,A 账户减少 100 元,B 账户增加 100 元,数据库保证这两个操作同时成功或同时失败,但应用需要保证转账金额正确、账户存在等业务一致性。
隔离性(Isolation)
隔离性要求并发事务之间互不干扰,每个事务看到的数据库状态都是一致的。这通过锁机制或多版本并发控制实现。不同的隔离级别提供了不同的隔离保证,我们会在后续详细讨论。
持久性(Durability)
持久性要求事务一旦提交,对数据的修改就是永久的,即使系统崩溃也不会丢失。这通过 WAL 和定期刷盘实现。事务提交时,日志必须成功落盘(fsync),数据页可以延后刷盘。崩溃恢复时,重放已提交但未刷盘的修改,撤销未提交的修改。
隔离级别与并发问题
脏读(Dirty Read)
脏读是指一个事务读取了另一个未提交事务的修改。如果后者回滚,前者读到的就是无效数据。解决脏读需要至少 READ COMMITTED 隔离级别:事务只能读取已提交的修改。
不可重复读(Non-Repeatable Read)
不可重复读是指同一事务内两次读取同一数据,结果不一致,因为期间有其他事务修改了该数据。解决不可重复读需要 REPEATABLE READ 隔离级别:事务内多次读取同一数据,结果始终一致。
幻读(Phantom Read)
幻读是指同一事务内两次执行相同条件的范围查询,结果集不一致,因为期间有其他事务插入或删除了满足条件的新记录。解决幻读需要 SERIALIZABLE 隔离级别:事务串行执行,完全隔离。
MySQL InnoDB 的 REPEATABLE READ 通过 Next-Key Lock(临键锁)在一定程度上解决了幻读,这是它的一个亮点。
锁机制
锁的粒度
锁可以加在不同粒度上:行锁、页锁、表锁。粒度越细,并发度越高,但锁管理的开销越大。InnoDB 支持行级锁,通过在记录上加锁实现。行锁实际上是索引记录锁,如果查询没有使用索引,会退化为表锁。
锁的类型
共享锁(Shared Lock, S 锁)允许读,不允许写。多个事务可以同时持有 S 锁,读取同一数据。
排他锁(Exclusive Lock, X 锁)允许读写,不允许其他事务持有任何锁。修改数据必须加 X 锁。
意向锁(Intention Lock)是表级锁,用于快速判断是否允许行级锁。意向共享锁(IS)表示事务打算在某些行上加 S 锁,意向排他锁(IX)表示事务打算在某些行上加 X 锁。意向锁之间不冲突,但与实际的 S/X 锁有兼容性规则。
间隙锁(Gap Lock)锁定两个索引记录之间的间隙,防止其他事务在该间隙插入新记录,用于解决幻读。间隙锁不阻塞其他间隙锁,但阻塞插入操作。
临键锁(Next-Key Lock)是间隙锁和记录锁的组合,锁定记录及其前的间隙。InnoDB 在 RR 隔离级别下,范围查询会使用临键锁,防止幻读。
两阶段锁协议
两阶段锁协议(2PL)保证可串行化:增长阶段只获取锁,不释放锁;缩减阶段只释放锁,不获取锁。严格 2PL 要求事务提交或回滚时才释放所有锁,这保证了可串行化,但可能增加死锁概率。
多版本并发控制(MVCC)
多版本并发控制是现代数据库的主流方案,它通过维护数据的多个版本,实现读写不阻塞,大幅提升并发度。InnoDB 和 PostgreSQL 都采用 MVCC。
Undo Log 与版本链
InnoDB 在每行记录中隐藏两个字段:DB_TRX_ID(最近修改该行的事务 ID)和 DB_ROLL_PTR(回滚指针)。回滚指针指向 undo log 中该行的上一个版本。每次修改一行,都会生成新的 undo log,形成版本链。
Read View
Read View 是事务启动时生成的一致性视图,记录了当前活跃的事务 ID 列表(m_ids)、最小活跃事务 ID(min_trx_id)、下一个要分配的事务 ID(max_trx_id)。Read View 用于判断某个版本是否对当前事务可见。
可见性规则:如果版本的 DB_TRX_ID < min_trx_id,说明版本在事务启动前已提交,可见。如果 DB_TRX_ID >= max_trx_id,说明版本在事务启动后才生成,不可见。如果 DB_TRX_ID 在 m_ids 中,说明版本由活跃事务生成,不可见。如果 DB_TRX_ID 不在 m_ids 中,说明版本在事务启动前已提交,可见。
快照读与当前读
快照读读取 Read View 判断可见的版本,不加锁,是 InnoDB 的默认读方式。SELECT 是快照读。
当前读读取最新版本,加锁。UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 是当前读。当前读需要加 X 锁或 S 锁,保证读取的是最新提交的数据。
MVCC 的优势
MVCC 实现了读写不阻塞:读操作通过 Read View 读取历史版本,不阻塞写;写操作生成新版本,不阻塞读。这比传统的读写锁并发度更高。MVCC 还实现了 REPEATABLE READ:事务内的 Read View 在第一次读时生成,后续读使用同一个 Read View,保证读到的版本一致。
死锁检测与处理
当多个事务互相持有对方需要的锁时,发生死锁。数据库需要检测死锁并选择一个事务回滚。InnoDB 使用等待图(wait-for graph)检测死锁:节点是事务,边是等待关系。如果图中存在环,说明有死锁。回滚代价最小的事务(修改行数最少)是常见的策略。
应用层可以通过约定锁的获取顺序、尽快释放锁、设置锁超时等方式减少死锁概率。