支付回调超时后,Cancel 先到了
用户下单,TCC 事务开始:Try 预占库存 2 件,等待支付;支付回调迟迟不来,协调器判定超时,向库存服务发送 Cancel。由于重试和网络抖动,Cancel 比第一次 Try 的请求更早到达库存服务。库存服务没有找到对应的预留记录,于是执行了一次“空回滚”:什么都没释放,直接返回成功。
几分钟后,那条迟到的 Try 终于到达。如果库存服务只是简单地“没找到就新建预留”,就会为这个已经取消的事务重新占住 2 件库存。协调器认为事务早已结束,不会再来 Confirm 或 Cancel,这 2 件库存就永久悬挂,直到人工对账发现。这就是 TCC 里最典型的两类故障:空回滚(Cancel 先到、无资源可释放)和悬挂(Try 后到、资源被占住却无人收尾)。
错误直觉是“TCC 就是 2PC 的业务版,把 prepare 改名叫 Try 就行”。实际上 TCC 把资源控制从数据库锁搬到了业务代码里:Try 要显式预留,Confirm 要显式生效,Cancel 要显式释放,而且这三个接口都必须处理乱序、重复和中间态。协议只给了形状,正确性要靠状态机和对可见性的设计。
TCC 与 2PC 的区别:锁在业务层
2PC 的 Prepare 由资源管理器执行,锁也由资源管理器持有;事务期间其他事务通常被数据库阻塞。TCC 的 Try 是业务接口,它不持有数据库长事务,而是写入一条“预留”记录,例如库存表里的 reserved_qty 或一张预占单。
| 维度 | 2PC/XA | TCC |
|---|---|---|
| 资源锁 | 数据库/资源管理器 | 业务预留记录 |
| 隔离性 | 资源管理器提供锁 | 业务自己保证(预留 + 可见性) |
| 中间态 | 对普通查询通常不可见 | 对查询可见,必须显式处理 |
| 回滚 | 数据库物理回滚 | Cancel 释放预留 |
| 适用资源 | 支持 XA 的存储 | 库存、额度、优惠券等可预留资源 |
TCC 的好处是不会长时间持有数据库锁,适合高并发、长流程;代价是所有参与者都要实现三个接口,而且必须自己处理空回滚、悬挂和幂等。它适合“资源可以被显式预留”的场景,不适合“只能事后补偿”的外部渠道。
三个异常:空回滚、悬挂与重复
| 异常 | 触发条件 | 后果 | 处理方式 |
|---|---|---|---|
| 空回滚 | Cancel 先于 Try 到达 | 没有资源可释放,但必须留下“已取消”事实 | 写 CANCELLED 墓碑,后续 Try 拒绝 |
| 悬挂 | Try 晚于 Cancel 到达 | 预留无人 Confirm/Cancel,资源永久占用 | Try 先查墓碑,命中则拒绝 |
| 重复 | Try/Confirm/Cancel 被重试 | 重复预留、重复扣减、重复释放 | 每个接口按事务号幂等 |
| Confirm 后 Cancel | 协调器状态错乱或超时误判 | 已生效资源被错误释放 | 状态机拒绝非法迁移并告警 |
| Cancel 后 Confirm | 同上 | 已释放资源被确认 | 状态机拒绝,进入人工对账 |
这五个异常可以用一张状态机统一处理。事务的合法状态只有 TRYING、CONFIRMED、CANCELLED:
Try:不存在则创建TRYING;已是TRYING则幂等返回;已是CANCELLED返回“已取消”;已是CONFIRMED返回非法状态。Confirm:TRYING → CONFIRMED;已是CONFIRMED幂等返回;CANCELLED或不存在则拒绝。Cancel:不存在则创建CANCELLED墓碑;TRYING → CANCELLED并释放预留;已是CANCELLED幂等返回;CONFIRMED拒绝。
关键在 Cancel 的“不存在”分支:它必须写入墓碑,而不是简单返回成功。没有墓碑,迟到的 Try 就不知道曾经取消过,悬挂无法避免。同样,Try 在创建预留前必须查墓碑;这两步要在同一个本地事务或同一把锁内完成,否则检查和写入之间仍会有竞态。
本地消息表:让中间态对下游可见
TCC 的状态不能只存在内存里。用户下单后轮询订单状态,客服查询支付结果,风控判断是否继续,都需要知道事务当前在 TRYING 还是已经 CONFIRMED。更关键的是,状态变化要通知下游:库存服务要释放预留,积分服务要发券,数据仓库要更新宽表。
本地消息表(local message table)解决的是“业务状态与消息发送的原子性”。在一个本地数据库事务里同时写两件事:
- 更新业务状态,例如
tx_state = CONFIRMED; - 插入一条待投递消息,例如
OrderConfirmed。
本地事务提交后,消息一定存在;如果事务回滚,消息也不存在。随后由独立的 relay 轮询未发送消息,投递给消息队列或下游服务,成功后标记 sent。投递可能重复,所以下游必须按消息 ID 或事务号幂等消费。
这个模式不追求“恰好一次”投递,而是“至少一次投递 + 幂等消费”。它的价值在于状态与消息同源:不会出现“状态改了但消息没发”或“消息发了但状态回滚”的经典不一致。站内 分布式幂等与可靠性 已经讨论过消费端去重,这里把重点放在状态查询上。
对用户来说,中间态不是错误,而是正常阶段。下单接口在 Try 成功后应返回“处理中”和事务号,而不是 200 成功或 500 失败;查询接口返回 TRYING、CONFIRMED、CANCELLED 三态,并带上“下游是否已收到”的标志。这样前端可以显示“正在为你锁定库存”,客服也能区分“还在处理”和“已经失败”。
可运行实现
下面的 Store 把 TCC 状态、预留余额和本地消息表放在同一把锁内,模拟同库事务。Try 先查墓碑,Cancel 在记录不存在时写 CANCELLED 墓碑,Relay 投递未发送消息,Query 同时返回状态和下游可见性。测试覆盖正常确认、空回滚拒绝迟到 Try、重复调用、Confirm 后 Cancel、中间态可见性和并发同事务 Try。
// file: internal/platform/txn/tcc/tcc.go
// tier: L1
// verify: go test ./internal/platform/txn/tcc/ -count=1
package tcc
import (
"errors"
"fmt"
"sync"
)
// State is the durable transaction state.
type State string
const (
StateTrying State = "TRYING"
StateConfirmed State = "CONFIRMED"
StateCancelled State = "CANCELLED"
)
var (
// ErrCancelled is returned when Try arrives after a Cancel tombstone.
ErrCancelled = errors.New("tcc: already cancelled")
// ErrInvalidState reports a transition that violates the state machine.
ErrInvalidState = errors.New("tcc: invalid state")
)
// Message is one row of the local message table.
type Message struct {
TxID string
Payload string
Sent bool
}
type record struct {
state State
reserved int64
}
// Store holds TCC state and the local message table behind one mutex. In a
// real service the business row and the message row are written in one
// database transaction.
type Store struct {
mu sync.Mutex
balance int64
txs map[string]*record
messages []*Message
}
// NewStore creates a store with the given available balance in cents.
func NewStore(balance int64) *Store {
return &Store{balance: balance, txs: make(map[string]*record)}
}
// Try reserves amount and appends a TRYING message. A Cancel that arrived
// first leaves a tombstone, so the late Try is rejected (no hanging).
func (s *Store) Try(txID string, amount int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.txs[txID]; ok {
switch r.state {
case StateTrying:
return nil
case StateCancelled:
return fmt.Errorf("tcc: try %s: %w", txID, ErrCancelled)
default:
return fmt.Errorf("tcc: try %s: %w", txID, ErrInvalidState)
}
}
if amount <= 0 {
return fmt.Errorf("tcc: try %s: amount must be positive", txID)
}
if s.balance < amount {
return fmt.Errorf("tcc: try %s: balance %d < %d", txID, s.balance, amount)
}
s.balance -= amount
s.txs[txID] = &record{state: StateTrying, reserved: amount}
s.messages = append(s.messages, &Message{TxID: txID, Payload: "reserved"})
return nil
}
// Confirm commits the reservation. Repeat calls are no-ops.
func (s *Store) Confirm(txID string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.txs[txID]
if !ok {
return fmt.Errorf("tcc: confirm %s: %w", txID, ErrInvalidState)
}
if r.state == StateConfirmed {
return nil
}
if r.state != StateTrying {
return fmt.Errorf("tcc: confirm %s: %w", txID, ErrInvalidState)
}
r.state = StateConfirmed
r.reserved = 0
s.messages = append(s.messages, &Message{TxID: txID, Payload: "confirmed"})
return nil
}
// Cancel releases a reservation, or writes a tombstone when Cancel arrives
// before Try. Repeat Cancel calls are no-ops.
func (s *Store) Cancel(txID string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.txs[txID]
if !ok {
s.txs[txID] = &record{state: StateCancelled}
s.messages = append(s.messages, &Message{TxID: txID, Payload: "cancelled-before-try"})
return nil
}
switch r.state {
case StateCancelled:
return nil
case StateConfirmed:
return fmt.Errorf("tcc: cancel %s: %w", txID, ErrInvalidState)
}
s.balance += r.reserved
r.reserved = 0
r.state = StateCancelled
s.messages = append(s.messages, &Message{TxID: txID, Payload: "cancelled"})
return nil
}
// Relay publishes unsent messages, as a relay worker would, and reports how
// many rows became visible to downstream consumers.
func (s *Store) Relay() int {
s.mu.Lock()
defer s.mu.Unlock()
sent := 0
for _, m := range s.messages {
if !m.Sent {
m.Sent = true
sent++
}
}
return sent
}
// Query returns the transaction state, whether it exists, and whether its
// latest message has already been delivered downstream.
func (s *Store) Query(txID string) (state State, found, visible bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.txs[txID]
if !ok {
return "", false, false
}
for i := len(s.messages) - 1; i >= 0; i-- {
if s.messages[i].TxID == txID {
return r.state, true, s.messages[i].Sent
}
}
return r.state, true, false
}
// Balance returns the available balance.
func (s *Store) Balance() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.balance
}
实测输出与读数
在本机 macOS darwin/arm64、Go 1.26.0 下执行:
$ go test ./internal/platform/txn/tcc/ -count=1 -v
=== RUN TestTryConfirmHappyPath
--- PASS: TestTryConfirmHappyPath (0.00s)
=== RUN TestEmptyRollbackTombstoneRejectsLateTry
--- PASS: TestEmptyRollbackTombstoneRejectsLateTry (0.00s)
=== RUN TestIdempotentTryAndConfirm
--- PASS: TestIdempotentTryAndConfirm (0.00s)
=== RUN TestCancelAfterConfirmIsRejected
--- PASS: TestCancelAfterConfirmIsRejected (0.00s)
=== RUN TestIntermediateStateBecomesVisibleAfterRelay
--- PASS: TestIntermediateStateBecomesVisibleAfterRelay (0.00s)
=== RUN TestInsufficientBalanceLeavesNoState
--- PASS: TestInsufficientBalanceLeavesNoState (0.00s)
=== RUN TestConcurrentTryReservesOnce
--- PASS: TestConcurrentTryReservesOnce (0.00s)
PASS
ok archlab/internal/platform/txn/tcc 2.666s
TestEmptyRollbackTombstoneRejectsLateTry 先调用 Cancel("tx-2"),再调用 Try("tx-2", 300),期望 ErrCancelled 且余额保持 1000;这证明墓碑阻止了悬挂。TestIntermediateStateBecomesVisibleAfterRelay 在 Try 之后查到 TRYING 且 visible=false,Relay 之后同一状态变为可见,说明状态本身与下游可见性是两件事。TestConcurrentTryReservesOnce 用 16 个 goroutine 同时 Try 同一事务,最终只扣一次 300,消息表也只有一条预留消息。
这些数字来自内存实现和 -race 测试,只能证明状态机在并发下没有数据竞争,不能推导出生产 QPS 或消息投递延迟。
中间态的产品语义
技术上能返回 TRYING 还不够,产品要约定这个状态对用户意味着什么。常见做法是:
- 下单接口返回 202 或业务码
PROCESSING,带tx_id,不返回“成功”。 - 查询接口对同一
tx_id幂等,重复查询不改变状态。 - 超过预留租期仍未 Confirm 的
TRYING,由定时任务自动 Cancel,并写审计记录。 CANCELLED的订单对用户显示“下单失败/已取消”,对客服显示取消原因和补偿结果。- 下游消息未送达时,查询结果带
notify_pending,避免客服以为库存已经释放。
这套语义把“中间态”从异常变成可解释的阶段,也让对账系统有明确的收敛目标:所有 TRYING 最终必须变成 CONFIRMED 或 CANCELLED,所有消息最终必须 sent。
选型表:TCC、本地消息、Outbox 与 Saga
| 方案 | 解决的问题 | 隔离性 | 主要成本 | 适用场景 |
|---|---|---|---|---|
| TCC | 跨服务资源预留与释放 | 业务预留 | 三个接口、空回滚/悬挂处理 | 库存、额度、优惠券 |
| 本地消息表 | 业务状态与消息同源 | 不提供 | relay、重复投递、下游幂等 | 状态变更通知、最终一致 |
| 事务性 Outbox | 与本地消息表同源,强调投递 | 不提供 | 表设计、清理、位点 | 事件驱动架构 |
| Saga | 长流程正向 + 补偿 | 无 | 补偿设计、不可逆步骤 | 跨服务长流程 |
TCC 和本地消息表不是互斥的。订单服务可以先用 TCC 预占库存,Confirm 后在同一个本地事务里更新订单状态并写消息表,再由 relay 通知积分、风控和数据仓库。这样资源预留与状态传播各有明确的边界。
常见错误与边界
- Cancel 未命中时直接返回成功:不写墓碑,迟到的 Try 会重新预留,形成悬挂。空回滚必须留下“已取消”的持久事实。
- Try 不检查墓碑:只在内存里判断“当前没有预留”就创建,检查和写入之间的竞态仍会放过迟到的 Try。检查和写墓碑/预留必须在同一临界区。
- Confirm 与 Cancel 都做成幂等:Confirm 后收到 Cancel 如果静默成功,已生效的资源会被释放。非法迁移要拒绝并告警,不能一律幂等。
- 状态更新与消息发送分开提交:先改状态再发消息,改完崩溃就丢消息;先发消息再改状态,消息可能指向不存在的事务。两者必须同库同事务。
- 把中间态当失败:用户看到 500 就重复下单,反而制造更多悬挂。接口要返回可查询的处理中状态和事务号。
小结
TCC 把资源控制从数据库锁搬到业务层,用 Try 预留、Confirm 生效、Cancel 释放,换来了更短的锁持有时间和更好的并发能力。它的正确性依赖三件事:Cancel 先到时写墓碑、Try 到达时查墓碑、所有接口按事务号幂等。本地消息表则把状态变更与消息投递放进同一个本地事务,让中间态既能被查询,也能最终传播到下游。
下一节把本地消息表扩展成完整的事务性 Outbox:业务表与 outbox 同库写入,relay 如何记录位点、如何处理提交后崩溃与投递后崩溃,以及“至少一次投递 + 幂等消费”到底能保证到什么程度。
阅读导航:上一节:Saga:补偿、语义锁与不可补偿步骤 · 下一节:事务性 Outbox:同库写入与投递保证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。