支付成功、订单没确认,钱去哪了
渠道回调显示订单 o-1001 支付成功,1999 分已经进入商户结算账户。订单服务在把状态从 INVENTORY_RESERVED 改成 PAID 之前崩溃,重试又因为幂等键已存在而被拒绝。结果是:钱到了,订单还停在待支付,库存预占没有释放,用户看到的是“支付处理中”。
这时有两个完全不同的动作可选。第一,把支付流水和订单状态之间的错误记账修正掉,让账本回到“要么两边都成功、要么两边都没有”的状态;第二,承认支付已经发生,发起一笔新的退款,把钱原路退回。前者是冲正,后者是补偿。选错不会立刻报错,但会在财务口径、发票和渠道手续费上留下长期问题。
站内 数据一致性 讨论过强一致、最终一致与业务不变量的关系。本节只处理资金类操作的一个窄问题:当一次跨服务流程只完成了一半,如何用账本自身的结构证明它最终会回到一致,而不是靠人工记忆去补。
冲正与补偿:一个往回改账,一个往前做事
冲正针对的是“账记错了”。它用一笔反向分录抵消原分录,原分录不删除、不修改。冲正通常发生在同一个会计期间,目的是让账面恢复正确,不涉及真实的资金移动。比如重复入账、金额录错、账户挂错,都是用冲正修正。
补偿针对的是“事做了一半,需要一个新动作把业务拉回可用状态”。补偿是正向的、独立的一笔业务:退款、释放库存、取消运单、发补发单。补偿会产生新的资金流或新的业务单据,因此有自己的单号、自己的幂等键、自己的对账记录。
退款不是冲正。退款是真实的资金从商户回到用户,渠道会收手续费,发票要红冲,账单上会出现一笔独立的退款流水。把退款记成“冲掉原支付”,账面上看总额归零,但渠道账单里仍然有两笔流水,T+1 对账必然对不上。
| 维度 | 冲正 | 补偿 |
|---|---|---|
| 目的 | 修正错误记账 | 推动业务回到可用状态 |
| 账本动作 | 原分录的反向分录 | 新的正向分录 |
| 是否移动资金 | 通常不 | 通常会 |
| 单号 | 新的冲正单号,引用原单 | 新的业务单号 |
| 幂等依据 | 原单号 + 冲正原因 | 补偿动作自己的幂等键 |
| 典型场景 | 重复入账、金额录错 | 退款、释放库存、补发 |
判断标准可以压成一句话:如果动作改变的是“账本对事实的记录”,用冲正;如果动作改变的是“事实本身”,用补偿。支付已经真实发生,想让它没发生,只能补偿(退款);支付被记了两次,第二次是记录错误,用冲正。
只追加账本与四条不变量
资金账本最重要的结构约束是只追加。分录一旦写入就不能改,错误通过反向分录修正。这样任何时刻的余额都可以由全部分录重算出来,审计也能看到完整的修改历史。
在这个结构上,有四条不变量可以机器检查:
- 每笔交易的所有分录金额之和为零。
- 所有账户余额之和为零。
- 同一笔原始交易最多被冲正一次。
- 冲正分录必须逐账户精确抵消原分录。
前两条保证借贷平衡和资金守恒,第三条防止重复冲正把余额打成负数,第四条防止“冲正了但冲错账户”。四条都成立时,账本内部不会凭空产生或消灭资金。
注意,这些不变量保证的是账本自洽,不是业务正确。一笔支付被记到了错误的订单上,账本仍然平衡;这要靠对账和第 9.2 节的差异分类去发现。账本不变量负责“钱没有多出来也没有少掉”,业务不变量负责“钱记对了对象”。两层都要有。
部分成功与悬挂:不平衡的是流程,不是分录
跨服务流程只完成一半时,账本里不会出现一笔不平的分录,因为每笔分录在写入时就必须平衡。不平衡体现在另一个地方:过渡账户的余额。
支付成功但订单未确认时,钱先记在“悬挂户”(suspense account):借结算账户 1999,贷悬挂户 1999。此时悬挂户余额是 -1999,表示有 1999 分钱已经到了,但还没有归属到具体订单。订单确认后,再记一笔:借悬挂户 1999,贷订单应收 1999,悬挂户归零。
所以“悬挂”不是一个错误状态,而是一个有明确余额的中间状态。工程上要监控的不是“有没有悬挂”,而是“悬挂了多久、金额多大”。超过业务时限仍未归零的悬挂,才是需要人工介入的差异。
同样的结构可以用于跨行转账的在途资金、渠道清算的待结算款。共同点是:每个服务只写自己本地平衡的分录,跨服务的缺口由一个专门的过渡账户承载,最终由对账或补偿动作把它清零。
可运行实现
下面的账本只做三件事:校验并追加平衡分录、用反向分录冲正、从全部分录重算不变量。余额是缓存,Check 不信任缓存,而是重新计算后比对。
// file: internal/platform/ledger/ledger.go
// tier: L1
// verify: go test ./internal/platform/ledger/ -count=1
package ledger
import (
"errors"
"fmt"
"sort"
"sync"
)
// Posting is one leg of a double-entry transaction. Debits are positive,
// credits are negative, and every transaction must sum to zero.
type Posting struct {
Account string
AmountCents int64
}
// Entry is one immutable posted transaction.
type Entry struct {
TxnID string
Postings []Posting
ReversalOf string // empty unless this entry reverses another one
}
var (
ErrUnbalanced = errors.New("ledger: transaction does not sum to zero")
ErrDuplicateTxn = errors.New("ledger: transaction id already posted")
ErrAlreadyReversed = errors.New("ledger: transaction already reversed")
ErrUnknownTxn = errors.New("ledger: unknown transaction")
ErrInvalidPosting = errors.New("ledger: invalid posting")
)
// Ledger is an append-only double-entry book. A mistake is corrected by
// reversing it, never by editing the original entry.
type Ledger struct {
mu sync.Mutex
entries map[string]Entry
order []string
balances map[string]int64
reversedBy map[string]string
}
// New returns an empty ledger.
func New() *Ledger {
return &Ledger{
entries: make(map[string]Entry),
balances: make(map[string]int64),
reversedBy: make(map[string]string),
}
}
func validate(e Entry) error {
if e.TxnID == "" {
return fmt.Errorf("ledger: empty txn id: %w", ErrInvalidPosting)
}
if len(e.Postings) < 2 {
return fmt.Errorf("ledger: txn %s has %d postings, want at least 2: %w",
e.TxnID, len(e.Postings), ErrInvalidPosting)
}
var sum int64
accounts := make(map[string]bool, len(e.Postings))
for _, p := range e.Postings {
if p.Account == "" || p.AmountCents == 0 {
return fmt.Errorf("ledger: txn %s has empty account or zero amount: %w", e.TxnID, ErrInvalidPosting)
}
if accounts[p.Account] {
return fmt.Errorf("ledger: txn %s repeats account %q: %w", e.TxnID, p.Account, ErrInvalidPosting)
}
accounts[p.Account] = true
sum += p.AmountCents
}
if sum != 0 {
return fmt.Errorf("ledger: txn %s sums to %d: %w", e.TxnID, sum, ErrUnbalanced)
}
return nil
}
// Post appends a balanced transaction.
func (l *Ledger) Post(e Entry) error {
if err := validate(e); err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
if _, exists := l.entries[e.TxnID]; exists {
return fmt.Errorf("ledger: post %s: %w", e.TxnID, ErrDuplicateTxn)
}
postings := append([]Posting(nil), e.Postings...)
l.entries[e.TxnID] = Entry{TxnID: e.TxnID, Postings: postings, ReversalOf: e.ReversalOf}
l.order = append(l.order, e.TxnID)
for _, p := range postings {
l.balances[p.Account] += p.AmountCents
}
return nil
}
// Reverse posts the exact negation of originalID. Repeating the same reversal
// id is a no-op; a second reversal under a different id is rejected.
func (l *Ledger) Reverse(originalID, reversalID string) (Entry, bool, error) {
if reversalID == "" || reversalID == originalID {
return Entry{}, false, fmt.Errorf("ledger: reverse %s with id %q: %w",
originalID, reversalID, ErrInvalidPosting)
}
l.mu.Lock()
defer l.mu.Unlock()
original, ok := l.entries[originalID]
if !ok {
return Entry{}, false, fmt.Errorf("ledger: reverse %s: %w", originalID, ErrUnknownTxn)
}
if existing, done := l.reversedBy[originalID]; done {
if existing != reversalID {
return Entry{}, false, fmt.Errorf("ledger: reverse %s by %s: %w",
originalID, existing, ErrAlreadyReversed)
}
return l.entries[existing], false, nil
}
if _, exists := l.entries[reversalID]; exists {
return Entry{}, false, fmt.Errorf("ledger: reverse %s: id %s: %w",
originalID, reversalID, ErrDuplicateTxn)
}
rev := Entry{TxnID: reversalID, ReversalOf: originalID}
for _, p := range original.Postings {
rev.Postings = append(rev.Postings, Posting{Account: p.Account, AmountCents: -p.AmountCents})
}
l.entries[reversalID] = rev
l.order = append(l.order, reversalID)
for _, p := range rev.Postings {
l.balances[p.Account] += p.AmountCents
}
l.reversedBy[originalID] = reversalID
return rev, true, nil
}
// Balance returns the signed balance of one account.
func (l *Ledger) Balance(account string) int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.balances[account]
}
// TotalBalance is the sum of every account balance; double entry keeps it 0.
func (l *Ledger) TotalBalance() int64 {
l.mu.Lock()
defer l.mu.Unlock()
var total int64
for _, v := range l.balances {
total += v
}
return total
}
// Entries returns a copy in posting order.
func (l *Ledger) Entries() []Entry {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]Entry, 0, len(l.order))
for _, id := range l.order {
e := l.entries[id]
e.Postings = append([]Posting(nil), e.Postings...)
out = append(out, e)
}
return out
}
// Check recomputes invariants from the entries instead of trusting the running
// balances, and returns violations in stable order.
func (l *Ledger) Check() []string {
l.mu.Lock()
defer l.mu.Unlock()
var violations []string
recomputed := make(map[string]int64)
for _, id := range l.order {
e := l.entries[id]
var sum int64
for _, p := range e.Postings {
sum += p.AmountCents
recomputed[p.Account] += p.AmountCents
}
if sum != 0 {
violations = append(violations, fmt.Sprintf("txn %s is unbalanced: %d", id, sum))
}
}
for account, want := range recomputed {
if got := l.balances[account]; got != want {
violations = append(violations, fmt.Sprintf("account %s balance %d, recomputed %d", account, got, want))
}
}
for _, id := range l.order {
revID, ok := l.reversedBy[id]
if !ok {
continue
}
original, rev := l.entries[id], l.entries[revID]
if rev.ReversalOf != id {
violations = append(violations, fmt.Sprintf("reversal %s points to %q, want %s", revID, rev.ReversalOf, id))
continue
}
orig := make(map[string]int64, len(original.Postings))
for _, p := range original.Postings {
orig[p.Account] = p.AmountCents
}
for _, p := range rev.Postings {
if orig[p.Account] != -p.AmountCents {
violations = append(violations, fmt.Sprintf("reversal %s does not negate %s on %s", revID, id, p.Account))
}
}
}
var total int64
for _, v := range l.balances {
total += v
}
if total != 0 {
violations = append(violations, fmt.Sprintf("total balance is %d, want 0", total))
}
sort.Strings(violations)
return violations
}
实现里有三个关键点。第一,validate 要求至少两条分录、账户不重复、金额非零且总和为零,单腿写入和两腿不平都直接被拒,部分成功不可能以“半笔分录”的形式落库。第二,Reverse 用 reversedBy 记录原单与冲正单的对应关系,同一个冲正单号重复调用返回已有结果,不同冲正单号则报“已冲正”,把幂等和防重放在同一个入口。第三,Check 从分录重算每个账户余额,并逐账户验证冲正是否精确取反,避免运行中的计数错误被缓存掩盖。
本机实测:冲正、幂等与并发守恒
测试覆盖平衡入账、非法分录拒绝、冲正归零、重复冲正、悬挂清零、重复交易号拒绝和 128 并发过账。命令与原始输出(节选,完整报告见 .local/reports/be-arch/):
$ go test ./internal/platform/ledger/ -count=1 -v
=== RUN TestPostBalancedPayment
ledger_test.go:40: channel=1999 suspense=-1999 total=0 violations=0
=== RUN TestUnbalancedTxnRejected
ledger_test.go:60: single_leg=ErrInvalidPosting lopsided=ErrUnbalanced stored=0
=== RUN TestReverseRestoresBalances
ledger_test.go:83: suspense_before=-1999 suspense_after=0 entries=2 total=0 violations=0
=== RUN TestSameReversalIsIdempotent
ledger_test.go:102: second_reversal created=false entries=2
=== RUN TestSecondReversalUnderNewIDRejected
ledger_test.go:117: double_reversal_error=ledger: reverse pay-1001 by rev-1001: ledger: transaction already reversed entries=2
=== RUN TestHangingSuspenseClearsAfterResolve
ledger_test.go:142: hanging_cents=-1999 suspense_after=0 receivable=-1999 total=0
=== RUN TestConcurrentPostsConserve
ledger_test.go:193: posts=128 channel=12800 receivable=-12800 total=0 violations=0
PASS
ok archlab/internal/platform/ledger 0.385s
读数分四组。平衡入账后结算账户 1999、悬挂户 -1999、总额 0,说明资金只是换了归属,没有凭空产生。单腿分录被 ErrInvalidPosting 拒绝,两腿不平被 ErrUnbalanced 拒绝,且都没有落库,说明“部分成功”不可能伪装成一笔完整交易。冲正后悬挂户从 -1999 回到 0,分录数从 1 变成 2,总额仍为 0,四条不变量全部通过。重复冲正用同一个单号返回 created=false 且分录数不变;换一个单号则报“已冲正”,防止第二笔反向分录把悬挂户余额打成 +1999。
并发测试里 128 个 goroutine 各自写入一笔平衡交易,最终结算账户 12800、应收账户 -12800、总额 0、违规 0。这个测试在 -race 下也通过。它验证的是账本在并发追加时不会丢更新,不代表生产数据库的隔离级别;真实实现要靠事务和唯一索引保证同样的性质。
资金类最终一致的判据表
“最终一致”不能只写在设计文档里,要能随时查询和告警。下表把每条不变量映射到可执行的检查。
| 不变量 | 检查方式 | 违反时的动作 | 恢复判据 |
|---|---|---|---|
| 单笔交易借贷平衡 | 按交易号汇总分录金额 | 停止该交易后续步骤,人工核查 | 汇总为零 |
| 全局资金守恒 | 所有账户余额求和 | 立即冻结相关账户,排查写入路径 | 总额回到零 |
| 原单最多冲正一次 | 按原单号统计冲正单 | 拒绝新冲正,检查重复请求 | 冲正单数 ≤ 1 |
| 冲正精确取反 | 逐账户比对原单与冲正单 | 撤销错误冲正,重新按原单生成 | 每个账户金额互为相反数 |
| 悬挂时限 | 过渡账户余额按账龄分桶 | 超时进入人工队列 | 悬挂余额归零或有明确归属 |
最后一行是业务判据,前四行是账本判据。只监控账本判据,悬挂可以永远挂着而无人发现;只监控业务判据,账本内部的分录错误又无法定位。两者要一起上看板。
常见错误与边界
第一,用修改原分录代替冲正。直接把金额改对,账本当下看起来正确,但历史消失了,审计无法还原“曾经记错多少、什么时候改的”。只追加加反向分录,才能同时保留正确性和可追溯性。
第二,把退款记成冲正。退款是真实的资金流出,有渠道流水、手续费和发票影响;冲正只是账面修正。混为一谈会让渠道账单和平台账永远差两笔流水。
第三,冲正没有幂等键。网络重试或人工重复点击会产生第二笔反向分录,余额从 0 变成 +1999,而且账本仍然“借贷平衡”,普通的平衡校验发现不了。冲正单号必须由业务生成并唯一。
第四,只校验单笔平衡,不校验全局守恒。单笔分录可以笔笔平衡,但重复入账会让总额翻倍;全局求和为零只能发现“凭空产生”,发现不了“同一笔业务记了两次”。后者要靠业务幂等键和 T+1 对账。
第五,悬挂户没有账龄。悬挂是合法的中间状态,但长期悬挂等于资金去向不明。必须按账龄分桶告警,并给每笔悬挂一个责任人和解决时限。
小结
冲正修正记录,补偿改变事实,两者不能互相替代。资金类操作的最终一致,靠的是一个只追加、每笔平衡的账本,加上四条可机器检查的不变量:单笔平衡、全局守恒、最多冲正一次、冲正精确取反。跨服务只完成一半时,缺口由悬挂户承载,悬挂余额和账龄是比“有没有报错”更可靠的观测点。本节用冲正归零、重复冲正被拒和 128 并发守恒三组测试验证了这些边界。下一章转向分布式 ID:订单号、支付单号、预占单号都要求全局唯一,Snowflake 的位分配和时钟回拨会直接决定这些单号能不能安全生成。
阅读导航:上一节:对账系统:T+1、实时与差异分类 · 下一节:分布式 ID:Snowflake、号段与时钟回拨 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。