《后端架构设计·分布式与数据一致性》7.1 2PC 与 3PC:准备、提交与协调者故障

订单与库存、支付分库后,两阶段提交常被当成“分布式事务的标准答案”。本文用一次下单说明它真正保证的是原子提交而不是不阻塞,给出参与者日志、协调者决策日志与崩溃恢复的可运行 Go 实现,复现 in-doubt 窗口,并用判据表比较 2PC、3PC 与补偿型方案。

库存和支付分库后,“原子”到底指什么

一笔 1000 元的订单要先预占库存、再扣支付账户。单库时代,两条记录在同一个本地事务里,提交或回滚由数据库保证。拆成库存库与支付库之后,两边的连接、锁和日志都不再共享,团队很自然地说:“上两阶段提交,保证要么都成功要么都失败。”

这句话只对了一半。2PC 保证的是原子提交:不会出现一边提交、一边回滚的最终结果。它不保证不阻塞,也不保证高可用。协调者在“所有参与者都已准备、但提交决定还没落盘”的窗口崩溃时,参与者既不能提交也不能回滚,只能持有锁等待。这个窗口不是实现缺陷,而是 2PC 在异步网络下的固有代价。

另一个常见直觉是“3PC 多一轮就能解决阻塞”。3PC 确实缩小了阻塞窗口,代价是额外一轮消息,而且它的超时决策依赖同步网络假设;一旦真实网络出现分区,这个假设不成立,安全性反而要重新论证。本节先把 2PC 的不变量和故障窗口写清楚,再谈 3PC 换来了什么。站内 分布式事务 已介绍过 XA、TCC、Saga 的分类,这里只讨论提交协议本身的边界。

两个阶段与三条不变量

准备阶段,协调者把事务内容和参与者名单发给每个参与者。参与者执行本地事务的“预提交”:检查约束、预留资源、写 undo/redo 信息,然后先把 prepared 写入自己的日志并落盘,再回复 Yes。如果资源不足或本地检查失败,参与者回复 No,并且不持有任何需要后续处理的锁。

提交阶段,协调者收集全部投票。只有全部 Yes 才写 commit,否则写 abort。决定必须先持久化到协调者日志,然后才向参与者发送 Commit 或 Abort,并重试到收到确认为止。参与者按日志执行,重复消息按幂等处理。

这里有三条不能退让的不变量:

  1. 任何参与者提交之前,协调者的 commit 决定必须已经持久化。
  2. 只要有一个参与者提交,最终所有参与者都必须提交;决定一旦写下就不能改。
  3. 一个已经回复 Yes 的参与者,重启后必须能从自己的日志和协调者决定中恢复出唯一结果。

第一条把“决定”与“执行”分开,第二条让决定成为全局事实,第三条要求日志先于回复。很多线上的“2PC 丢数据”并不是协议本身的问题,而是这三条里有一条被实现偷掉了:日志没 fsync、先回复后写日志、协调者决定只存在内存里。

协调者故障的四个窗口

协调者可能在四个位置崩溃,后果完全不同。把窗口列出来,比背“2PC 会阻塞”有用得多。

崩溃时点参与者状态恢复依据可用性影响
发送 Prepare 之前未收到请求无事务,直接放弃无锁,可立即重试
Prepare 进行中部分 Yes、部分未响应决定日志无记录,按 Abort 处理已准备者需回滚,短暂持锁
全部 Yes 之后、决定落盘之前全部 prepared无决定,无法安全提交或回滚in-doubt,锁一直持有
决定落盘之后、通知完成之前全部 prepared,决定已知重放决定,幂等提交/回滚可恢复,不阻塞新事务

第三行就是常说的阻塞窗口。参与者无法自行判断:提交可能违反“有人投了 No”,回滚可能违反“已经有人提交”。在没有额外协调机制时,只能等协调者恢复,或者由运维按业务规则做人工裁决。缩短这个窗口的办法不是换 3PC,而是让决定日志高可用、缩短落盘路径,并为 in-doubt 准备可执行的裁决手册。

第四行常被误认为也很危险,其实它最安全:决定已经持久化,参与者重启后重放即可。恢复流程只需要回答“这个事务在决定日志里是什么”,不需要重新投票。

参与者日志与恢复流程

