《后端架构设计·分布式与数据一致性》7.3 TCC、本地消息表与事务状态的可见性

支付回调超时后,Cancel 可能先于 Try 到达,造成空回滚;迟到的 Try 又可能永久占住库存,形成悬挂。本文用订单预占场景实现带墓碑与状态机的 TCC,并用同库本地消息表让 TRYING、CONFIRMED、CANCELLED 对下游可见,给出空回滚、悬挂、幂等与中间态查询的可运行测试。

支付回调超时后,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/XATCC
资源锁数据库/资源管理器业务预留记录
隔离性资源管理器提供锁业务自己保证(预留 + 可见性)
中间态对普通查询通常不可见对查询可见,必须显式处理
回滚数据库物理回滚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)解决的是“业务状态与消息发送的原子性”。在一个本地数据库事务里同时写两件事:

  1. 更新业务状态,例如 tx_state = CONFIRMED;
  2. 插入一条待投递消息,例如 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:同库写入与投递保证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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