前言
古法编程,匠心手写
不逐速成,不耗 token
源于逻辑,成于指尖
推演虚实,因果尽显
每一行代码,皆为手工雕琢
每一次运行,不负初心坚守
古法编程——致敬纯粹之美🫡

多年的工程经验告诉我,技术上成功的项目很少基于“绝对完美”的设计,更多时候来自在复杂约束下做出合适的 trade-off。复制、分片、事务、一致性与共识……这些概念在教科书里以独立的章节呈现,但在真实系统中往往交织耦合在一起,显著增加了工程师的心智负担。因此,如果没有深入理解这些机制是如何协同工作的,就很难在设计时做出可靠的判断。反过来说,对强一致事务实现机制了解越多,在面对各种取舍时就越有把握。当然,钻研这些机制,并不意味着设计系统时就应该默认引入最强语义,而是要知道这些语义究竟由哪些代价来支撑,最终会把复杂度引向何处。
先前通关了 CMU-15445 之后,我感觉自己对单机 SQL 已经有了非常深刻的理解。而当我读完 DDIA,感觉自己对事务的理解还是欠缺最后一层:我们在 DynamoDB 里天天用的分布式事务到底是怎么实现的?于是,我翻出了之前学习 MIT 6.5840 公开课时写的 Raft 和 Shard KV 代码,在此基础之上,手搓了一套经典的两阶段提交(2-phase commit, 2PC)事务支持。做完之后,最大的感受是:分布式系统里很多看起来很简单的抽象和理论,一旦真的要把它落地成工程,就会发现困难会从系统各种组件交互所引入的无数细节中,源源不断地冒出来。
这篇文章就系统梳理一下这套 2PC 实现里遇到的几个关键技术点以及实现思路。
起源:为什么需要分布式事务
MIT 6.5840 的经典老版本 lab,主线大致是先实现 Raft 协议,再实现基于 Raft 的单分片 KV,最后加入 controller 演进为 Shard KV(分片数据库)。做到这里,系统已经具备:
- 基于 shard 水平拆分数据库
- 单 replica group 内的复制一致性
- 支持 group 配置变更和 shard 数据迁移
但它仍然缺少一项非常重要的能力:一系列操作同时跨多个 shard,且要求原子提交——也就是数据库里的分布式事务。如果缺少这项能力,就无法基于我们的 KV 数据库来实现 outbox pattern 之类的 exactly-once processing 机制,而这是实现幂等业务逻辑的基础。
因此,我们的目标是:在已有 Shard KV 之上,尽量非侵入地接入一层经典 2PC 事务支持,从而实现一个功能完整的分布式 KV 数据库。
如果一个事务只落在单个 shard 上,那么把它塞进那个 replica group 自己的 Raft 日志就够了;但如果一个事务同时涉及多个 group,就必须额外解决两个问题:
- 确保所有参与者最终要么一起提交,要么一起回滚(Atomicity)
- 在提交结果落定之前,别的事务不能破坏这笔事务的隔离(Isolation)语义
第一个问题对应 2PC,第二个问题对应 2PL(2-phase locking)。
事务接口设计
我们定义如下接口:
Clerk.Transaction(txnId string, operations []TxnOperation) []string
其中一笔事务由一组顺序操作[]TxnOperation组成,其中包含了一系列的GET/PUT/APPEND;返回值也严格按输入位置一一对应:
GET返回读到的值PUT返回写入值APPEND返回追加后的最终值- 如果事务 abort,则返回
nil
当遭遇网络抖动、服务器 crash/recovery、leader 切换等不稳定因素,此时客户端重试同一笔事务是常态,不是例外。所以, Transaction() 的语义必须明确支持幂等:
- 同一个
txnID的重复调用,应当返回第一次事务的结果 - 即使后续调用携带了不同的
operations,也以第一次成功进入状态机的那份事务内容为准
这里需要注意的细节:系统最终不是按“最后一次请求长什么样”执行,而是按“第一份已持久化的事务计划”执行。并且我们不是只要 commit 成功就行,coordinator 还要能幂等地返回完整的 Values,其顺序和输入operations一致,而且重试后的结果也一致。所以, coordinator 不能只记录“这笔事务提交成功了”,还必须把最终结果也持久化下来。当然在实际的工程实现中,我们一定会引入某种 TTL 机制来清理掉过期许久的事务,以免空间占用无限膨胀。
Coordinator
2PC 中的 coordinator 主要负责解决三个问题:
- 事务状态如何持久化
- 如何安全推进状态
- 崩溃后如何恢复
状态机定义
有了之前实现 Shard KV 的经验,我们知道 coordinator 无非也就是维护 TxnID => TxnState的一组复制状态机,定义其状态如下:
- Prepare
- Commit
- Abort
- Committed
- Aborted
它的含义分别是:
- Prepare:事务信息已经被持久化,正在发 prepare RPC 给所有参与者
- Commit:已经做出 commit 决议,正在传播给所有参与者
- Abort:已经做出 abort 决议,正在传播给所有参与者
- Committed/Aborted:事务终态,可以直接返回给 client
- 一旦 coordinator 决定进入
Commit,之后就不能再回退成Abort - 一旦 coordinator 决定进入
Abort,之后也不能再回到Commit
这里把 Commit 和 Abort 做成显式的中间态,而不是只记录终态,原因在于:“做出决议” 和 “所有 participant 都已经完成最终动作” 不是一回事,如果 coordinator 在正在传播 commit / abort 的时候发生了崩溃,必须要借助 Raft 从崩溃恢复,因此要显式把这个状态写入 log。
状态推进
Raft.Start()只是提议,不是共识
回顾我们之前实现的 Shard KV,推进状态时我们需要遵循特定的模式:“先把命令交给 Raft,再等待本地状态机 apply 成功后才继续”。原因在于在这个系统里,真正具有语义效力的持久化状态并不是某个 leader 线程本地临时构造出的结果,而是已经进入复制状态机并按日志顺序被执行后的结果。Raft.Start() 只能说明当前节点能够提议一条日志,它既不能保证这条日志最终会提交,也不能保证当前节点在这条日志提交时仍然是 leader,更不能保证这条日志不会被别的日志覆盖掉。
如果在 Start() 返回后就把事务视为已经持久化,协调者实际上是在拿一个尚未被集群确认、也尚未被状态机吸收的意图当成事实使用。这样一来,后续逻辑可能依据一个并不存在的事务状态继续推进 prepare、commit 或 abort,崩溃恢复时又会发现状态机里根本没有对应记录,最终破坏了“恢复后一定可以相信状态机”的基本保障。
事务操作的持久化
一笔跨 shard 事务在进入 coordinator 时,会先被按当前配置切成:“groupID => 本 replica group 负责的操作子序列”。但仅仅保存这些 group-local 子操作还不够,最终还需要把各 participant 返回的局部 Values 按照原始事务中的顺序拼接回去。因此,coordinator 还要额外持久化“groupID => 这些子操作在原始事务里的位置下标”。
显然,发起事务时所使用的 Config 也要被存下来,这样 coordinator 在发起 RPC 的时候才能通过 groupID 找到对应 replica group 的地址。也就是说,我们将某个事务与它发起时的配置(shard => replica group)做了绑定,后续我们会看到这对于正确实现 reconfiguration 至关重要。
崩溃恢复
虽然基于 Raft 的状态机是持久的,但实际负责推进状态的是线程,而线程是不可靠的。既然 coordinator 可能会崩溃,那么崩溃恢复之后,已经没有线程负责继续推进状态机中尚未进入终态的事务——也就是发送 RPC 并收集回复的工作。
此时我们必须回答:一笔停在 Prepare/Commit/Abort 中间态的事务,谁来继续推进?直观上,一定需要引入某种 recovery / reconciliation driver:
- leader 周期性扫描 coordinator 状态机
- 找出处于 Prepare/Commit/Abort 的中间态事务
- 如果当前没有线程负责推进它,就重新拉起对应的工作线程
这里我们要注意,启动新线程的时候要按照 TxnID + TxnStatus 去重,以避免拉起多个线程做重复工作;而且要注意不能只按 TxnID 做去重,因为存在这样一种 corner case:
- 执行 Prepare 状态的线程还没有彻底退出
- 但它已经将 commit / abort 决议持久化进了状态机
- 后续推进新状态的线程刚刚启动
- 此时如果看到同一个
TxnID上已经有线程在运行,就会被错误挡掉
使用共识算法的难点不仅在于状态机的设计,更在于你能不能把状态推进中的并发运行时语义也想清楚。
这也是为什么很多系统“看起来对”,但一跑就出问题。
Participant
状态定义
与 coordinator 不同的是,participant 一侧只保留 Prepared、Committed、Aborted 三个状态,这是因为它只承担局部的职责。参与者不负责决定事务最终命运,它只需要回答两件事:这笔事务是否已经在当前 replica group 成功拿到锁,以及最终决议到来后,当前 group 应当执行 commit 还是 abort。Prepared 表示本地检查已经通过且锁已持有,等待协调者给出最终决定;Committed 和 Aborted 分别表示这笔事务在本地已经终结,并用于支持幂等重试。更细的中间状态放在 coordinator 侧才有意义,因为只有 coordinator 才需要区分“正在发 prepare”、“准备进入 commit”、“正在发 abort”这类事务状态推进阶段。
从 2PC 到 2PL
绝大多数只读过教科书的人,提起分布式事务第一反应是 2PC。但真做下来会发现,2PC 本身只完成了拼图的一半——“大家一起 commit 还是一起 abort”的原子决议,而另一半工作在于事务之间的隔离控制,两者加在一起才能实现最终目标:强一致性。
分布式事务最终总要把某些资源或提交条件固定住,从而阻止会破坏当前事务提交正确性的并发变化。在 participant 侧,我们需要实现给事务所涉及的 keys 上锁的机制,这基本就是一套严格的 2PL,其大体语义是:
- Prepare 时做完整冲突检查,未通过就直接 abort 事务
- 只有全部通过后才把事务涉及的 key 加入读写锁表
- 一旦进入 Prepared,锁一直持有到 Commit / Abort
- 最终决议 RPC 到来后再统一释放锁
如果不持锁到 commit,其他事务可能在 prepare 与 commit 之间提交并发修改,导致隔离性被破坏。
加锁的原子性
这里一个重要约束是本地 prepare 必须是原子加锁,即一笔事务在某个参与者上要么完整获得所有 key 的锁并进入 Prepared,要么 abort 并且不留下锁了一半的状态。这个性质主要依赖 Raft 状态机 apply 的串行执行:锁冲突检查和锁表更新发生在同一个单线程状态机步骤里,不存在两个 prepare 在本地并发交错修改锁表的问题。提交(commit)时参与者顺序执行事务中的操作,返回局部结果并释放锁;回滚(abort)时参与者直接释放锁。这样一来,事务的提交原子性来自两阶段提交,事务的隔离性则由 prepare 后持续持有的本地锁来保证。
ConditionCheck
为了支持类似 DynamoDB 风格的事务条件(ConditionCheck),我们采取一个比较简化的方案,直接把条件检查也作为一种特殊的OpType:
type OpType string
const (
Put OpType = "Put"
Append OpType = "Append"
Get OpType = "Get"
// Transaction support
TxnCondEqual OpType = "TxnCondEqual"
TxnCondNotEqual OpType = "TxnCondNotEqual"
TxnCondExist OpType = "TxnCondExist"
TxnCondNotExist OpType = "TxnCondNotExist"
)
type TxnOperation struct {
Key string
Value string
Op OpType
}
这些条件检查操作和普通的 Get/Put/Append 一样,直接出现在同一笔事务的 operations 列表中,participant 会在 Prepare 阶段统一检查这些条件:
- 所有条件成立,事务继续推进
- 有任何条件不成立,participant 直接返回 abort,整笔事务不会进入提交阶段
在并发控制上,这些条件检查按只读访问处理:仅参与本地冲突检查并更新读集合(read set),但不会在 Commit 阶段修改实际数据。
这样一来,我们就不需要额外设计“单条写操作自带复杂条件表达式”的接口;而相应的需求可以拆成多条显式的条件检查,再和后续写操作一起放进同一笔 2PC 事务中,最终语义仍然保持不变:条件检查和后续写入要么一起成功;要么一起失败且不留下副作用。
这种设计的好处是实现简单,语义清晰,也足够表达类似 DynamoDB 事务里“带条件的PUT”这类常见用法。
对于 RPC 乱序的正确处理
在不可靠网络里,完全可能发生:
- coordinator 已经决定 abort
- participant 先收到 abort
- 迟到的 prepare 才到
如果在这种时序下,收到 abort 的时候什么都不做就直接返回 OK,就会直接把一笔应该终止的事务重新执行 prepare ,并且永远卡死在这里——因为 coordinator 误以为已经正确完成了 abort。因此,在 participant 侧必须支持:
- 收到 Abort RPC 要留下 tombstone
- 后续收到同
TxnID的 Prepare RPC,就直接幂等返回 aborted
扩展 2PC:引入提交判定点
经典 2PC 对 coordinator 的活性有理论上的依赖:一旦事务停在 prepared 中间态,而 coordinator 又在完成决议广播前长时间崩溃,就会导致 participant 长期悬挂,系统无法推进。工业实现通常会围绕这一点做工程化改进。以 TiDB/Percolator 一类设计为例,事务在发起阶段会选定一个 primary participant,而所有 secondary participants 状态机会记录下 primary 是谁。这样一来,事务最终是否已经提交,就不再只能依赖原始 coordinator 来宣布;当 secondary 长时间拿不到最终决议时,它可以去查询 primary 的提交状态,再决定自己应当 commit 还是 abort。这个项目里我做了一个最小化的模拟:为事务选择 PrimaryGID,让 secondary 在等待 coordinator 期间可以主动查询 primary 的事务状态,从而部分降低对 coordinator 持续存活的依赖。
严格来说,在引入 Raft 这类持久化机制之后,coordinator 已经不再是传统意义上的单点故障。当事务状态和推进命令都已经写入复制状态机,只要多数派仍然存活,新的 leader 就可以接手并继续完成 prepare、commit 或 abort。因此,这里讨论的 primary commit 一类改进,目标不是解决“协调者一挂整个系统就不可恢复”这种问题,而是进一步降低事务推进对协调者持续可用和及时响应的依赖。换句话说,它主要改善的是活性和恢复路径,而不是弥补缺失的正确性保障。
当然,这种机制仍然有一个前提:事务至少已经推进到 primary commit 成功落地。只有在 primary 成为一个可查询的提交判定点之后,secondary 才能据此推导出本地应当 commit 还是继续等待;如果所有 participants 都停留在 prepared 阶段,那么事务依然会被卡死在这里,除非我们引入额外的垃圾回收机制来清理这些过期事务。
2PC 如何与 MVCC 以及 OCC 兼容?
在单机 SQL 数据库中,事务通常基于 MVCC(Multi-Version Concurrency Control)来实现:每次写入在提交时生成一个新的版本,并通过 commit timestamp 来决定其对其他事务的可见性。这个模型本质上是“单阶段”的——只有当事务提交后,新的数据版本才对其他事务可见。
当系统演进到分布式事务并引入 2PC 之后,一个新的问题随之出现:2PC 要求 prepare 成功的事务一定能 commit, 但是 OCC(乐观并发控制)是有可能出现直到最后的 validation 阶段,才发现当前事务的 read set 与其他事务的 write set 冲突了,此时既然已经承诺了成功,还怎么去 abort 事务呢?换句话说,在 prepare 与 commit 之间,事务已经“决定要写”,但尚未最终提交,那么这个中间状态应当如何在 MVCC 模型中表达,以确保最终一定能成功提交?
一种自然的扩展方式,就是为 MVCC 引入 intent(未提交版本),从数据模型的角度来看,它可以被理解为 MVCC version chain 的一种“中间态”:
-
在 Prepare 阶段,各个 participant 会把整个事务“几乎做完”:
- 完成读写冲突检查以避免并发异常(写倾斜)
- 写入一个带有事务标识的暂存版本(intent)进入version chain
- 该版本已经包含了最终写入的值,但尚未被标记为 committed
-
对于同一节点上发生的普通读操作而言,这类版本默认不可见
- 其可见性不再仅由 timestamp 决定,而是同时依赖事务状态(pending / committed / aborted)
- 最终 Commit 阶段,将 intent 标记为正式版本,使其对其他事务可见
在传统 2PL 中,prepare 的语义是“通过加锁冻结数据以防止冲突”;而在 2PC + OCC 中,其语义则转化为:
- 通过写入 intent 显式声明未来的写入意图,并在 version chain 中占位
- 在同一阶段完成读写集合的冲突检测,确保提交条件已经满足
- 一旦进入 prepared 状态,该 intent 即承担“锁”的语义,阻止后续事务写入同一行,或迫使其进行冲突处理
- 后续写事务如果看到 intent,不能“直接无视继续写”,否则就是写写冲突,可以选择等待、抢占或者 abort 自己
- 后续读事务通常仍可读取已提交的旧版本数据,但是在 Serializable 隔离级别下,读到 intent 会告诉系统:这里存在一个尚未完成的写,此次读必须纳入依赖追踪,并最终确定它到底是排在写之前还是之后
Serializable 的核心在于:事务之间的依赖关系图必须是无环的,只有这样才能拓扑排序出一个等价的串行执行顺序。
事务与 Shard 迁移
到这里为止,我们已经解决了事务在静态系统中的执行,但真实系统的配置(shard => replica group 映射)并不会保持不变,而 2PC 的隐含前提却是:participant 集合必须稳定。
为什么选择事务与配置绑定
我们先前提到,要把事务和它发起时系统的配置绑定起来,这里先解释一下其必要性——如果不绑定,会导致事务的执行上下文不再固定,直接违反了 2PC 的基本条件:最终决议必须发给当初参与 prepare 的那批 participant,从而让恢复/幂等语义都无法收敛。例如,同一笔事务的 participant 集合会漂移:
- 一笔事务在 Prepare 时,某个 key 属于 gid=100
- 到了 Commit 时,这个 key 可能已经迁到 gid=101
- 如果 coordinator 不保存旧 config 下的事务执行计划,而是每次都按最新 config 重新查找,就会出现:
- Prepare 实际是 100 做的
- Commit 却发给了 101
此时问题来了:101 并没有这笔事务的 prepared 记录,由于锁不在它那,它能给的操作返回值不一定正确,导致事务的隔离性被破坏。
Reconfiguration 推进条件
原始 Shard KV 系统里,每个 replica group 会周期性轮询 shardctrler 是否有新配置,发现配置变化后自行处理分片迁移。引入了事务支持之后,这个流程的推进条件也要有所调整。考虑如下场景:
- 某个 participant 已经对一笔事务执行过
Prepare - 发生配置变更,该事务涉及到的 shard 此时要被迁走
- 那么该 participant 到底还能不能处理这笔事务的 Commit/Abort?
事实上,旧配置下的 participant 身份在事务终结之前必须保持有效,否则如果承认并应用了新配置,后续 coordinator 发起的 Commit 或 Abort 还是向旧配置下的groupID发请求,于是就会找不到正确的 shard owner,导致事务卡死在中间态。
这个问题的解决方案是在原有配置变更逻辑里加入额外约束:当待发送的 shard 与未决事务持有的锁集合相交时,当前配置变更暂不生效。虽然对于外部的 shardctrler 而言,最新配置已经存在并且对客户端可见;但对于某个尚未完成事务的 participant 而言,旧配置继续生效,直到相关事务结束后才应用新的配置。也就是说,虽然 coordinator 和 participant 都不能阻止shardctrler发布新配置,但是 participant 可以选择把自己真正切到新配置的时间点延迟,从而保障了 2PC 的关键条件。
工业系统的做法
工业系统通常不愿意让 reconfiguration 被可能运行长时间的事务直接卡住,因为这会让控制面与事务活性过度耦合。更常见的方案是设计某种理解事务中间态的迁移协议,让中间态能够随分片数据一起复制和转移,这样分片迁移后新 owner 也能继续接管事务。但这种灵活性所付出的代价,是大幅度提升数据模型、存储引擎和恢复机制的工程复杂度和测试难度。
测试:vibe coding 一把梭
LLM 的出现使得生产代码这件事变得成本极低,但对于分布式系统,复杂性并不会凭空消失——你还是要确保业务逻辑的正确性,这就要求你必须想办法约束 AI 生成产物的规模和可读性,从而以人力能够承受的心智负担来进行验证。因此,对于关键位置的核心代码,LLM 并不能大幅度提升开发效率,最终瓶颈仍然是你自己对正确性的推导和审查。
相比之下,写测试代码是一种纯粹的脑力消耗,这种代码多数情况下既没有复杂的上下文,又很容易大致验证其正确性,因此极其适合借助 vibe coding 来完成。对于这个项目,我认为 AI 在测试方面为我节省的时间,使得整体效率提升了约一倍,尤其是当你需要使用某种你不熟悉的测试工具的时候,比如下面这个线性一致性检查。
Linearizability Test
线性一致性(Linearizability)是对单个对象的读写保证,它约束的是:一系列操作的对外可观察行为是否能够被解释为某个满足真实时间顺序的串行执行。
由于事务执行会涉及多个 key,因此其线性一致性测试是把一次 Transaction(txnID, ops) 当成一个高层原子操作来建模,而不是像 Shard KV 那样把每个 key 上的 Get/Put/Append 单独拆开检查。我们运行一个模拟网络故障和节点崩溃的随机事务测试,在执行时记录每笔事务的开始时间、结束时间、输入操作序列以及最终输出,然后把整个历史喂进 Porcupine。Porcupine 库的任务是在满足真实时间先后约束的前提下,搜索是否存在一个串行顺序,可以解释这批事务的返回值和最终状态——如果存在,这段历史就被认为是可线性化的。
为了让 Porcupine 能够判断一段事务历史是否成立,我们需要为它提供一个顺序执行模型。这里的模型比较糙快猛:维护一张简单的 map[string]string 作为抽象状态,按照事务内部操作的顺序模拟 Get、Put、Append 对状态和返回值的影响,并把 abort 视为一个不改变状态的合法结果。这样一来,线性化检查所验证的就是:这些事务对外是否确实表现得像一个原子操作,而不是在执行过程中泄露了中间状态或破坏了原子提交语义。
结语
回到文章开头那句话:软件工程的本质,从来不是追求某种教科书式的“完美方案”,而是在真实约束下识别问题的边界,并做出合理的 trade-off。
从共识算法,到分片数据库,再到分布式事务,虽然我这个玩具项目距离真正的工业级分布式数据库还很远,但沿着这条路径一路手搓下来,最大的收获是通过做出一系列简化与保留之间的取舍,让我深刻理解了强一致事务到底贵在哪、为什么很多系统宁可退一步采用更弱的 saga 语义,以及让我在面对系统设计中的取舍时,能更有底气地回答——我们为什么不该引入事务。