参与者日志至少要记录 prepared、committed、rolledback 三个相位,并在回复协调者之前完成持久化。恢复时按下面的顺序处理:

  1. 扫描日志,找出处于 prepared 的事务。
  2. 用事务号查询协调者的决定日志。
  3. 有 commit 就执行提交,有 abort 就执行回滚;两者都必须幂等。
  4. 查不到决定就把事务标记为 in-doubt,保留锁,进入人工或自动裁决队列。

这套流程的关键不是代码量,而是决定来源唯一。如果参与者自己也缓存了一份“我觉得应该提交”的判断,恢复时就可能出现两个真相。决定只能来自协调者日志;参与者日志只证明“我承诺过可以提交”。

下面的实现把资源抽象成可预留的余额,把参与者和协调者的日志都做成内存结构,并用一个 crashAfterPrepare 开关精确停在第三窗口。它只依赖标准库,测试覆盖正常提交、准备失败回滚、崩溃后 in-doubt、按决定恢复和重复操作幂等。

// file: internal/platform/txn/twopc/twopc.go
// tier: L1
// verify: go test ./internal/platform/txn/twopc/ -count=1
package twopc

import (
	"errors"
	"fmt"
	"sync"
)

// Decision is the coordinator's durable outcome for a transaction.
type Decision string

const (
	DecisionPending Decision = "pending"
	DecisionCommit  Decision = "commit"
	DecisionAbort   Decision = "abort"
)

// ErrInvalidState reports an operation that contradicts the journal.
var ErrInvalidState = errors.New("twopc: invalid state")

// Journal is an append-only participant log. It keeps the last phase per
// transaction; a real implementation fsyncs before replying.
type Journal struct {
	mu   sync.Mutex
	last map[string]string
}

func (j *Journal) append(txID, phase string) {
	j.mu.Lock()
	defer j.mu.Unlock()
	if j.last == nil {
		j.last = make(map[string]string)
	}
	j.last[txID] = phase
}

// Last returns the last phase recorded for txID.
func (j *Journal) Last(txID string) (string, bool) {
	j.mu.Lock()
	defer j.mu.Unlock()
	phase, ok := j.last[txID]
	return phase, ok
}

type txState struct {
	reserved int64
	applied  bool
}

// Resource is one participant's lockable balance in cents.
type Resource struct {
	mu      sync.Mutex
	balance int64
	state   map[string]*txState
	journal Journal
}

// NewResource creates a participant resource with the given balance.
func NewResource(balance int64) *Resource {
	return &Resource{balance: balance, state: make(map[string]*txState)}
}

// Prepare reserves amount and journals "prepared". Repeat calls are no-ops.
func (r *Resource) Prepare(txID string, amount int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if st, ok := r.state[txID]; ok {
		if st.applied || (st.reserved > 0 && st.reserved == amount) {
			return nil
		}
		if st.reserved > 0 {
			return fmt.Errorf("twopc: prepare %s: %w", txID, ErrInvalidState)
		}
	}
	if amount <= 0 {
		return fmt.Errorf("twopc: prepare %s: amount must be positive", txID)
	}
	available := r.balance
	for _, st := range r.state {
		available -= st.reserved
	}
	if available < amount {
		return fmt.Errorf("twopc: prepare %s: available %d < %d", txID, available, amount)
	}
	r.state[txID] = &txState{reserved: amount}
	r.journal.append(txID, "prepared")
	return nil
}

// Commit applies a reservation and journals "committed". Repeat calls are no-ops.
func (r *Resource) Commit(txID string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	st, ok := r.state[txID]
	if !ok || st.applied || st.reserved == 0 {
		if ok && st.applied {
			return nil
		}
		return fmt.Errorf("twopc: commit %s: %w", txID, ErrInvalidState)
	}
	r.balance -= st.reserved
	st.reserved = 0
	st.applied = true
	r.journal.append(txID, "committed")
	return nil
}

// Rollback releases a reservation and journals "rolledback". Repeat calls are no-ops.
func (r *Resource) Rollback(txID string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	st, ok := r.state[txID]
	if ok && st.applied {
		return fmt.Errorf("twopc: rollback %s: already committed", txID)
	}
	delete(r.state, txID)
	if _, seen := r.journal.Last(txID); !seen {
		r.journal.append(txID, "rolledback")
	}
	return nil
}

// DecisionLog is the coordinator's durable log. It must survive the crash
// that happens after every participant has voted.
type DecisionLog struct {
	mu        sync.Mutex
	decisions map[string]Decision
}

// NewDecisionLog creates an empty durable decision log.
func NewDecisionLog() *DecisionLog {
	return &DecisionLog{decisions: make(map[string]Decision)}
}

