事务与ACID
事务与ACID
复习定位
事务将一组数据库操作(L1的UPDATE+L2的INSERT)包装成一个不可分割的逻辑单元——要么全部反映到数据库(提交后)——要么好像都没发生过(回滚)。转账是最经典的例子——扣款和加款要么同时成功要么同时回滚。ACID中的原子性由UNDO日志保障——持久性由REDO日志保障——隔离性由锁或MVCC实现——一致性靠应用逻辑和完整性约束保障。
事务的概念
事务是数据库应用程序访问和更新数据的基本逻辑单位。事务以一条BEGIN语句(或隐式、如第一条SQL语句的自动开始)开始——以COMMIT(提交所有更改——永久保存)或ROLLBACK(撤销所有更改——回滚到事务开始前的状态)结束。
单个事务包含若干条SQL操作——这些操作对数据库的修改要么全部生效(COMMIT后)、要么全部不生效(ROLLBACK后)。如果在事务执行过程中系统崩溃——数据库管理系统在重新启动时会自动ROLLBACK该未完成的事务——实现原子性保障。
原子性
原子性保证事务中的所有操作要么全部完成——要么全部不完成。事务在提交之前的任意时刻——若发生系统故障、事务被强制中断、或用户主动发出ROLLBACK——数据库管理系统必须将事务已执行过的一切修改恢复到事务开始之前。
UNDO日志记录了数据修改前的旧值——当需要回滚事务时——系统逆序扫描UNDO日志——将每项修改恢复到旧值。
一致性
一致性保证事务执行前后数据库从一个一致性状态转移到另一个一致性状态。一致性由"完整性约束"保证——建表时的PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK、NOT NULL等约束。如果事务期间插入一行违反主键唯一性——事务被终止并回复ROLLBACK。
隔离性
隔离性使不同事务并发执行时就像它们被顺序串行执行一样——不互相干扰。隔离级别从低到高(读未提交→读已提交→可重复读→可串行化)逐步增强隔离性——但并发收益逐步降低。隔离性由锁或多版本并发控制(MVCC)实现。
持久性
持久性保证一旦事务提交成功——它对数据库所做的任何修改都将永远保存——即使后续系统崩溃也不应丢失。REDO日志记录事务的修改操作——当系统重启时——搜索日志中所有已提交的事务——重做REDO确保修改被安全地写入数据文件。
复习检查
事务的原子性和一致性的关系——没有原子性(部分执行)导致不一致:扣款成功但加款失败——账户余额不正确。这一描述是否属于一致性被违反?
UNDO日志和REDO日志的区别:UNDO用于回滚(反向恢复旧值)——REDO用于重做已提交事务——崩溃恢复过程——先扫描REDO再扫描UNDO——这一顺序的原因是什么?
隔离级别中——脏读和幻读的具体差异——哪一个只发生在读未提交而其他级别不出现?为什么可重复读仍然无法防止幻读(在SQL标准中——而InnoDB在可重复读下通过Next-Key Lock防止幻读)?
如果用户打开了A事务——修改数据但未提交——另一用户B在A提交之前──修改同一行——会怎样——这取决于隔离级别和锁机制吗?对于Mysql InnoDB默认的RR级别──B修改同一行会发生什么?
COMMIT之后系统崩溃的数据恢复——系统在重启时读取日志——如果找到了事务T的COMMIT记录但T的数据变化没有及时刷入磁盘——恢复系统会执行REDO还是UNDO?
事务的具体执行过程——以转账为例
一个典型的转账事务包含两条UPDATE语句:
BEGIN;
UPDATE account SET balance=balance-100 WHERE id=1;
UPDATE account SET balance=balance+100 WHERE id=2;
COMMIT;事务管理器在执行过程中的行为:
- 记录一条
<BEGIN TRANSACTION>日志到WAL(预写日志)。 - 执行第一条UPDATE——在数据页上修改balance字段——在修改数据页之前先将
<UPDATE, account, id=1, old_balance, new_balance>写入WAL——然后刷新数据页到Buffer Pool(不一定刷盘)。 - 执行第二条UPDATE——同样先写WAL再修改Buffer Pool中的页面。
- 执行COMMIT——先写
<COMMIT TRANSACTION>到WAL——确保WAL的COMMIT记录被fsync刷入持久存储后——才向客户端返回"事务提交成功"。 - 此后——Buffer Pool中的数据页可能经过一段时间才被后台刷入磁盘(如果系统在这之前崩溃——恢复时通过WAL的COMMIT记录重做REDO)。
如果在第2步之后、第3步之前系统崩溃——恢复时找不到COMMIT记录——系统通过UNDO日志将第一条UPDATE的修改回滚——保持账户余额不变。
原子性的实现——UNDO日志
UNDO日志记录了每个事务对每行数据修改前的旧值。当事务需要回滚时——恢复系统从日志末尾反向扫描——为每一条属于该事务的UPDATE记录将数据还原为旧值——反向扫描的原因是恢复系统需要按照"最后做的修改最先撤销"的顺序来回滚。
UNDO日志在崩溃恢复中处理未提交事务时使用——在日志中扫描到的所有没有COMMIT记录的事务——都需要通过UNDO回滚。在InnoDB中——UNDO日志存储在系统表空间中的回滚段(Rollback Segment)中——回滚段是循环使用的——当事务不再需要访问旧版本时——Purge线程会逐步清理过期的UNDO日志。
持久性的实现——REDO日志与WAL
REDO日志记录的是修改后的新值。当系统崩溃重启时——恢复系统扫描日志——找到所有有COMMIT记录的事务——将这些事务的修改重做(REDO)到数据页面——保证提交的数据不丢失。
WAL(Write-Ahead Logging)原则——在修改数据页之前——必须先将修改对应的日志记录写入持久存储——即日志必须优先于数据写入磁盘。这个原则保证了恢复时一定可以从日志中找到完整的重做/撤销信息。性能方面——WAL使数据页的写入可以异步延迟进行——因为日志的顺序写入性能远高于数据页的随机写入——日志落盘后即可确认持久性——数据页可以在后续方便的时刻刷盘。
隔离性的实现——锁与MVCC
通过锁机制——写操作加排他锁(X锁)阻止其他事务同时写同一行——读操作加共享锁(S锁)或不加锁(通过MVCC快照读)。不同隔离级别控制锁的持有时长和读操作的可见性。MVCC通过保存多版本来避免读-写冲突——读操作看到事务开始时的一致性快照——写操作创建新版本但不阻塞读——这就是为什么MySQL InnoDB在高并发下还能保持良好的读性能。
一致性——与应用层逻辑的配合
与原子性、隔离性、持久性不同——一致性不能单靠DBMS保证——它需要应用层的业务逻辑配合。ACID中的一致性要求——事务执行前后必须满足所有预定义的完整性约束——以及应用层的业务规则(如"转账后双方余额之和不变")。DBMS通过约束(主键、外键、唯一、CHECK等)提供了部分一致性保障——但复杂的业务规则需要应用程序在事务中正确实现。
WAL的检查点机制与崩溃恢复流程
恢复流程:
- 系统重启后——从最后一个检查点开始扫描WAL
- 将所有有COMMIT记录的事务列入重做队列(REDO)——将没有COMMIT记录的事务列入撤销队列(UNDO)
- 正向扫描日志——执行REDO:将重做队列中的事务的所有修改重新应用到数据页
- 反向扫描日志——执行UNDO:将撤销队列中的事务的所有修改回滚到旧值
- 完成恢复——系统进入正常状态——开始接受新连接
检查点的作用——在检查点之前的所有已提交事务的修改都已确保写入磁盘——恢复时只需从最近检查点之后扫描——减少日志扫描范围——缩短恢复时间。
复习检查(续)
UNDO日志和REDO日志在崩溃恢复中的顺序——先REDO(重做所有已提交事务)——后UNDO(回滚所有未提交事务)——因为在REDO阶段可能重做了未提交事务的修改——UNDO阶段再将其回滚——确保数据库在回滚之前处于"重做了所有写入"的状态——保证了操作的可撤销基础的可用性。
检查点(checkpoint)的核心作用——缩短恢复时间——检查点之前的所有已提交修改都已写入磁盘——恢复时只需从检查点后的日志开始扫描——不需要扫描整个日志文件。
WAL原则为什么能同时保障原子性和持久性——先写日志再写数据——日志持久化后——要么事务完全提交(有COMMIT记录)——可用REDO重做——要么事务未提交(无COMMIT记录)——可用UNDO撤销——系统无论何时崩溃数据都能恢复到一致状态。
在可重复读隔离级别下——事务A对id=1的行做了加减法——事务B在A提交之前尝试修改同一行——在MySQL InnoDB的RR下——B会被阻塞——等待A释放锁——在MVCC下——B的写操作会创建新版本——但不影响A看到的事务开始时的快照。
一致性不能仅靠DBMS保证——靠应用层实现业务规则(如转账后双方余额之和不变)——DBMS通过约束提供部分支持——但复杂的业务规则必须应用层在事务中正确执行。
事务隔离级别在ACID中的位置
隔离性通过锁和MVCC实现——但隔离级别的选择直接影响一致性。
- READ UNCOMMITTED——脏读可能导致一致性被破坏(读取到未提交的中间状态)
- READ COMMITTED——不可重复读可能导致基于两次读取相同值的业务规则失效
- REPEATABLE READ——可防止不可重复读——但幻读可能影响基于范围条件的业务逻辑
- SERIALIZABLE——最强的隔离性——但并发性能急剧下降
实际应用中通常选择READ COMMITTED(PostgreSQL默认——开发中通常不用显式修改)或REPEATABLE READ(MySQL默认)——在一致性需求和并发性能之间做权衡。
事务的分布式实现——两阶段提交
在分布式数据库场景下——单个事务可能跨多个数据库实例。两阶段提交(2PC)保证跨实例事务的原子性:
阶段1:准备(Prepare)——协调者向所有参与节点发送PREPARE消息——各节点执行事务操作——将UNDO/REDO日志写入持久存储——准备提交——如果所有节点回复OK——进行阶段2——任何一个节点失败则事务回滚。
阶段2:提交(Commit)——协调者向所有节点发送COMMIT消息——各节点完成提交——释放锁和其他资源。如果协调者在阶段2中崩溃——部分节点可能已提交而部分未提交——处于不一致状态——需要协调者恢复后继续完成剩余的提交——或在不确定时预留的日志仲裁数据自动弥补缺漏。
2PC的性能影响较大——因为事务的提交需要多个网络轮次——在分布式数据库中——通常使用更高效的共识协议(如Paxos/Raft/Percona XtraDB Cluster的gcomm)来替代2PC——以换取更高的性能——同时保证数据一致性。
复习检查(续二)
两阶段提交(2PC)在分布式事务中的核心流程——准备阶段所有参与节点确认可提交→提交阶段协调者指示所有节点正式提交——如果任何参与节点在准备阶段失败——协调者通知所有节点回滚。
为什么两阶段提交中的"协调者故障"会导致阻塞——协调者在向不同节点发送COMMIT消息时崩溃——部分节点已提交——部分节点未提交——其他节点不知道应该提交还是回滚——需要协调者恢复后才能继续。
检查点为什么能缩短恢复时间——不需要从日志开头扫描——只需从最近的检查点之后开始——减少了需扫描的日志量——缩短了数据库重启后恢复所需的时间。
REDO日志和UNDO日志在恢复流程中的应用顺序——先执行REDO(重做所有有COMMIT记录的事务的修改)——后执行UNDO(回滚所有无COMMIT记录的事务的修改)——这是因为REDO阶段会将部分已提交但未刷盘的修改重新应用到——而UNDO会处理崩溃时未提交的那些修改——两者互不干扰。
为什么WAL必须按顺序写——因为事务的操作顺序相关——日志记录的先后顺序代表了修改的先后顺序——REDO/UNDO阶段必须按日志的顺序来——否则数据的一致性会被破坏。
事务的ACID属性在实际系统中的表现
账户转账的业务需求:
- 原子性:扣款和加款要么全完成要么全回滚——不能出现只扣款不加款的情况
- 一致性:转账前后双方账户余额之和不变——业务规则保证
- 隔离性:同一时间两个相关操作互相看到中间状态的问题——锁/MVCC解决
- 持久性:转账提交后——即使系统立刻崩溃——重启后转账结果依然在绝大多数的业务系统只有满足ACID——才能保证数据的正确性和可信度。但强ACID会限制系统的扩展性和可用性——因此在分布式系统(NoSQL/最终一致性系统)中——通常通过放松隔离性或持久性的要求来换取更高的性能和可用性——CAP定理(一致性/可用性/分区容忍性三者只能满足其二)给出了这一权衡的理论边界。
事务日志的物理存储格式
在MySQL InnoDB中——REDO日志是一个环形缓冲区日志(redo log buffer)——通过innodb_log_buffer_size参数控制大小(默认16MB)。日志文件在磁盘上(ib_logfile0/ib_logfile1)——通过innodb_log_file_size控制大小。写入流程:事务提交时——日志从redo log buffer写入操作系统缓存——通过fsync同步到磁盘文件——确认写入成功后返回客户端提交成功。日志文件的换序由系统的LSN(Log Sequence Number)递增控制——LSN唯一标识了每一条日志记录——构成整体上的顺序增长的撤销/重做历史。
复习检查(续三)
InnoDB中REDO日志的写入流程——事务修改数据→写REDO到log buffer→提交时fsync到磁盘文件——如果log buffer大小超过阈值——部分日志会提前被刷入磁盘——但COMMIT的标志位必须fsync后才返回客户端。
两阶段提交在MySQL Group Replication(MGR)中的应用——同个组内的多个节点通过分布式共识协议(Paxos的变体)来实现事务的分布式原子提交——广播日志并收集多数派确认——与经典2PC类似但有更完善的处理机制。
为什么REDO日志的顺序写入比随机写入快——REDO日志追加写入文件末尾(连续的日志块)——充分利用了存储设备的顺序I/O性能——而数据页的随机写入速度慢得多——因此WAL先顺序写日志——后续异步地将数据页随机写入磁盘——既不丢数据又最大化性能。
回滚段(Rollback Segment)的循环使用机制——InnoDB的回滚段包含多个UNDO slot——事务开始时分配一个slot——事务结束后如果没有任何其他事务需要访问该事务的旧版本——Purge线程会回收该slot——否则UNDO信息保留——直到所有需要访问快照版本的事务结束。
为什么说"一致性"与业务逻辑直接相关——数据库保证约束不被违反——但业务规则(如"转账后金额不能为负")需要应用程序在事务中通过检查条件(如
UPDATE SET balance = balance - 100 WHERE id = 1 AND balance >= 100)来实现。
事务的总结
ACID是关系型数据库的基础保证:
- 原子性通过UNDO日志实现(回滚撤销所有修改)
- 一致性通过完整性约束和应用层逻辑保证
- 隔离性通过锁和MVCC实现(不同隔离级别权衡并发性能)
- 持久性通过REDO日志和WAL原则保证(日志先持久化数据延迟写盘)
四个特性互相支撑——隔离性影响一致性——持久性不影响原子性——原子性和持久性通过WAL和日志协作保证。理解这个四者的联带关系是掌握事务的核心。