两阶段提交(2PC)原理:准备阶段、提交/回滚阶段、协调者与参与者角色
两阶段提交(Two-Phase Commit,2PC)是分布式事务中最经典的原子提交协议。它通过引入一个协调者(Coordinator)和多个参与者(Participant),将一次全局事务的提交过程拆分为两个阶段,从而保证所有节点要么全部提交,要么全部回滚。2PC 是理解后续所有分布式事务协议(如 3PC、Paxos、Raft 事务)的基础。
核心角色定义
| 角色 | 职责 | 状态管理 |
|---|---|---|
| 协调者(Coordinator) | 全局事务的发起者与决策者,负责向所有参与者发送准备请求,收集投票,并最终决定提交或中止。 | 维护全局事务日志,记录事务状态(Init → Prepared → Committed / Aborted)。 |
| 参与者(Participant) | 本地事务的执行者,负责执行本地操作并锁定资源,向协调者反馈“可以提交”或“必须回滚”。 | 维护本地事务日志,记录本地分支状态(Working → Prepared → Committed / Rollback)。 |
角色映射到 Go 工程:在 Go 微服务架构中,协调者通常是一个独立的 Transaction Manager(TM) 服务,参与者则是各个业务服务(如 Order Service、Inventory Service)。TM 通过 gRPC 或消息队列与参与者通信。
第一阶段:准备阶段(Voting Phase)
协调者向所有参与者发送 Prepare 请求,询问它们是否能够提交事务。参与者收到请求后,执行以下步骤:
- 执行本地事务:执行 SQL 或业务操作,但不提交(处于未提交状态)。
- 锁定资源:对涉及的数据行、表或资源加锁,防止其他事务修改。
- 写入本地日志:将事务状态标记为
Prepared,确保即使宕机也能恢复。 - 返回投票:如果一切正常,返回
Yes(同意提交);如果失败,返回No(必须回滚)。
资源锁定风险:参与者一旦回复
Yes,就必须持有资源锁直到第二阶段结束。如果协调者崩溃或网络分区,这些锁可能长时间不释放,导致系统吞吐量骤降。这是 2PC 的主要性能瓶颈。
第二阶段:提交/回滚阶段(Decision Phase)
协调者收集所有参与者的投票后,做出最终决策:
- 全部 Yes → 全局提交:协调者向所有参与者发送
Commit指令。参与者收到后,正式提交本地事务,释放资源锁,并写入Committed日志。 - 任一 No 或超时 → 全局回滚:协调者向所有参与者发送
Abort指令。参与者收到后,回滚本地事务,释放资源锁,并写入Rollback日志。
关键约束:第二阶段必须保证“全有或全无”。协调者在发送
Commit 或 Abort 之前,必须将决策写入本地日志(Write-Ahead Log),以防止自身崩溃后丢失决策。
2PC 完整流程 SVG 图
下图展示了协调者与两个参与者之间的完整交互流程,包含准备阶段和提交/回滚阶段的所有消息与状态转换。
2PC 状态机与日志结构
协调者和参与者在 2PC 过程中维护严格的状态机。以下表格展示了协调者的状态转换路径:
| 当前状态 | 触发事件 | 下一状态 | 动作 |
|---|---|---|---|
| Init | 开始全局事务 | Preparing | 写入日志:BEGIN GLOBAL TX |
| Preparing | 发送 Prepare 给所有参与者 | Waiting | 启动超时计时器 |
| Waiting | 收到所有 Yes | Committing | 写入日志:GLOBAL COMMIT |
| Waiting | 收到任一 No 或超时 | Aborting | 写入日志:GLOBAL ABORT |
| Committing | 发送 Commit 给所有参与者 | Finished | 等待所有 ACK |
| Aborting | 发送 Abort 给所有参与者 | Finished | 等待所有 ACK |
Go 语言中的 2PC 协调者骨架代码
以下代码展示了协调者在 Go 中的核心逻辑,使用 sync.WaitGroup 和 context 管理并发投票:
package twopc
import (
"context"
"fmt"
"sync"
"time"
)
type Participant interface {
Prepare(ctx context.Context) (bool, error)
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
type Coordinator struct {
participants []Participant
timeout time.Duration
}
func (c *Coordinator) Execute(ctx context.Context) error {
// 第一阶段:准备
votes := make(chan bool, len(c.participants))
var wg sync.WaitGroup
for _, p := range c.participants {
wg.Add(1)
go func(part Participant) {
defer wg.Done()
ok, err := part.Prepare(ctx)
if err != nil || !ok {
votes <- false
return
}
votes <- true
}(p)
}
// 等待所有准备完成或超时
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// 所有参与者已投票
case <-time.After(c.timeout):
return fmt.Errorf("prepare phase timeout")
}
close(votes)
allYes := true
for v := range votes {
if !v {
allYes = false
break
}
}
// 第二阶段:提交或回滚
if allYes {
for _, p := range c.participants {
if err := p.Commit(ctx); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
}
return nil
}
for _, p := range c.participants {
if err := p.Rollback(ctx); err != nil {
return fmt.Errorf("rollback failed: %w", err)
}
}
return fmt.Errorf("transaction aborted by participant vote")
}
工程要点:上述代码省略了持久化日志、重试机制和幂等性处理。生产级 2PC 协调者必须将每个阶段的决策写入本地磁盘日志,并在崩溃恢复时根据日志状态重放或中止未完成的事务。
2PC 的优缺点与适用场景
| 维度 | 评价 |
|---|---|
| 强一致性 | 保证所有节点数据最终一致,无中间状态不一致问题。 |
| 同步阻塞 | 参与者在准备阶段锁定资源,直到第二阶段结束才释放,高并发下性能差。 |
| 单点故障 | 协调者宕机可能导致参与者长时间阻塞,需要人工介入或超时机制。 |
| 适用场景 | 短事务、低并发、强一致性要求高的场景(如金融转账、库存扣减)。 |
| 不适用场景 | 长事务、高并发、微服务间频繁调用的场景(推荐使用 Saga 或 TCC)。 |
2PC 在 Go 生态中的实践
Go 社区中,2PC 通常不直接作为独立框架使用,而是作为底层协议被更高级的分布式事务中间件封装。常见的实践模式包括:
- 数据库原生 XA 事务:通过
database/sql的BeginTx和Prepare接口实现,但 Go 的 XA 支持较弱,不推荐跨数据库使用。 - 自定义协调者 + 消息队列:使用 Kafka 或 RabbitMQ 作为协调者与参与者之间的通信通道,利用消息的持久化特性实现日志可靠性。
- 分布式事务中间件:如 DTM(Distributed Transaction Manager)支持 2PC 模式,提供 Go SDK,开发者只需实现
Prepare/Commit/Rollback回调接口。
推荐学习路径:先通过 DTM 的 2PC 示例理解协调者与参与者的通信模式,再阅读 DTM 源码中关于日志恢复和超时处理的实现,最后尝试自己实现一个简化版 2PC 协调者以加深理解。