// Record writes a terminal decision once; conflicting writes are rejected.
func (l *DecisionLog) Record(txID string, d Decision) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	if old, ok := l.decisions[txID]; ok && old != d {
		return fmt.Errorf("twopc: decision %s: %w", txID, ErrInvalidState)
	}
	l.decisions[txID] = d
	return nil
}

// Lookup returns the recorded decision, if any.
func (l *DecisionLog) Lookup(txID string) (Decision, bool) {
	l.mu.Lock()
	defer l.mu.Unlock()
	d, ok := l.decisions[txID]
	return d, ok
}

// Leg is one participant's prepare input.
type Leg struct {
	Participant string
	Amount      int64
}

// Coordinator drives two-phase commit across named participants.
type Coordinator struct {
	Participants map[string]*Resource
	Log          *DecisionLog
}

// Execute runs one 2PC transaction. With crashAfterPrepare it returns after
// all votes but before recording a decision, simulating a coordinator crash.
func (c *Coordinator) Execute(txID string, legs []Leg, crashAfterPrepare bool) (Decision, error) {
	if c.Log == nil {
		return DecisionPending, errors.New("twopc: nil decision log")
	}
	prepared := make([]*Resource, 0, len(legs))
	for _, leg := range legs {
		r, ok := c.Participants[leg.Participant]
		if !ok {
			return DecisionPending, fmt.Errorf("twopc: unknown participant %q", leg.Participant)
		}
		if err := r.Prepare(txID, leg.Amount); err != nil {
			if recErr := c.Log.Record(txID, DecisionAbort); recErr != nil {
				return DecisionPending, recErr
			}
			for _, done := range prepared {
				if rbErr := done.Rollback(txID); rbErr != nil {
					return DecisionAbort, rbErr
				}
			}
			return DecisionAbort, err
		}
		prepared = append(prepared, r)
	}
	if crashAfterPrepare {
		return DecisionPending, nil
	}
	if err := c.Log.Record(txID, DecisionCommit); err != nil {
		return DecisionPending, err
	}
	for _, r := range prepared {
		if err := r.Commit(txID); err != nil {
			return DecisionCommit, err
		}
	}
	return DecisionCommit, nil
}

// Recover resolves prepared participants from the durable decision log.
// Without a decision a prepared participant stays in doubt and keeps its lock.
func Recover(log *DecisionLog, participants map[string]*Resource, txID string) map[string]string {
	out := make(map[string]string, len(participants))
	decision, decided := log.Lookup(txID)
	for name, r := range participants {
		phase, _ := r.journal.Last(txID)
		switch {
		case phase == "prepared" && decided && decision == DecisionCommit:
			_ = r.Commit(txID)
			out[name] = "committed"
		case phase == "prepared" && decided && decision == DecisionAbort:
			_ = r.Rollback(txID)
			out[name] = "rolledback"
		case phase == "prepared":
			out[name] = "in-doubt"
		default:
			out[name] = phase
		}
	}
	return out
}

实测输出与读数

在本机 macOS darwin/arm64、Go 1.26.0 下执行:

$ go test ./internal/platform/txn/twopc/ -count=1 -v
=== RUN   TestHappyPathCommitsBothParticipants
--- PASS: TestHappyPathCommitsBothParticipants (0.00s)
=== RUN   TestAbortReleasesPreparedLocks
--- PASS: TestAbortReleasesPreparedLocks (0.00s)
=== RUN   TestCrashLeavesInDoubtUntilDecisionRecovered
--- PASS: TestCrashLeavesInDoubtUntilDecisionRecovered (0.00s)
=== RUN   TestRecoveryHonoursAbortDecision
--- PASS: TestRecoveryHonoursAbortDecision (0.00s)
=== RUN   TestParticipantOperationsAreIdempotent
--- PASS: TestParticipantOperationsAreIdempotent (0.00s)
PASS
ok  	archlab/internal/platform/txn/twopc	1.609s

读数要看断言而不是耗时。TestAbortReleasesPreparedLocks 让库存准备成功、支付因余额不足失败,验证库存的预留被释放且余额回到 1000;这说明“部分 Yes”可以安全回滚。TestCrashLeavesInDoubtUntilDecisionRecovered 在全部准备后不写决定就返回,此时两个参与者都处于 prepared,Recover 只能给出 in-doubt;写入 commit 后再恢复,两者才提交。最后一条验证重复 Prepare、重复 Commit 不重复扣款。

