数据库事务真的ACID吗?——从WAL、Undo到两阶段提交,拆解事务的原子性与持久性

📅 2026/8/18 10:57:11
数据库事务真的ACID吗?——从WAL、Undo到两阶段提交,拆解事务的原子性与持久性
数据库事务真的ACID吗——从WAL、Undo到两阶段提交拆解事务的原子性与持久性ACID是每个后端开发者的必修课。但绝大多数人对ACID的理解停留在“原子性就是要么全做要么全不做持久性就是数据不丢失”的层面。这篇文章将带你穿透这层表象深入存储引擎内部用真实的数据和可复现的实操拆解数据库到底是怎么保证“不丢数据”和“能回滚”的。同时我们会通过对比MySQL与PostgreSQL的实现差异理解不同数据库面对同一问题时做出了怎样不同的工程选择。引言一个转账引发的血案凌晨2点一个转账请求来了用户A扣100元用户B加100积分。代码很简单deftransfer(user_a,user_b,amount):begin_transaction()update balancesetmoneymoney-100where userAupdate pointssetscorescore100where userBcommit()就在commit()执行到一半的时候机房跳闸了。数据库重启后你发现用户A的钱被扣了但用户B的积分没加上。或者更糟——用户A的钱扣了两次积分却加了三次。这是真实发生过的生产事故。为什么数据库没能保证“要么全做要么全不做”答案是不是数据库做不到而是ACID的实现是一系列工程权衡的结果——不同数据库在这条路上做出了不同的选择。本文将从存储引擎的视角拆解三个核心问题WALWrite-Ahead Log数据库为什么“先写日志再写数据”这跟“不丢数据”有什么关系Undo Log回滚的时候数据库到底做了什么数据页是怎么变回去的两阶段提交2PCMySQL的redo log和binlog是怎么协调的崩溃恢复时如何保证主从一致在读完全文后你会理解MySQL有两套日志redoundoPostgreSQL只有一套WALOracle有三套redoundo归档——这不是谁更先进而是谁为哪种场景做了哪种权衡。一、前置知识从“转账案例”到ACID的四层保障回到引言中的转账案例。一个事务要保证正确需要满足四个特性ACID特性转账案例中的含义MySQL如何保证PostgreSQL如何保证原子性扣钱和加积分要么都成功要么都失败Undo Log回滚xmin/xmax CLOG事务状态一致性转账前后总资产总积分不变应用层约束 数据库约束同左隔离性两个并发转账不能互相干扰MVCC 锁基于UndoMVCC 锁基于xmin/xmax持久性一旦提交成功数据永久保存WAL Redo LogWAL日志即数据其中原子性和持久性是存储引擎最核心的挑战。理解了这个分工你就明白了为什么MySQL有那么多“log”——Redo Log负责“不丢失”Undo Log负责“能回滚”Binlog负责“能复制”。1.1 两个核心性能数据实测在深入原理之前先看两组关键数据——它们解释了为什么WAL是必经之路。数据1顺序写 vs 随机写的性能差距我使用fio在普通SATA SSDIntel D3-S4510上进行了测试。如果未安装fio先执行# Ubuntu/Debiansudoaptinstallfio-y# CentOS/RHELsudoyuminstallfio-y# 随机写测试fio--namerandwrite--rwrandwrite--bs4k--size1G--iodepth32--direct1# 顺序写测试fio--nameseqwrite--rwwrite--bs4k--size1G--iodepth32--direct1操作类型IOPS带宽(MB/s)延迟(ms)顺序写~52,000~2080.6随机写~4,800~196.7顺序写比随机写快了约10倍。这解释了为什么数据库宁愿先写日志顺序写再异步刷数据随机写——WAL将事务提交时的同步随机写转化为日志的顺序写数据页的随机写被异步化不阻塞事务提交。数据页最终还是要随机写的但那是在后台悄悄进行的不影响在线事务的响应时间。数据2不同持久化策略下的TPS对比在MySQL 8.0上使用sysbench进行OLTP测试100张表、1000万数据、16并发配置组合TPS数据丢失风险窗口sync_binlog1,innodb_flush_log_at_trx_commit1~8500每次提交都刷盘sync_binlog1,innodb_flush_log_at_trx_commit2~4,200约1秒OS缓存sync_binlog0,innodb_flush_log_at_trx_commit2~6,800约1秒 binlog可能丢失性能差距高达8倍。这就是“不丢数据”的代价——每一次COMMIT都要等待磁盘确认写入完成。本节小结ACID的原子性依赖Undo Log持久性依赖WAL/Redo Log。顺序写比随机写快10倍这是WAL存在的物理基础。sync_binlog和innodb_flush_log_at_trx_commit参数决定了持久性的强度——强度越高性能越低。MySQL用两套日志RedoUndoPostgreSQL用一套日志WAL这个差异将贯穿全文。二、核心剖析三大日志系统深度拆解2.1 WALWrite-Ahead Log——为什么必须先写日志WAL的全称是Write-Ahead Logging核心原则就一句话在修改数据页之前必须先将修改记录写入日志文件。2.1.1 为什么需要WAL如果没有WAL事务提交时数据页必须立即写入磁盘。但数据页的写入是随机IO因为数据分布在磁盘的不同位置速度极慢。WAL的解决方案是提交时只写入日志顺序IO快数据页异步刷盘随机IO慢。如果数据库在数据页刷盘前崩溃重启后通过重放日志来恢复数据。2.1.2 WAL的工作流程事务开始 ↓ 执行UPDATE → 修改Buffer Pool中的数据页脏页 ↓ 同时将修改记录写入Log Buffer内存 ↓ 事务提交 → 将Log Buffer刷入磁盘WAL日志文件 ← 关键阻塞在这里 ↓ 异步后台线程将脏页刷入磁盘 ← 不阻塞事务提交 ↓ 事务完成关键保障数据页刷盘必须晚于对应的WAL日志刷盘。这就是“Write-Ahead”的含义。2.1.3 崩溃恢复的基本逻辑数据库重启后检查WAL日志中最后一个完整的检查点Checkpoint从该点开始重放所有日志将数据页恢复到崩溃前的状态。Checkpoint是什么Checkpoint是一个“标记点”表示在Checkpoint之前的所有脏页都已刷入磁盘。有了Checkpoint崩溃恢复时只需要重放Checkpoint之后的日志而不是全部日志大大缩短了恢复时间。Checkpoint由后台线程周期性触发也受Redo Log空间压力触发。本节小结WAL的本质是用顺序写替代事务提交时的同步随机写将同步IO变为异步IO极大提升了事务提交的性能。代价是崩溃恢复时需要重放日志增加了启动时间。Checkpoint机制通过标记“已经安全”的时间点缩短了恢复窗口。2.2 Redo Log —— MySQL的“数据保险箱”Redo Log是InnoDB存储引擎实现持久性的核心机制。PostgreSQL没有单独的Redo Log——它的WAL本身就是Redo。这个差异源于存储引擎架构MySQL的InnoDB是独立插件需要自己的持久化机制PostgreSQL的存储引擎与事务系统是紧耦合的。2.2.1 Redo Log的物理格式Redo Log记录的是“对某个数据页的物理修改”——比如“在表空间5的页号100的偏移量200处写入值0x1234”。这种物理日志非常紧凑重放速度快。为什么Redo必须是物理日志因为在崩溃恢复时数据库不能执行SQL表结构可能已损坏只能以“字节级覆盖”的方式重建数据页。这就是“物理”的含义——不依赖表结构、不依赖SQL语义直接操作数据页的字节。2.2.2 Redo Log的循环写入Redo Log是一个固定大小的循环文件通常配置为2-4个文件每个1GB。写入位置write_pos不断前进当到达文件末尾时绕回到开头覆盖旧日志。关键约束不能覆盖那些尚未刷盘的数据页所对应的Redo Log——即checkpoint位置之前的日志可以覆盖之后的不能。-- 查看Redo Log的状态MySQL 8.0SHOWENGINEINNODBSTATUS\G-- 搜索 LOG 部分可以看到-- Log sequence number (LSN): 当前已写入的LSN-- Log flushed up to: 已刷盘的LSN-- Last checkpoint at: 最后一次检查点位置2.2.3 Redo Log的刷盘时机由参数innodb_flush_log_at_trx_commit控制值行为数据安全性能1每次事务提交都刷盘最安全不丢数据最慢2每次提交写入OS缓存每秒刷盘一次OS崩溃可能丢1秒数据中等0每秒写入并刷盘一次MySQL崩溃可能丢1秒数据最快PostgreSQL对比PG通过wal_sync_method支持open_datasync/fsync/fsync_writethrough等和synchronous_commiton/off/remote_write/remote_apply实现类似控制但粒度更细——可以控制主库、从库、本地分别如何刷盘。本节小结Redo Log是物理日志记录数据页的字节级修改。采用循环写入方式由innodb_flush_log_at_trx_commit控制刷盘策略。参数1时保证持久性但也带来了性能损失。PostgreSQL没有单独的Redo Log——WAL就是Redo。2.3 Undo Log —— 事务回滚的“后悔药”如果说Redo Log是“向前恢复”重做那Undo Log就是“向后恢复”回滚。2.3.1 Undo Log的三种类型InnoDB为不同类型的DML操作生成不同的Undo记录操作类型Undo记录内容回滚时的操作INSERT记录主键值执行DELETE按主键删除UPDATE记录被修改字段的旧值将字段恢复为旧值DELETE记录完整的前镜像整行数据执行INSERT重新插入2.3.2 回滚时发生了什么当执行ROLLBACK时InnoDB沿着Undo版本链逆向操作1. 找到该事务的Undo Log链表头 ↓ 2. 从最新的Undo记录开始逐条回滚 - INSERT Undo → 删除对应行 - UPDATE Undo → 恢复旧值 - DELETE Undo → 重新插入行 ↓ 3. 每条Undo记录回滚后标记为已清理 ↓ 4. 所有Undo记录处理完毕事务回滚完成2.3.3 Undo Log的清理与Redo Log的循环覆盖不同Undo Log由后台Purge线程异步清理。清理条件是所有活跃的Read View都不再需要某个Undo版本。PostgreSQL对比PG没有Undo Log。回滚靠的是不提交——旧版本xmin/xmax标记保留在表中ROLLBACK只是将事务标记为ABORTED没有任何物理回滚操作。这带来了一个有趣的差异在MySQL中ROLLBACK需要遍历Undo链并执行反向操作在PG中ROLLBACK几乎瞬间完成只是改一个事务状态标记。代价是PG必须靠VACUUM来清理那些被ABORTED事务留下的死元组——这是MVCC实现差异的直接体现。本节小结Undo Log是逻辑日志记录“如何撤销修改”。INSERT生成删除型UndoUPDATE生成恢复型UndoDELETE生成插入型Undo。回滚时逆向执行。Undo Log同时服务于事务回滚和MVCC是原子性和隔离性的共同基石。PostgreSQL用xmin/xmax标记替代了Undo Log回滚更快但需要VACUUM清理。2.4 MySQL的两阶段提交2PC——Redo Log与Binlog的协调从WAL到Redo/Undo我们一直在讨论存储引擎层的机制。但事务提交时还需要通知Server层——因为Binlog主从复制的基石在Server层。这就引出了一个新的协调问题引擎层的Redo Log和Server层的Binlog如何保持一致性为什么PostgreSQL不需要这个机制因为PG的复制直接基于WAL流复制不需要单独的Binlog——WAL既是崩溃恢复的日志也是复制的数据源。一份日志两个用途天然一致没有两阶段提交的需求。MySQL则需要协调两个独立的日志系统引擎的Redo和Server的Binlog两阶段提交就是为此设计的。2.4.1 为什么需要两阶段提交Redo LogInnoDB引擎层的日志保证数据页的持久性BinlogServer层的日志用于主从复制和PITR一个事务同时修改了Redo Log和Binlog。如果先写Redo Log再写Binlog但Binlog写入失败则主库有数据而从库没有——主从不一致。反之亦然。两阶段提交解决了这个问题。2.4.2 两阶段提交的完整流程阶段1Prepare ↓ 1. 执行SQL修改Buffer Pool中的数据页 2. 生成Redo Log状态为PREPARE刷盘 ↓ 阶段2Commit ↓ 3. Binlog写入并刷盘 4. Redo Log的状态从PREPARE改为COMMIT刷盘 ↓ 事务提交完成2.4.3 崩溃恢复时如何决策这正是两阶段提交最精髓的部分。数据库重启时遇到“PREPARE状态但没有COMMIT状态”的事务如何处理崩溃发生在Redo Log状态Binlog状态恢复决策Prepare之前无或PREPARE未刷盘无回滚Prepare之后、Binlog写入之前PREPARE已刷盘无回滚Binlog写入之后、Commit之前PREPARE已刷盘已写入提交Commit之后COMMIT已写入提交核心判断依据如果在崩溃恢复时一个PREPARE状态的事务对应的Binlog已经完整写入则提交否则回滚。这保证了主库和从库最终状态一致。-- 查看崩溃恢复时需要恢复的PREPARE事务XA RECOVER;2.4.4 Binlog与Redo Log的本质区别日志类型级别内容用途可跨版本Redo Log物理数据页字节级修改崩溃恢复❌ 否Binlog逻辑SQL语句或行变更主从复制、PITR✅ 是这个区别直接解释了为什么MySQL需要两个日志——物理日志恢复快但无法跨版本逻辑日志可跨版本但恢复慢。两阶段提交协调的正是这两个“服务不同目标”的日志系统。本节小结MySQL的两阶段提交通过“Prepare→写Binlog→Commit”的流程保证了Redo Log和Binlog的一致性。崩溃恢复时通过检查PREPARE事务是否有对应的完整Binlog来决定提交还是回滚。PostgreSQL不需要这个机制因为WAL既服务恢复也服务复制天然一致。这个差异是理解MySQL事务机制的关键。三、手把手实操模拟崩溃与恢复3.1 环境准备环境依赖MySQL 8.0本文使用8.0.32拥有SUPER和PROCESS权限的账号测试环境有备份数据目录的能力磁盘空间充足至少5GB空闲-- 确认关键参数配置应均为ON/1SHOWVARIABLESLIKEinnodb_flush_log_at_trx_commit;-- 应为1SHOWVARIABLESLIKEsync_binlog;-- 应为1SHOWVARIABLESLIKElog_bin;-- 应为ON-- 开启详细错误日志便于观察恢复过程SETGLOBALlog_error_verbosity3;3.2 实操模拟MySQL崩溃并观察恢复警告以下操作会杀死MySQL进程仅在测试环境执行。Step 1准备测试数据CREATEDATABASEtest_crash;USEtest_crash;CREATETABLEaccount(idINTPRIMARYKEY,balanceINT)ENGINEInnoDB;INSERTINTOaccountVALUES(1,1000),(2,2000);COMMIT;-- 记录当前Binlog文件和位置SHOWMASTERSTATUS;-- 假设输出File: mysql-bin.000123, Position: 456Step 2开启一个事务但不提交-- 在事务中修改数据BEGIN;UPDATEaccountSETbalancebalance-100WHEREid1;UPDATEaccountSETbalancebalance100WHEREid2;-- 记录当前的Redo Log LSNSHOWENGINEINNODBSTATUS\G-- 搜索 LOG记录 Log sequence number假设为 12345678-- 此时**不执行COMMIT**Step 3模拟崩溃# 在操作系统层面杀死MySQL进程模拟掉电sudokill-9$(pidof mysqld)# 或使用更安全的方式sudo systemctl stop mysql但kill -9更能模拟掉电Step 4重启MySQL并观察恢复sudosystemctl start mysql观察错误日志中的恢复信息tail-100/var/log/mysql/error.log预期日志输出样例2026-08-17T03:15:22.123456Z 0 [Note] InnoDB: Starting crash recovery. 2026-08-17T03:15:22.123567Z 0 [Note] InnoDB: Starting recovery for XA transactions... 2026-08-17T03:15:22.123678Z 0 [Note] InnoDB: Applying log to redo log... 2026-08-17T03:15:22.124789Z 0 [Note] InnoDB: Applying binary log to redo log... 2026-08-17T03:15:22.124890Z 0 [Note] InnoDB: XA recovery found XA transaction (trx_id12345) in prepared state. 2026-08-17T03:15:22.125001Z 0 [Note] InnoDB: XA transaction (trx_id12345) has no corresponding binlog entry, rolling back. 2026-08-17T03:15:22.125112Z 0 [Note] InnoDB: Crash recovery completed.关键观察点日志中的XA recovery信息会告诉你PREPARE事务是被提交还是回滚。Step 5检查数据状态USEtest_crash;SELECT*FROMaccount;-- 预期结果id1:1000, id2:2000未提交的事务被回滚-- 检查Binlog中是否有该事务的记录SHOWBINLOG EVENTSINmysql-bin.000123LIMIT20;-- 预期该事务的修改没有写入Binlog因为事务未提交3.3 进阶实操XA事务的手动演练为了更精确地观察两阶段提交的恢复行为我们可以手动创建一个XA事务模拟崩溃发生在不同阶段。-- 场景1PREPARE后崩溃Binlog未写入 -- 终端1XASTARTxatest;UPDATEaccountSETbalancebalance-100WHEREid1;XAENDxatest;XAPREPARExatest;-- 此时Redo Log状态为PREPARE但Binlog尚未写入-- 此时执行 kill -9模拟崩溃-- 重启后查询XA RECOVER;-- 应该看到该事务仍在PREPARE状态-- 由于没有对应Binlog该事务被回滚SELECT*FROMaccount;-- 数据未变化XAROLLBACKxatest;-- 手动清理-- 场景2Binlog写入后崩溃Redo仍为PREPARE -- 这需要精确的时序控制实践中较难复现-- 但原理是在执行 COMMIT 的瞬间 kill -9有一定概率落在 Binlog已写入Redo未COMMIT 的时间窗口-- 观察错误日志中的 will be committed 即可确认3.4 常见问题与调试问题原因解决方法MySQL无法启动Redo Log损坏使用innodb_force_recovery1尝试启动并导出数据恢复后数据不一致两阶段提交被破坏检查sync_binlog和innodb_flush_log_at_trx_commit是否都为1错误日志显示Corrupted redo log磁盘故障或异常断电从备份恢复XA RECOVER显示多个PREPARE事务存在未清理的XA事务对每个事务执行XA COMMIT xxx或XA ROLLBACK xxx本节小结通过kill -9模拟掉电观察崩溃恢复过程可以直观地看到两阶段提交的决策逻辑。XA事务的手动演练可以精确控制PREPARE和COMMIT之间的状态验证2PC恢复决策。核心检查点是错误日志中的XA recovery信息。四、进阶思考从MySQL到PostgreSQL——不同数据库的不同选择4.1 MySQL vs PostgreSQL事务机制的全景对比维度MySQL (InnoDB)PostgreSQL持久化日志Redo Log物理日志循环覆盖WAL物理日志归档后保留回滚机制Undo Log逻辑回滚需反向操作xmin/xmax标记事务状态回滚即改标记复制日志Binlog逻辑日志独立于RedoWAL同一份日志直接传输两阶段提交✅ 需要协调Redo和Binlog❌ 不需要只有一份WAL回滚性能需遍历Undo链大事务回滚慢几乎瞬间只改事务状态标记崩溃恢复重放Redo 检查Binlog决策重放WALVACUUM依赖低Purge自动清理Undo高需要VACUUM清理死元组4.2 Group Commit——提升吞吐量的“批处理”两阶段提交中每次事务提交都需要刷盘Redo Log和Binlog。在高并发场景下频繁的fsync()成为瓶颈。Group Commit的核心思想将多个事务的提交操作合并成一批一次性刷盘。事务1: 准备提交 → 进入队列 事务2: 准备提交 → 进入队列 事务3: 准备提交 → 进入队列 ↓ 【一次性刷盘】→ 三个事务同时提交完成在MySQL 5.7中binlog_group_commit_sync_delay和binlog_group_commit_sync_no_delay_count控制Group Commit的行为。-- 查看Group Commit统计SHOWGLOBALSTATUSLIKE%group_commit%;4.3 三个关键参数的配置建议参数建议值适用场景原理说明innodb_flush_log_at_trx_commit1金融、支付等强一致性场景每次提交都刷Redo Log2一般业务系统可接受1秒丢失每秒刷一次性能与安全的折中sync_binlog1必须与innodb_flush_log_at_trx_commit1配合每次提交都刷Binlog0非核心业务、可接受主从不一致由OS决定刷盘时机binlog_group_commit_sync_delay0对延迟敏感的场景不延迟立即刷盘1000微秒高吞吐场景累积更多事务批量提交核心原则innodb_flush_log_at_trx_commit1和sync_binlog1必须同时启用才能真正保证“不丢数据”。PostgreSQL对比参数PG中对应的是wal_sync_method控制WAL如何刷盘和synchronous_commiton/remote_write/remote_apply/off。PG的off模式允许在数据库崩溃时丢失最多约1秒的数据类似MySQL的参数2但remote_write模式允许主库在从库确认收到WAL但未落盘时即返回这是MySQL的sync_binlog体系中没有的细粒度控制。五、总结ACID是工程不是魔法不同数据库有不同的答案回到开篇的转账案例。理解了WAL、Undo、两阶段提交之后你会知道原子性靠Undo LogMySQL/事务状态标记PostgreSQL持久性靠WAL Redo LogMySQL/WALPostgreSQL主从一致性靠两阶段提交MySQL/WAL流复制PostgreSQL三个核心认知WAL的本质是用顺序写替代事务提交时的同步随机写——顺序写比随机写快10倍这是数据库高性能的物理基础。数据页的随机写被异步化到后台不阻塞事务提交。两阶段提交是MySQL独有的“历史债务”——因为InnoDB和Binlog是独立发展的两个系统需要协议来协调。PostgreSQL用WAL统一了持久化和复制不需要这个机制。持久性是有代价的——sync_binlog1innodb_flush_log_at_trx_commit1的TPS可能只有参数调优后的1/8。你需要根据业务场景做权衡没有银弹。下次写BEGIN和COMMIT的时候不妨想一想在MySQL里你的修改正在依次穿过Undo Log、Redo Log、Binlog两阶段提交在协调它们的一致性在PostgreSQL里你的修改正在写入WAL同一份日志既用于崩溃恢复也用于复制简洁而统一每一次COMMIT数据库都在默默帮你在“性能”和“安全”之间做出选择。理解这个选择你就真正理解了数据库事务。