库存和支付分库后,“原子”到底指什么
一笔 1000 元的订单要先预占库存、再扣支付账户。单库时代,两条记录在同一个本地事务里,提交或回滚由数据库保证。拆成库存库与支付库之后,两边的连接、锁和日志都不再共享,团队很自然地说:“上两阶段提交,保证要么都成功要么都失败。”
这句话只对了一半。2PC 保证的是原子提交:不会出现一边提交、一边回滚的最终结果。它不保证不阻塞,也不保证高可用。协调者在“所有参与者都已准备、但提交决定还没落盘”的窗口崩溃时,参与者既不能提交也不能回滚,只能持有锁等待。这个窗口不是实现缺陷,而是 2PC 在异步网络下的固有代价。
另一个常见直觉是“3PC 多一轮就能解决阻塞”。3PC 确实缩小了阻塞窗口,代价是额外一轮消息,而且它的超时决策依赖同步网络假设;一旦真实网络出现分区,这个假设不成立,安全性反而要重新论证。本节先把 2PC 的不变量和故障窗口写清楚,再谈 3PC 换来了什么。站内 分布式事务 已介绍过 XA、TCC、Saga 的分类,这里只讨论提交协议本身的边界。
两个阶段与三条不变量
准备阶段,协调者把事务内容和参与者名单发给每个参与者。参与者执行本地事务的“预提交”:检查约束、预留资源、写 undo/redo 信息,然后先把 prepared 写入自己的日志并落盘,再回复 Yes。如果资源不足或本地检查失败,参与者回复 No,并且不持有任何需要后续处理的锁。
提交阶段,协调者收集全部投票。只有全部 Yes 才写 commit,否则写 abort。决定必须先持久化到协调者日志,然后才向参与者发送 Commit 或 Abort,并重试到收到确认为止。参与者按日志执行,重复消息按幂等处理。
这里有三条不能退让的不变量:
- 任何参与者提交之前,协调者的
commit决定必须已经持久化。 - 只要有一个参与者提交,最终所有参与者都必须提交;决定一旦写下就不能改。
- 一个已经回复 Yes 的参与者,重启后必须能从自己的日志和协调者决定中恢复出唯一结果。
第一条把“决定”与“执行”分开,第二条让决定成为全局事实,第三条要求日志先于回复。很多线上的“2PC 丢数据”并不是协议本身的问题,而是这三条里有一条被实现偷掉了:日志没 fsync、先回复后写日志、协调者决定只存在内存里。
协调者故障的四个窗口
协调者可能在四个位置崩溃,后果完全不同。把窗口列出来,比背“2PC 会阻塞”有用得多。
| 崩溃时点 | 参与者状态 | 恢复依据 | 可用性影响 |
|---|---|---|---|
| 发送 Prepare 之前 | 未收到请求 | 无事务,直接放弃 | 无锁,可立即重试 |
| Prepare 进行中 | 部分 Yes、部分未响应 | 决定日志无记录,按 Abort 处理 | 已准备者需回滚,短暂持锁 |
| 全部 Yes 之后、决定落盘之前 | 全部 prepared | 无决定,无法安全提交或回滚 | in-doubt,锁一直持有 |
| 决定落盘之后、通知完成之前 | 全部 prepared,决定已知 | 重放决定,幂等提交/回滚 | 可恢复,不阻塞新事务 |
第三行就是常说的阻塞窗口。参与者无法自行判断:提交可能违反“有人投了 No”,回滚可能违反“已经有人提交”。在没有额外协调机制时,只能等协调者恢复,或者由运维按业务规则做人工裁决。缩短这个窗口的办法不是换 3PC,而是让决定日志高可用、缩短落盘路径,并为 in-doubt 准备可执行的裁决手册。
第四行常被误认为也很危险,其实它最安全:决定已经持久化,参与者重启后重放即可。恢复流程只需要回答“这个事务在决定日志里是什么”,不需要重新投票。
参与者日志与恢复流程
参与者日志至少要记录 prepared、committed、rolledback 三个相位,并在回复协调者之前完成持久化。恢复时按下面的顺序处理:
- 扫描日志,找出处于
prepared的事务。 - 用事务号查询协调者的决定日志。
- 有
commit就执行提交,有abort就执行回滚;两者都必须幂等。 - 查不到决定就把事务标记为 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 的阻塞窗口压缩成“协调者与参与者同时失联”的较小窗口。
代价同样明确:
| 维度 | 2PC | 3PC |
|---|---|---|
| 消息轮次 | 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:补偿、语义锁与不可补偿步骤 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。