测试耗时是 go test 的框架读数,只说明用例很快,不能当作生产延迟或吞吐数据。真实的 2PC 性能取决于日志 fsync、网络往返和协调者持久化,本机内存实现不构成证据。

3PC 多出的一轮换来了什么

3PC 在 Prepare 与 Commit 之间插入 PreCommit,并把参与者状态拆成 CanCommit、PreCommit、DoCommit 三个可超时的阶段。参与者在 PreCommit 之后超时,可以按“大家大概率都已准备”的规则自行提交;在 CanCommit 阶段超时则中止。这样就把 2PC 的阻塞窗口压缩成“协调者与参与者同时失联”的较小窗口。

代价同样明确:

维度2PC3PC
消息轮次2 轮(准备、提交)3 轮(CanCommit、PreCommit、DoCommit)
阻塞窗口决定落盘前的协调者崩溃仍存在,但更窄
依赖假设崩溃-恢复,允许异步网络同步网络、有界延迟、无分区
分区下安全性不提交,可能阻塞可能两边各自做决定,需要额外 epoch/fencing
实现复杂度较低状态更多,恢复分支更多

3PC 的超时提交不是免费的午餐。网络分区时,一侧参与者可能收不到 PreCommit 而中止,另一侧已经 PreCommit 的参与者超时后提交,最终仍然不一致。要让它安全,需要额外的成员视图或 fencing token 来保证“只有合法的一侧能推进”。所以大多数订单系统不会为了消除阻塞直接上 3PC,而是选择:协调者多副本 + 决定日志强一致 + in-doubt 运维流程,或者干脆避开跨库强事务,改用 Saga/TCC。

选型判据

方案原子性隔离性典型阻塞适用场景
2PC/XA强依赖资源管理器锁决定窗口内持锁两个支持 XA 的数据库,事务短、冲突低
3PC强(需同步假设)同 2PC窗口更窄,成本更高教学或受控网络,不建议作为通用方案
Saga最终一致无,需要语义锁不长期持锁长流程、跨服务、可补偿步骤多
TCC最终一致业务预留预留期占资源库存、支付等可显式预留的资源

判断顺序建议是:先问“这两个资源是否真的必须强原子”,再问“能否把流程改成长事务”。如果订单和支付必须同生共死,且两边都支持 XA、事务能在几十毫秒内完成,2PC 配合高可用协调者是合理选择;如果流程包含外部渠道、人工审核或不可补偿步骤,强事务的锁会被长时间持有,应该转向 7.2 的 Saga 或 7.3 的 TCC。

常见错误与边界

  • 把 Prepare 当成 Commit:准备阶段只承诺“可以提交”,不产生最终业务效果。如果准备阶段已经扣了款,回滚就必须真的退款,而不是改一个状态位。
  • 先回复再写日志:参与者必须在回复 Yes 之前把 prepared 落盘。否则崩溃重启后它不知道自己承诺过,协调者却按 Yes 推进,原子性被破坏。
  • 协调者决定只存内存:决定日志不持久化或只有单副本,第三窗口就从“可恢复的阻塞”退化成“永久 in-doubt”。决定日志的可用性就是 2PC 的可用性。
  • 认为 2PC 提供隔离:2PC 只保证原子提交,不保证并发事务的可串行化。两个事务同时预留同一库存时,仍需数据库约束、版本号或业务锁来防止超卖。
  • 用 3PC 兜底网络分区:3PC 的超时决策依赖同步网络假设。分区下它可能让两侧做出不同决定,必须配合 fencing 或成员视图,不能直接当成分区容错方案。

小结

2PC 的核心不是“两轮消息”,而是“决定先持久化、参与者先写日志、恢复时只认一个决定来源”。协调者在决定落盘前崩溃会产生 in-doubt 阻塞,这是协议在异步网络下的真实边界;3PC 用额外一轮和同步假设换取更窄的阻塞窗口,并没有消除分区下的一致性风险。工程上更常见的做法是把协调者做成高可用服务、把事务做短,或者在流程允许时改用补偿型方案。

下一节把视角从“资源锁”换到“业务补偿”:当库存已经预留、支付已经扣款,而后续步骤失败时,Saga 如何用反向操作收敛,以及哪些步骤根本不能补偿。

阅读导航:上一节:从一致性模型到业务不变量 · 下一节:Saga:补偿、语义锁与不可补偿步骤 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查