资讯动态

PolarDB事务原子性深度解析:从WAL到分布式崩溃恢复

发布时间:2026/10/20 10:08:42 来源:尧图企业网站定制
不知道你有没有想过一件事一个转账事务在“扣钱成功、加钱还没完成”的那一毫秒机器突然断电了会发生什么几乎所有数据库教材都会告诉你事务的原子性保证这种情况绝对不会留下中间状态。但“保证”这两个字背后是一整套非常繁琐的机制日志要写到哪里、数据页什么时候落盘、崩溃之后从哪里开始恢复、恢复过程中如果又崩了怎么办……这套东西单机数据库时代已经够绕了一旦数据库变成像 PolarDB 这样“计算与存储分离”的架构很多原来默认成立的前提就都不成立了。这篇我打算从数据库内核的视角把 PolarDB 的原子性实现从头拆一遍。不搞名词枚举而是沿着“为什么难、怎么设计、崩了怎么办、跨节点怎么办、怎么验证”这条链路往下走。读完你至少能回答三个问题redo 和 undo 在 PolarDB 里到底怎么分工计算存储分离之后原子性的维护成本落在了谁头上以及业务里那些“事务好像没生效”的坑到底是哪一环出了问题。适合谁看后端开发、DBA、对数据库内核感兴趣的架构师以及所有准备把核心交易场景往云原生架构迁移的人。1. 原子性到底在解决什么问题1.1 转账场景里“中间状态”为什么不能靠事后补救经典的转账例子A 账户扣 100 元B 账户加 100 元。从应用层的视角看只需要执行两条 UPDATE然后提交事务。但数据库内部实际上经历了“修改 Buffer Pool 里的数据页、写 undo、写 redo、可能触发落盘”等一系列步骤。如果系统在扣完 A、还没加 B 的那一刻崩溃数据页上可能已经是“A 少了 100B 没变”的中间状态。有人可能会说那启动的时候扫描一下账户表发现账不平把少了的补回去不就行了这个想法在玩具系统里可行但真实数据库根本做不到。原因在于崩溃之后数据库不知道哪些操作属于同一个未完成事务。几百个并发事务交织在一起每一条记录可能被多个事务改过想靠“事后扫描和猜”来判断哪些修改需要撤销在数学上就不成立。所以正确的思路只有一条在操作发生之前先把“这次事务要做什么以及如果回滚该怎么做”完整记录下来。这就是 undo 和 redo 日志存在的根本原因。1.2 原子性质的两层含义不留半截也不丢已提交严格说事务的 ACID 里A原子性和 D持久性是捆在一起设计的。原子性要求“未提交事务的修改必须能完整撤销”持久性要求“已提交事务的修改必须不丢失”。这两个要求听起来是对立的但实现它们的机制是同一套日志。用大白话讲数据库把每一次操作都“记账”了。崩溃恢复时数据库干两件事凡是没有提交的事务按照 undo 记录“退回原样”凡是已经提交的事务按照 redo 记录“重新执行一遍或者确认它已经生效”。注意这里有个容易被忽略的点已提交事务的修改未必真的写到了磁盘的数据页上。数据库为了性能通常会延迟刷脏页。如果事务提交后、脏页刷盘前发生断电内存里的修改全没了。没有 redo 日志的话这个已提交事务就凭空消失了这是不可接受的。所以 redo 日志必须先于数据页落盘这就是 WALWrite-Ahead Logging的核心思想。1.3 一个常见误区原子性不是“加个大锁”我见过不少开发者把原子性和锁混为一谈。有人说“我这几个更新操作都放在一个同步锁里外面根本不可能看到中间状态这不就是原子性吗”这话只对了一半。锁解决的是“并发下别人能不能看到中间状态”它管的是隔离性。原子性要解决的是“系统崩溃后中间状态能不能被恢复”它管的是故障恢复。加了锁其他事务确实看不到 A 已经扣钱而 B 没加钱的状态但如果数据库进程被 kill 掉锁也一起消失了下一条数据落盘的依然可能是中间状态。真正能让系统从中间状态恢复过来的只有日志。还有一个常见误区有人觉得“既然要保证一致性先把数据页备份一份再改不就行了”。这个思路在少量数据上成立但真实场景里一个事务可能涉及几千页备份成本是 O(数据量)而写日志的成本是 O(修改量)。日志机制本质上就是用“顺序追加的小文件”换“随机修改的大文件”代价小得多这也是它能成为工业标准的原因。2. 经典原子性架构redo、undo 与 WAL 的三权分立2.1 WAL 为什么必须先写日志再改数据WAL 的核心规则一句话数据页允许晚点落盘但日志必须先落盘。为什么必须这样因为日志是顺序写的数据页是随机写的。顺序写在机械盘时代有巨大性能优势在 SSD 和云盘时代依然是更可控的 IO 模式。但性能只是表面理由更深刻的原因是可恢复性。想象一个反过来的流程先改数据页再写日志。如果数据页改到一半崩溃日志里没有这次操作的任何记录那数据库恢复时根本不知道这个数据页已经被改过一半了。它可能是一个半新的页面也可能是一个混合了新旧数据的页面这种状态在物理上就是损坏的。反过来先写日志再改数据页哪怕数据页写到一半崩溃恢复时只要拿着日志重放一遍就能把页面重新“做”成完整的最终状态。我用一个生活化的类比盖房子的时候工人每天先写施工日志记录“今天砌了第几面墙”然后再动手砌墙。如果墙砌到一半塌了施工队长只要翻开日志就知道应该从哪面墙重新砌。日志本身不怕重复重放一百遍墙还是那面墙。但如果反过来墙先砌歪了日志又没记那谁也不知道这面墙本来该是什么样。2.2 undo 日志一本“反向操作手册”redo 负责“往前重做”undo 负责“往后回滚”。这两个东西很容易混淆我拆开说。undo 里记录的不是数据的最终值而是撤销这次修改所需的信息。比如一条 UPDATE 把某行余额从 100 改成 0undo 里记录的就是“这行原来的值是 100主键是 xxx”。事务回滚时数据库沿着 undo 链找到这条记录把余额改回 100。INSERT 的 undo 记录里存的是主键回滚时直接删除这行DELETE 的 undo 记录里存的是整行的旧值回滚时把这行插回去。与 redo 不同undo 不只是崩溃恢复才用。隔离级别为读已提交或可重复读时普通查询如果发现某行被其他未提交事务改了也要通过 undo 链构造旧版本快照。这就是 MVCC 的底层机制。所以 undo 是“事务回滚”和“一致性读”两者共享的基础设施。InnoDB 系的引擎里undo 是物理日志和逻辑日志的结合体定位靠物理主键修改靠逻辑操作。这么设计是因为数据页可能被重新组织单纯记录字节级“旧值”不一定能安全复原而逻辑操作配上主键定位则稳定得多。2.3 redo 日志的幂等性为什么是刚需崩溃恢复有个要命的问题恢复过程本身也可能崩溃。假设恢复程序正在重放第 100 条日志数据页改到一半机器又断电了。重启之后恢复程序必须从头再来一遍。如果日志没有幂等性第二次重放可能把数据页改成错误的结果。所以 redo 里记录了类似 LSNLog Sequence Number日志序列号的单调递增标识。每个数据页也会记录自己“已经应用到了哪个 LSN”。恢复时如果日志的 LSN 小于或等于数据页当前的 LSN说明这次修改已经生效直接跳过。这样第 100 条日志哪怕被重放一百次结果也是幂等的。换到写作场景理解每一页作业都编了页码批改时只改页码大于当前进度的部分。已经标到第 50 页的作业本绝不会因为又拿第 30 页的答案来对一遍而出错。这是 redo 重放安全的基石也是 PolarDB 能放心让存储节点提前消费 redo 的前提。3. PolarDB 的架构变化给原子性带来了哪些新约束3.1 计算存储分离日志不再躺在本地磁盘单机 MySQL 的 redo 写在本地磁盘崩溃后操作系统带着磁盘一起恢复没什么网络问题。PolarDB 不一样计算节点负责解析 SQL、执行事务、维护 Buffer Pool存储节点负责把数据页持久化到分布式存储上。计算节点和存储节点之间走的是网络日志也得通过网络传输到存储层。这一变化带来两个直接后果。第一日志的“落盘确认”从本地返回变成了网络往返。以前写 redo 是本地文件 IO现在要等存储节点确认收到并持久化。事务提交的延迟会因此变高所以 PolarDB 在存储层做了多副本和批量提交的优化把网络延迟的影响压到最低。第二崩溃恢复的职责被拆开了。单机库里崩溃后是本机进程从本地日志恢复。PolarDB 里计算节点崩溃后新的计算节点要能从存储层拿回“已经持久化到什么位置”的日志状态并基于这个状态继续恢复。存储层必须是一个“即使计算节点全挂了日志也不丢”的可靠底座。3.2 物理复制为主存储节点要能“消费”redo传统的主从复制主库生成 binlog 或类似的逻辑日志从库拿到 SQL 或者行变更消息在本地重新执行一遍。这种逻辑复制的问题在于从库的数据页内容和主库并不完全一致只是“逻辑等价”。如果主库的数据页有某种物理布局逻辑复制是复制不过去的。PolarDB 用的是基于 redo 的物理复制。主计算节点产生 redo 日志存储节点拿到 redo 后直接把它应用到数据页上。这样一来存储节点上保存的页面和主计算节点内存里的页面是物理一致的只读节点不需要通过 SQL 回放来追赶主节点直接读取已经被 redo 推进过的数据页即可。回到原子性的语境“物理复制”意味着存储节点对 redo 的消费不能破坏事务语义。redo 里记录的每个修改必须保持“要么整个事务的所有修改都应用要么都暂时不应用”的推进逻辑吗这里恰恰有个精妙之处存储节点应用 redo 时并不区分事务是否已提交。这听起来好像违反原子性其实没有。未提交事务的 redo 被应用到存储层的数据页只是在磁盘上留下了一个“可能被回滚”的中间版本。真正的事务原子性由计算节点上的 undo 与事务状态表保证如果事务最终回滚undo 会把那些修改全部退回去。存储层提前应用 redo 只是为了减少崩溃后的恢复时间它不承担“判断事务是否提交”的职责。3.3 共享存储上的唯一标尺LSN 的推进逻辑多副本的存储层要回答一个非常基本的问题大家同时对日志做持久化到底以谁为准怎么保证副本之间一致答案是多数派确认。一条 redo 日志只要被多数副本确认写入这条日志就是“持久化成功”的。事务提交时只要 redo 日志被多数派确认就可以向客户端返回成功没有必要等所有副本都落盘。这个过程有点类似于分布式共识里的法定人数机制它用概率换确定性只要多数副本没同时丢日志就不会丢。在原子性层面LSN 就是一把唯一的尺子。所有副本上的数据页都带 LSN 标记存储节点按 LSN 从小到大应用 redo。如果某个数据页的 LSN 是 100而新到的日志 LSN 是 90说明这是乱序老日志直接丢弃。如果数据页 LSN 是 80新日志 LSN 是 90就正常应用。这套逻辑保证每个副本最终都会收敛到同一个 LSN 位置不会出现“这个节点记录多一些那个节点记录少一些”的分叉。共享存储上所有计算节点看到的数据版本因此是一致的。4. 崩溃恢复的完整闭环从 redo 重放到 undo 回滚4.1 重启之后数据库如何找到“从哪儿开始修”崩溃恢复的第一步是定位。数据库定期做检查点Checkpoint来记录“某个 LSN 之前的所有修改都已经落盘”。恢复程序从最后一个完整检查点对应的 LSN 开始扫描 redo而不是从头扫到尾这样能大幅缩短恢复时间。对 PolarDB 来说“落盘”这个概念被网络化了。检查点推进的逻辑是计算节点在内存里维护一个“当前已确认持久化的 LSN”水位这个水位随存储层日志确认而增长。崩溃恢复时新的计算节点从存储层读取这个水位就知道哪些日志已经安全落地哪些日志需要重新拉取并应用。关键点是恢复程序不需要精确判断“哪条日志属于哪个事务”。它从头往后无脑应用 redo把所有崩溃前已经写入但尚未完全持久化到数据页的修改一并补到数据页上。这个过程叫“前滚”。4.2 存储层已经应用了一部分 redo剩下的怎么接上PolarDB 恢复快的一个核心原因就是存储节点平时一直在消费 redo。崩溃发生时绝大多数数据页已经被 redo 推进到了“最新点附近”。计算节点恢复时只需要处理尾部的少量日志而不是把整个事务期间产生的所有 redo 全部重放一遍。“接上”的过程依赖 LSN 的幂等性。恢复程序从存储层读取每个数据页的当前 LSN再结合日志里记录的 LSN只应用那些“页 LSN 小于日志 LSN”的记录。也就是说哪怕日志在网络上重复传输、重复应用只要 LSN 没涨应用结果就不会错。这个设计直接拉高了恢复速度的上限。单机数据库的恢复时间正比于崩溃前未落盘的数据量云原生架构的恢复时间则正比于存储层未消费的日志缺口。业务高峰期的崩溃前者可能恢复几十分钟后者往往几秒钟就完成了差别非常大。4.3 undo 回滚和 MVCC 的联动前滚结束之后数据库马上开始“回滚未提交事务”。这里有一个容易误解的点前滚阶段把所有修改都恢复了包括未提交事务的修改。那恢复程序怎么知道哪些事务是未提交的答案是数据库在正常运行期间会周期性记录“活跃事务列表”也存在日志里。恢复时拿这个列表和 redo 里出现的所有事务一做比对就能找出一批“有修改但没提交”的事务。对这些事务逐个扫描它们在 undo 里留下的回滚段反向执行撤销操作。有意思的是undo 回滚和 MVCC 读在代码路径上是同一条。一个运行中的长事务如果修改了某行这行的旧版本会留在 undo 链上。普通读事务查询到这行时会根据自己启动时的快照版本沿着 undo 链往回找。崩溃恢复里的回滚事务本质也是沿着这条链往回找“事务开始前的值”。区别只在于普通读是“找得到的值就算”恢复回滚是“必须把所有修改都还原到起点”。从这个角度看MVCC 不只是并发控制机制它本身就是原子性的一部分。没有 MVCC 对旧版本的保留能力回滚就只能靠复制整页的镜像数据代价会高得多。5. 跨节点事务当原子性需要“跨库”才能成立5.1 单写多读架构下的原子性边界前面讨论的原子性默认前提是“所有修改都在一个计算节点上完成”。PolarDB 的单写多读架构里写流量被收敛到一个主计算节点只读节点不接收写事务。这种情况下原子性完全由主计算节点的事务引擎保证只读节点只是因为存储层被 redo 推进到了新版本看到的是一致性快照。但业务规模大了以后就会出现“一个事务需要跨多个数据库实例”的需求。比如订单库和库存库拆成了两个独立实例下单时既要往订单库插入记录又要扣减库存库的数量。任何一个库单独崩溃都不能出现“订单建了但库存没扣”或“库存扣了但订单没建”的状态。这时单节点事务引擎那套原子性机制就不够用了需要引入分布式事务协议。5.2 为什么跨节点事务要引入两阶段提交跨节点事务最朴素的错误做法是先提交 A再提交 B。只要 B 提交失败A 已经提交了整体就是不一致的。有人会说那先提交 B失败就不提交 A 呗。可网络是不可靠的任何顺序提交都存在同样的窗口期。两阶段提交2PC解决这个问题的思路是把“最终决策”和“执行决策”分开。第一阶段 Prepare协调者给所有参与者发 Prepare参与者各自把本地事务写到 redo进入“可以提交、也可以回滚”的中间状态然后回复“准备好了”。第二阶段 Commit/Abort协调者收到所有参与者的“准备好了”之后才统一发 Commit只要有任何参与者没准备好就统一发 Abort。这个过程很像团建活动里的聚餐结账每个人先看好自己的账单、确认自己能付款Prepare等团长收到所有人的确认后才喊“成交”Commit。任何人说自己没法付款团长就宣布取消Abort。2PC 的精华在于没有任何一个参与者可以单方面改变“准备好”的状态最终结果必须由协调者的统一决策来定。这保证即使某个参与者在第二阶段崩溃了其他参与者也不能擅自提交或回滚只能等待协调者恢复后拿到最终指令。5.3 悬挂事务、协调者故障与全局时间戳2PC 最麻烦的场景是协调者在第一阶段之后崩溃。此时每个参与者都处于“准备好”的状态但没人知道最终该提交还是回滚。这个状态的分布式事务叫“悬挂事务”。怎么处理工业界的做法通常是协调者把自己的决策先写入高可用的日志存储然后才向参与者发出提交或回滚指令。即使协调者进程崩溃新协调者也能从日志里查出“这条事务我到底决定了提交还是回滚”继续完成第二阶段。这套逻辑本质上就是“协调者自身也要有事务日志”。另外分布式事务还要解决“快照一致性”问题。两个事务分别在不同实例上同时提交如果一个读请求想在单个时间点看到它们的一致结果就需要一个全局统一的时间戳。PolarDB 的分布式形态里全局时间戳GTS用来给事务排定提交顺序。有了统一的提交序号分布式事务的原子提交和快照读才能对齐到同一条时间线上。这里有个容易被忽略的细节分布式事务的性能损耗主要来自 Prepare 阶段的额外一轮网络交互。所以实践中能不能用分布式事务要谨慎评估。能用“最终一致”的业务设计就别上分布式事务能用单实例分区解决的也尽量别跨库。真到了必须跨库的阶段优先考虑数据库自带的分布式事务能力而不是在应用层自己造轮子因为应用层手写 2PC 的故障处理非常容易出 bug。6. 落在实践如何观察和验证 PolarDB 的事务原子性6.1 用一个简单实验模拟“断电回滚”理论说了一大堆不如动手验证一次。你可以用一个很小规模的测试环境模拟崩溃回滚和提交持久化。第一个实验验证回滚。建一张测试表比如CREATE TABLE tx_test ( id INT PRIMARY KEY, balance INT ); INSERT INTO tx_test VALUES (1, 100), (2, 100);然后开启一个事务插入一条记录、更新一条记录但不提交START TRANSACTION; INSERT INTO tx_test VALUES (3, 300); UPDATE tx_test SET balance balance - 20 WHERE id 1;随后直接模拟“会话被杀”。在 MySQL 兼容的 PolarDB 上可以用 SHOW PROCESSLIST 找到会话 ID然后执行 KILL 来中断会话。注意这里要杀的是会话本身不是整个数据库实例。事务被中断后连接断开事务回滚。重新查询表SELECT * FROM tx_test;结果应该是id3 的记录不存在id1 的余额还是 100。这说明事务未提交部分被 undo 完整撤回了。第二个实验验证提交后的持久性。这次我们把事务提交然后立刻把整个数据库实例杀掉START TRANSACTION; UPDATE tx_test SET balance balance - 20 WHERE id 1; UPDATE tx_test SET balance balance 20 WHERE id 2; COMMIT;提交成功后强行终止计算节点实例。等实例重启后再次查询两条记录的余额应该分别是 80 和 120。这组数据能够恢复出来靠的就是 redo 日志在提交时已经确认持久化。如果 redo 没落盘就返回了提交成功重启后数据就会丢这就是持久性被破坏的典型症状。6.2 业务里的“假原子性”陷阱很多上线事故表面看是“事务没生效”实际不是数据库的问题是事务边界根本没建立起来。最常见的坑有两个。第一个坑事务被隐式提交打断。在 MySQL 兼容的生态里DDL 语句会自动提交当前事务。比如你在事务里先执行了一条 UPDATE、又执行了一条 ALTER TABLE再执行一条 DELETE结果会是UPDATE 被自动提交了DELETE 因为 ALTER 之后的某个错误回滚前面那笔 UPDATE 已经改不回来了。排查这种问题需要看日志里是否出现了 DDL 语句、是否在事务中间调用了存储过程之类的会自动提交的操作。第二个坑应用层的事务注解没生效。如果应用通过连接池连接数据库但事务注解只加在了某个非 public 方法上或者因为同类自调用导致代理不生效那么你的“事务”很可能压根没开启。你以为是数据库原子性在兜底其实是多个独立提交在那里跑中间任何一步失败前面已经提交的数据都不会回滚。验证方法很简单在事务代码里人为抛一个异常看数据是不是真的回滚了。如果不回滚先检查事务边界别急着怀疑数据库。6.3 几条贴近真实的运维经验最后说几条我在实际运维和压测里总结出来的经验不一定写在官方文档里。第一监控活跃事务比监控慢查询更重要。一直挂在 information_schema.innodb_trx 上的长事务是 undo 膨胀和锁冲突的根源。我见过一次事故某个定时任务在事务里跑了一个复杂报表半小时没提交导致同一张表的所有更新全部锁等待业务直接瘫痪。事后加了活跃事务告警这类问题基本能提前发现。第二不要在事务里做外部网络调用。事务期间行锁和 undo 都处于“待命”状态拖得越久锁冲突概率越高。如果调用的外部接口很慢整个事务的存活时间会拉长数据库的并发能力被严重侵蚀。代码评审里我一般会直接要求事务里只放纯数据库操作。第三大事务要及时拆分。一个事务更新几十万行产生的 undo 可能非常庞大一旦需要回滚耗时可能是执行的数倍。PolarDB 的存储节点有后台任务帮你推进数据页但 undo 的清理还是要等事务结束。拆分事务还能减少锁覆盖范围是个既保护数据库又保护业务的好习惯。第四redo 日志容量和写入吞吐要提前规划。事务提交频率非常高时redo 的写入速率会成为瓶颈。压测阶段就应该观察 redo 写入量是否接近上限。如果接近了光加计算节点规格不一定有用可能需要调整写入模型比如合并小事务、降低提交频率。回看整条链路原子性从来不是“数据库自动就有的魔法”。它就是日志机制、存储确认、崩溃恢复、分布式协调这几个环节环环相扣的结果。PolarDB 做的事情本质上是在把单机数据库那些默认成立的前提一个个重新审视一遍然后在新的架构约束下找到等价的保障方式。我在实际使用中最深的体会是别把原子性当成数据库单独的事。事务边界怎么划、长事务怎么监控、分布式事务值不值得用这些都是业务侧要主动做的功课。数据库给了你一个可靠的底盘但方向盘始终在你手里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