支付回调重试时,重复的不只是请求
订单 o-1001 金额 1999 分,用户支付成功。渠道的支付回调在 200 毫秒后超时,渠道按约定重试三次;与此同时,客户端因为没拿到响应也重试了一次下单接口。三个请求几乎同时到达,每个都先查“支付单是否已存在”,都没查到,然后各自插入一条支付记录。结果订单被记了三次款。
错误直觉是“加个唯一索引就解决了”。唯一索引确实能挡住第二次插入,但挡不住更隐蔽的情况:两次请求带着不同的支付单号,却对应同一笔业务;或者第一次请求已经扣了库存,第二次在插入支付记录之前就失败了。幂等要解决的不是“同一行插两次”,而是“同一个业务意图被执行多次,副作用只能发生一次”。
站内 幂等与可靠性 讨论过重试、去重表与至少一次投递的整体关系。本节只回答一个更具体的问题:幂等键本身应该长什么样,它在什么范围内唯一,活多久,以及过期之后会发生什么。
幂等键的三段作用域
一个可用的幂等键至少由三段组成:租户、操作、请求标识。缺任何一段都会在生产上出问题。
| 组成 | 作用 | 缺失后的典型故障 |
|---|---|---|
| 租户 | 隔离不同商户的键空间 | 商户 A 的请求号碰巧等于商户 B 的,B 的请求被当成重放 |
| 操作 | 区分同一业务对象上的不同动作 | 用订单号同时做“创建”和“取消”的键,取消请求拿到创建结果 |
| 请求标识 | 标识一次逻辑意图 | 用订单号做键,用户改金额后重试被当成重复而拒绝 |
第三段最容易被误解。请求标识应该由调用方为“一次逻辑意图”生成,而不是为“一次 HTTP 连接”生成。客户端超时后重试同一个意图,必须复用同一个值;用户修改金额后再次提交,则是新的意图,必须换新值。把生成责任推给网关按连接生成,等于每次重试都是新键,幂等表形同虚设。
服务端不应该替调用方“猜”键。常见做法是要求写接口携带 Idempotency-Key 头,缺失时直接拒绝,而不是退化成按请求体哈希。请求体哈希看似方便,但两个不同用户提交相同内容时会撞键,而且金额字段的格式差异会让同一意图产生不同哈希。
指纹:同键不同体必须冲突
键只回答“是不是同一个意图”,指纹回答“这个意图的内容有没有变”。服务端对规范化后的业务字段做哈希,至少包含订单号、金额、币种、操作类型,得到指纹并与键一起存储。
同一个键带着不同指纹再次到达时,正确响应是冲突,不是覆盖,也不是重放旧结果。覆盖会让后到的请求悄悄改写前一次的业务参数;重放旧结果则会让调用方以为新参数生效了。两种都会把错误推迟到对账阶段才暴露。
指纹还必须覆盖那些“看起来不影响结果”的字段。比如优惠券 ID、收货地址、支付方式,任一变化都可能改变资金流向。实现上不要把整个 JSON 原样哈希,字段顺序和空值表示会让同一份数据产生不同指纹;先按固定字段列表拼出规范串,再哈希。
生命周期:租约、完成与保留
幂等记录不是一个布尔值,而是一个有状态机的小对象。至少有四种状态:执行中、成功、最终失败、可重试失败。状态之间的转换由执行结果驱动,而不是由时间单独驱动。
- 执行中:由一个调用方持有租约,其他同键请求返回“处理中”,让客户端稍后重试。
- 成功:保存响应体,后续同键请求直接重放,不再执行业务。
- 可重试失败:明确知道没有产生副作用,释放键,允许下一次执行。
- 最终失败:失败是确定性的,保留错误结果,避免调用方反复撞击。
租约是关键设计。没有租约,一个进程崩溃后键会永远停在“执行中”;租约太长,故障恢复要等很久;租约太短,慢请求还在执行时键就被别人接管,两个执行者同时产生副作用。
解决后一个问题的不是把租约调长,而是 fencing token:每次接管都让令牌单调递增,完成时必须带上自己那一轮的令牌。旧执行者即使还活着,它的完成请求也会因为令牌过期被拒绝。数据库里对应的写法是 UPDATE ... WHERE attempt = ?,影响行数为零就说明自己已经失去所有权。
保留期则回答“成功结果要记多久”。它必须覆盖客户端可能重试的最长时间,也要覆盖业务允许发起争议的窗口。支付类操作通常按天计,创建类操作可以短一些。保留期短于重试窗口,会出现“第一次成功、记录已过期、重试又执行一次”的重复。
可运行实现
下面的实现把键、指纹、租约和令牌放在同一个包里。时钟是注入的,测试可以手动推进,不依赖真实等待。
// file: internal/platform/idempotency/store.go
// tier: L1
// verify: go test ./internal/platform/idempotency/ -count=1
package idempotency
import (
"errors"
"fmt"
"sync"
"time"
)
// Key identifies one operation. Tenant and Operation scope a client-supplied
// key so the same value cannot collide across tenants or APIs.
type Key struct {
Tenant string
Operation string
Value string
}
func (k Key) String() string { return k.Tenant + "/" + k.Operation + "/" + k.Value }
func (k Key) validate() error {
if k.Tenant == "" || k.Operation == "" || k.Value == "" {
return fmt.Errorf("idempotency: tenant=%q operation=%q key=%q: %w",
k.Tenant, k.Operation, k.Value, ErrKeyEmpty)
}
return nil
}
// State is the lifecycle of one idempotency record.
type State string
const (
StateInProgress State = "in_progress"
StateDone State = "done"
StateFailed State = "failed"
StateRetryable State = "retryable"
)
var (
ErrConflict = errors.New("idempotency: key reused with different payload")
ErrInProgress = errors.New("idempotency: request already in progress")
ErrLeaseExpired = errors.New("idempotency: lease expired before completion")
ErrStaleAttempt = errors.New("idempotency: attempt no longer owns the key")
ErrKeyEmpty = errors.New("idempotency: empty key component")
)
// Record holds a replayable outcome. Attempt is a fencing token: only the
// attempt that currently owns the key may complete it.
type Record struct {
State State
Fingerprint string
Response string
ExpiresAt time.Time
Attempt uint64
}
// Store keeps records in memory. The same state machine can run behind a
// unique index on (tenant, operation, key).
type Store struct {
mu sync.Mutex
clock func() time.Time
lease time.Duration
retention time.Duration
seq uint64
records map[string]Record
}
// NewStore returns a store. lease bounds how long an in-progress record blocks
// duplicates; retention bounds how long a final answer can be replayed.
func NewStore(lease, retention time.Duration, clock func() time.Time) (*Store, error) {
if lease <= 0 || retention <= 0 || clock == nil {
return nil, fmt.Errorf("idempotency: lease=%v retention=%v clock_nil=%v", lease, retention, clock == nil)
}
return &Store{
clock: clock,
lease: lease,
retention: retention,
records: make(map[string]Record),
}, nil
}
// live returns a record unless it has expired. Caller holds s.mu.
func (s *Store) live(key string) (Record, bool) {
r, ok := s.records[key]
if !ok {
return Record{}, false
}
if !s.clock().Before(r.ExpiresAt) {
delete(s.records, key)
return Record{}, false
}
return r, true
}
// Begin reserves the key. created is true only for the caller that must
// execute. A finished request returns its stored record; a concurrent
// duplicate gets ErrInProgress.
func (s *Store) Begin(k Key, fingerprint string) (Record, bool, error) {
if err := k.validate(); err != nil {
return Record{}, false, err
}
if fingerprint == "" {
return Record{}, false, fmt.Errorf("idempotency: begin %s: empty fingerprint", k)
}
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.live(k.String()); ok {
if r.Fingerprint != fingerprint {
return Record{}, false, fmt.Errorf("idempotency: begin %s: %w", k, ErrConflict)
}
switch r.State {
case StateDone, StateFailed:
return r, false, nil
case StateInProgress:
return Record{}, false, fmt.Errorf("idempotency: begin %s: %w", k, ErrInProgress)
}
// StateRetryable: the previous attempt promised no effect.
}
s.seq++
rec := Record{State: StateInProgress, Fingerprint: fingerprint,
ExpiresAt: s.clock().Add(s.lease), Attempt: s.seq}
s.records[k.String()] = rec
return rec, true, nil
}
// Complete stores the response under the retention window. A stale attempt is
// rejected even when its fingerprint matches, because a lease takeover cannot
// safely share the same key.
func (s *Store) Complete(k Key, fingerprint string, attempt uint64, response string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.live(k.String())
if !ok {
return fmt.Errorf("idempotency: complete %s: %w", k, ErrLeaseExpired)
}
if r.Fingerprint != fingerprint {
return fmt.Errorf("idempotency: complete %s: %w", k, ErrConflict)
}
if r.Attempt != attempt {
return fmt.Errorf("idempotency: complete %s: %w", k, ErrStaleAttempt)
}
r.State, r.Response = StateDone, response
r.ExpiresAt = s.clock().Add(s.retention)
s.records[k.String()] = r
return nil
}
// Fail records a failed attempt. retryable=true releases the key; false makes
// the failure final for the retention window.
func (s *Store) Fail(k Key, fingerprint string, attempt uint64, retryable bool) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.live(k.String())
if !ok {
return fmt.Errorf("idempotency: fail %s: %w", k, ErrLeaseExpired)
}
if r.Fingerprint != fingerprint {
return fmt.Errorf("idempotency: fail %s: %w", k, ErrConflict)
}
if r.Attempt != attempt {
return fmt.Errorf("idempotency: fail %s: %w", k, ErrStaleAttempt)
}
if retryable {
r.State = StateRetryable
} else {
r.State = StateFailed
}
r.ExpiresAt = s.clock().Add(s.retention)
s.records[k.String()] = r
return nil
}
实现有三个刻意的选择。第一,Begin 对已完成或最终失败的键返回存储记录而不是错误,让调用方能区分“重放”和“冲突”。第二,Complete 同时校验指纹和令牌:指纹防止同键不同体,令牌防止租约被接管后旧执行者仍然落库。第三,过期记录在读取时惰性删除,不需要后台清理线程;生产环境可以再加一个扫描任务处理从未被再次访问的键。
本机实测:并发、接管与过期
测试使用假时钟,租约 30 秒、保留期 24 小时。命令与原始输出(节选,完整报告见 .local/reports/be-arch/):
$ go test ./internal/platform/idempotency/ -count=1 -v
=== RUN TestConcurrentSameKeyOnlyOneOwner
store_test.go:82: workers=64 created=1 in_progress=63 other=0
--- PASS: TestConcurrentSameKeyOnlyOneOwner (0.00s)
=== RUN TestLeaseExpiryAllowsTakeover
store_test.go:139: after_lease_expiry attempt_old=1 attempt_new=2 created=true late_complete=idempotency: complete tenant-a/order.create/req-1001: idempotency: attempt no longer owns the key
--- PASS: TestLeaseExpiryAllowsTakeover (0.00s)
=== RUN TestRetentionExpiryForgetsKey
store_test.go:198: after_retention created=true
--- PASS: TestRetentionExpiryForgetsKey (0.00s)
PASS
ok archlab/internal/platform/idempotency 0.336s
读数分三段。64 个并发请求打同一个键,只有 1 个拿到执行权,其余 63 个收到“处理中”,没有出现两个执行者,说明键的抢占是原子的。租约推进 31 秒后,第二个执行者拿到令牌 2;第一个执行者拿着令牌 1 去完成,被明确拒绝,这就是 fencing token 的作用。保留期推进 24 小时零 1 秒后,同一个键被当作全新记录接受,说明成功结果不会永久占用键空间。
这些数字来自单机内存和假时钟,只验证状态机语义,不代表生产延迟。真实存储要把 Begin 做成一次条件写入,把 Complete 做成带令牌条件的更新,并在同一事务里写业务结果和幂等记录,否则“业务已提交、记录未写入”的窗口仍然会产生重复。
存储选型与决策表
幂等记录可以放内存、Redis 或关系库。选型看的不是吞吐,而是“业务副作用和记录能不能一起原子提交”。
| 方案 | 适用 | 代价 | 退出条件 |
|---|---|---|---|
| 进程内 map | 单实例、可重放的计算型操作 | 重启即丢,不能跨实例 | 出现多实例或需要持久化 |
Redis SET NX PX | 高并发短事务、允许少量丢失 | 与业务库不在同一事务 | 业务副作用必须与记录同生共死 |
| 关系库唯一索引 | 支付、库存等资金类操作 | 写放大,需要清理历史 | 单表膨胀到影响写入 |
| 独立幂等服务 | 多语言、多团队共用 | 多一次网络往返与可用性依赖 | 延迟预算不允许额外跳转 |
对订单支付这种场景,推荐关系库唯一索引加令牌条件更新:业务落库和幂等记录在同一个本地事务里,失败一起回滚。Redis 适合放在前面做快速拦截,但不能作为唯一真相,否则 Redis 丢一次数据就可能多扣一次款。
常见错误与边界
第一,按键的存在与否判断重复,而不看状态。记录存在但状态是“可重试失败”时,应该允许重新执行;记录存在且是“执行中”时,应该让调用方等待而不是直接返回成功。把“存在”等同于“已完成”,会把失败请求永久钉死。
第二,幂等键按 HTTP 请求生成。客户端每重试一次就换一个键,去重表永远不会命中。键必须绑定逻辑意图,由发起方在第一次提交前生成并保存,重试时复用。
第三,作用域漏掉租户或操作。只用请求号做键,跨租户碰撞会让一个商户拿到另一个商户的响应;只用订单号做键,创建和取消会互相覆盖。三段作用域是最小可用集合。
第四,租约过期后没有令牌校验。两个执行者同时持有一个键,后完成的那个覆盖先完成的业务结果;表面上看幂等表有记录,实际上副作用发生了两次。租约只负责“让后来者能接管”,令牌才负责“让旧执行者闭嘴”。
第五,保留期短于业务重试窗口。成功记录过期后,同一个键会被当成新请求再次执行。保留期要按最坏重试间隔和争议窗口取最大值,而不是按缓存习惯拍一个小时。
小结
幂等键不是一列字符串,而是“作用域 + 指纹 + 状态机 + 租约令牌”的组合。作用域保证不同租户、不同操作、不同意图不会互相污染;指纹保证同键不同体被识别为冲突;租约让崩溃后的键可以恢复,令牌让恢复期间的旧执行者无法落库。本节给出的实现用 64 并发、租约接管和保留期过期三组测试验证了这些边界。下一节换一个方向:即使每个写接口都幂等,平台账和渠道账仍然可能对不上,需要一套对账系统把差异找出来并分类。
阅读导航:上一节:消息顺序、重放与死信的对账闭环 · 下一节:对账系统:T+1、实时与差异分类 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。