不知道你有没有想过一件事:一个转账事务,在“扣钱成功、加钱还没完成”的那一毫秒,机器突然断电了,会发生什么?
几乎所有数据库教材都会告诉你,事务的原子性保证这种情况绝对不会留下中间状态。但“保证”这两个字背后,是一整套非常繁琐的机制:日志要写到哪里、数据页什么时候落盘、崩溃之后从哪里开始恢复、恢复过程中如果又崩了怎么办……这套东西单机数据库时代已经够绕了,一旦数据库变成像 PolarDB 这样“计算与存储分离”的架构,很多原来默认成立的前提就都不成立了。
这篇我打算从数据库内核的视角,把 PolarDB 的原子性实现从头拆一遍。不搞名词枚举,而是沿着“为什么难、怎么设计、崩了怎么办、跨节点怎么办、怎么验证”这条链路往下走。读完你至少能回答三个问题:redo 和 undo 在 PolarDB 里到底怎么分工?计算存储分离之后,原子性的维护成本落在了谁头上?以及,业务里那些“事务好像没生效”的坑,到底是哪一环出了问题。
适合谁看:后端开发、DBA、对数据库内核感兴趣的架构师,以及所有准备把核心交易场景往云原生架构迁移的人。
1. 原子性到底在解决什么问题
1.1 转账场景里“中间状态”为什么不能靠事后补救
经典的转账例子:A 账户扣 100 元,B 账户加 100 元。从应用层的视角看,只需要执行两条 UPDATE,然后提交事务。但数据库内部实际上经历了“修改 Buffer Pool 里的数据页、写 undo、写 redo、可能触发落盘”等一系列步骤。
如果系统在扣完 A、还没加 B 的那一刻崩溃,数据页上可能已经是“A 少了 100,B 没变”的中间状态。有人可能会说:那启动的时候扫描一下账户表,发现账不平,把少了的补回去不就行了?这个想法在玩具系统里可行,但真实数据库根本做不到。原因在于:崩溃之后,数据库不知道哪些操作属于同一个未完成事务。几百个并发事务交织在一起,每一条记录可能被多个事务改过,想靠“事后扫描和猜”来判断哪些修改需要撤销,在数学上就不成立。
所以正确的思路只有一条:在操作发生之前,先把“这次事务要做什么,以及如果回滚该怎么做”完整记录下来。这就是 undo 和 redo 日志存在的根本原因。
1.2 原子性质的两层含义:不留半截,也不丢已提交
严格说,事务的 ACID 里,A(原子性)和 D(持久性)是捆在一起设计的。原子性要求“未提交事务的修改必须能完整撤销”,持久性要求“已提交事务的修改必须不丢失”。这两个要求听起来是对立的,但实现它们的机制是同一套:日志。
用大白话讲,数据库把每一次操作都“记账”了。崩溃恢复时,数据库干两件事:
- 凡是没有提交的事务,按照 undo 记录“退回原样”;
- 凡是已经提交的事务,按照 redo 记录“重新执行一遍,或者确认它已经生效”。
注意这里有个容易被忽略的点:已提交事务的修改,未必真的写到了磁盘的数据页上。数据库为了性能,通常会延迟刷脏页。如果事务提交后、脏页刷盘前发生断电,内存里的修改全没了。没有 redo 日志的话,这个已提交事务就凭空消失了,这是不可接受的。所以 redo 日志必须先于数据页落盘,这就是 WAL(Write-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 改成 0,undo 里记录的就是“这行原来的值是 100,主键是 xxx”。事务回滚时,数据库沿着 undo 链找到这条记录,把余额改回 100。INSERT 的 undo 记录里存的是主键,回滚时直接删除这行;DELETE 的 undo 记录里存的是整行的旧值,回滚时把这行插回去。
与 redo 不同,undo 不只是崩溃恢复才用。隔离级别为读已提交或可重复读时,普通查询如果发现某行被其他未提交事务改了,也要通过 undo 链构造旧版本快照。这就是 MVCC 的底层机制。所以 undo 是“事务回滚”和“一致性读”两者共享的基础设施。
InnoDB 系的引擎里,undo 是物理日志和逻辑日志的结合体:定位靠物理主键,修改靠逻辑操作。这么设计是因为数据页可能被重新组织,单纯记录字节级“旧值”不一定能安全复原,而逻辑操作配上主键定位则稳定得多。
2.3 redo 日志的幂等性为什么是刚需
崩溃恢复有个要命的问题:恢复过程本身也可能崩溃。假设恢复程序正在重放第 100 条日志,数据页改到一半,机器又断电了。重启之后,恢复程序必须从头再来一遍。如果日志没有幂等性,第二次重放可能把数据页改成错误的结果。
所以 redo 里记录了类似 LSN(Log 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;结果应该是:id=3 的记录不存在,id=1 的余额还是 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 做的事情,本质上是在把单机数据库那些默认成立的前提一个个重新审视一遍,然后在新的架构约束下找到等价的保障方式。
我在实际使用中最深的体会是:别把原子性当成数据库单独的事。事务边界怎么划、长事务怎么监控、分布式事务值不值得用,这些都是业务侧要主动做的功课。数据库给了你一个可靠的底盘,但方向盘始终在你手里。