用户付了钱,订单三天后还在待支付
「回声」的付费会员走第三方支付。用户在收银台付款成功后,支付平台通过回调通知服务端,服务端把订单从 pending 改成 paid 并开通会员。回调失败时,支付平台会按自己的策略重试若干次,服务端也配置了消费重试。
一个周五晚上,支付平台的回调到达服务端,服务端处理时数据库连接池刚好耗尽,写入失败,消息进入重试队列。重试了五次仍然失败,第六次超过上限,消息被投进死信队列。死信队列没有告警,也没有人定期查看。用户页面一直显示“待支付”,但银行卡已经扣款。
用户以为网络问题,重新下单又付了一次;第二次成功了,会员开通,但第一笔钱没有对应任何权益。三天后用户联系客服,客服在支付平台后台查到两笔成功交易,在业务库里只查到一笔订单,才开始人工退款。此时已经产生了退款手续费、客服工单和一条差评。
事后复盘发现,链路上每一层都“做了自己该做的”:支付平台重试了,服务端重试了,超过上限进死信了,死信也保存了。缺的是三件没有写进设计的事情:死信必须有人处理、支付平台的结算数据必须和业务订单对账、用户可见状态必须能解释“钱已扣但权益未到”。
重复、丢失、乱序不是异常分支,而是消息链路的常态。设计的目标不是消灭它们,而是让每一种都被一层明确的防线接住,并且让用户感知到的是“稍等”或“处理中”,而不是“系统坏了”。
重复、丢失、乱序是常态
先把三种偏差分开看。重复来自“确认丢失后的重发”,丢失来自“确认过早、进程崩溃或队列丢弃”,乱序来自“多个分区、多个消费者、并发处理”。它们的成因不同,防线也不同。
| 偏差 | 典型成因 | 业务后果 | 第一道防线 | 最后一道防线 |
|---|---|---|---|---|
| 重复 | 处理成功但确认超时,Broker 重投 | 计数多加、通知重发、重复扣款 | 消费者幂等键 | 对账时按业务主键去重 |
| 丢失 | 先确认后处理、进程崩溃、死信无人消费 | 订单永久待支付、权益未开通 | 处理完再确认 + 持久化 | 期望值与实际值对账 |
| 乱序 | 多分区并发、重试插队 | 旧状态覆盖新状态、版本回退 | 按业务键分区 + 版本比较 | 定时重算与状态修复 |
| 毒消息 | 结构错误、版本过旧、业务不变量不满足 | 消费者反复失败、阻塞分区 | 重试上限 + 死信 | 人工或脚本修复后重放 |
重试解决的是“暂时性失败”:网络抖动、下游 503、数据库连接池短暂耗尽。它不解决“业务拒绝”:余额不足、订单已关闭、参数非法,这些错误重试一百次也不会成功,只会浪费时间并阻塞后面的消息。重试必须有上限,否则一条坏消息可以让整个分区停住。
退避解决的是“重试风暴”。固定间隔重试会让所有失败者在同一时刻再次打到下游,下游刚恢复又被压垮。指数退避把重试分散开,但必须有上限:没有上限的指数退避会从 1 秒涨到几小时,用户在“最终一致”里等不到头。Max 不是性能参数,而是产品承诺的一部分。
死信解决的是“重试耗尽后去哪里”。死信不是垃圾桶,而是一个待处理队列:它必须有负责人、有告警、有重放工具、有保留期限。没有告警的死信队列等于把丢失藏起来,系统看起来一切正常,只有用户知道钱被扣了。
对账解决的是“不知道丢没丢”。重试和死信只能处理“我们知道失败”的消息;如果消息在确认环节丢失,或者消费者静默跳过了某条记录,系统内部没有任何错误日志。对账用两个独立来源比较期望值和实际值,把“未知的丢失”变成“可列举的缺失项”。
补偿解决的是“业务上必须回到正确状态”。重试是技术动作,补偿是业务动作:退款、补发权益、回滚库存、给用户发说明。补偿通常不可逆,必须有审批、幂等和审计,不能写成“再跑一次任务”。
这五层不是替代关系。只有重试没有死信,坏消息会永远循环;只有死信没有对账,静默丢失永远发现不了;只有对账没有补偿,能发现问题但用户仍然受损。一个可运维的链路,至少要让每一层都有明确的触发条件和负责人。
失败场景:消息丢了没人发现
回到那笔订单。把时间线展开,可以看到三个独立的缺口。
第一个缺口是“确认”和“处理”的顺序。服务端的消费者用了自动确认:消息一到就告诉 Broker “我收到了”,然后才去写数据库。写库失败时,消息已经被确认,Broker 不会重投。这条回调就这样消失了,日志里只有一条数据库连接超时,没有任何“消息丢失”的记录。
第二个缺口是重试上限之后没有动作。即使改成手动确认,重试五次仍然可能全部失败。消息进入死信后,系统没有任何告警;死信队列的监控面板是空的,因为团队只监控了主队列的积压。三天里没有任何人知道有一条支付回调躺在死信里。
第三个缺口是没有对账。支付平台每天生成结算文件,业务库里有订单表,但两边从来没有比较过。如果有一个日终任务把“支付平台成功交易”与“业务已支付订单”做差集,第一笔丢失会在第二天早上暴露,而不是三天后由用户投诉暴露。
三个缺口叠加,造成的用户感知是:钱扣了,会员没到,页面还让继续支付。用户无法判断是银行延迟、网络问题还是系统故障,只能重复操作。重复支付又产生新的订单和新的回调,进一步放大对账难度。
修复要同时动三处,缺一不可:
- 消费者改成手动确认,业务事务提交成功后再确认;确认失败会导致重复,但重复由幂等键接住。
- 幂等键使用支付平台的交易号,写入订单表时加唯一约束;同一笔交易重复回调只更新一次状态。
- 重试设置上限和退避,超过上限进入死信;死信积压、最老消息年龄、重放失败都配置告警。
- 每天用支付平台结算文件和业务订单做对账,输出缺失、金额不一致、状态不一致三类差异。
- 对账发现缺失后,走补偿流程:查支付平台交易、补开权益或退款,并给用户发通知。
这套方案没有让消息“绝不丢失”,而是让丢失在一天内被发现,并且有明确的补偿路径。用户可能仍然要等,但等待有上限、有解释、有客服可查的记录。
用 Next 与 Reconcile 固定重试和对账
重试和对账都可以写成纯函数:前者根据已尝试次数计算下一次等待时间,后者比较两个 ID 集合返回缺失项。纯函数的好处是边界可以穷举,策略调整时不需要在消费者代码里到处改常量。
// file: internal/async/retry.go
// tier: L1
// verify: go test ./internal/async/
package async
import (
"fmt"
"sort"
"time"
)
// Failure 是一条投递失败记录。Attempts 是已经尝试过的次数。
type Failure struct {
MsgID string
Attempts int
}
// Policy 是重试上限和退避参数。Base 是第一次重试的等待时间。
type Policy struct {
MaxAttempts int
Base time.Duration
Max time.Duration
}
// Next 计算下一次重试前的等待时间。
// 指数退避以 Base 起步,翻倍增长但不超过 Max。
// 已经达到 MaxAttempts 时返回 shouldRetry=false,由调用方转入死信。
func Next(f Failure, p Policy) (time.Duration, bool, error) {
if p.Base <= 0 {
return 0, false, fmt.Errorf("async: retry base must be positive")
}
if p.Max < p.Base {
return 0, false, fmt.Errorf("async: retry max %s below base %s", p.Max, p.Base)
}
if f.Attempts >= p.MaxAttempts {
return 0, false, nil
}
delay := p.Base
for i := 1; i < f.Attempts; i++ {
if delay >= p.Max/2 {
delay = p.Max
break
}
delay *= 2
}
if delay > p.Max {
delay = p.Max
}
return delay, true, nil
}
// Reconcile 对账:返回 expected 中存在、actual 中缺失的消息 ID,按升序排列。
// 重复的期望值只算一次;输入不会被修改。
func Reconcile(expected, actual []string) []string {
seen := make(map[string]bool, len(actual))
for _, id := range actual {
seen[id] = true
}
missing := make(map[string]bool)
for _, id := range expected {
if !seen[id] {
missing[id] = true
}
}
out := make([]string, 0, len(missing))
for id := range missing {
out = append(out, id)
}
sort.Strings(out)
return out
}
Failure.Attempts 表示“已经尝试过的次数”,不是“下一次是第几次”。这个语义要在接口上写清楚,否则调用方传入 0 和 1 会得到不同的退避节奏。当前实现里,第一次失败(Attempts=1)等待 Base,第二次失败等待 2*Base,以此类推。
Next 的退避用循环翻倍而不是位移,是为了在 Max 很小时也能安全收敛。每次翻倍前检查 delay >= p.Max/2,避免 delay*2 溢出或越过上限;循环结束后再兜底一次 delay > p.Max。这样无论 Attempts 多大,返回值都不会超过 Max。
MaxAttempts 的判断是 Attempts >= MaxAttempts 时停止重试。也就是说,MaxAttempts=5 表示最多尝试 5 次,第 5 次失败后返回 shouldRetry=false,由调用方转入死信。返回的延迟是 0,调用方不应该把它当成“立刻重试”。
策略校验只做两件事:Base 必须为正,Max 不能小于 Base。这两个错误通常来自配置;如果不拦住,Base=0 会导致所有重试立刻发生,Max<Base 会让第一次退避就被截断,指数退避名存实亡。
Reconcile 把 expected 和 actual 当成集合,而不是有序列表。重复的期望 ID 只算一次,实际 ID 里多出来的部分不报错——对账关心的是“期望有但没有”,不是“实际多了什么”。返回结果按字典序升序,保证同一批差异在不同机器上输出一致,方便比对和重放。
对账函数不负责判断“为什么缺失”。缺失可能来自消息丢失、消费者跳过、状态更新失败,也可能来自上游根本没发。它只负责把差异列出来,后续由人工或补偿任务逐条处理。把原因判断塞进对账函数,会让它依赖太多业务细节,最终变成一个难以测试的巨函数。
测试:退避有上限,对账不重不漏
测试要固定四个行为:指数增长、封顶、耗尽后停止、非法策略报错;对账则要固定去重、升序和不修改输入。
// file: internal/async/retry_test.go
// tier: L1
// verify: go test ./internal/async/
package async
import (
"reflect"
"testing"
"time"
)
func TestNextBacksOffExponentially(t *testing.T) {
p := Policy{MaxAttempts: 5, Base: 100 * time.Millisecond, Max: time.Second}
want := []time.Duration{
100 * time.Millisecond,
200 * time.Millisecond,
400 * time.Millisecond,
800 * time.Millisecond,
}
for i, w := range want {
got, retry, err := Next(Failure{MsgID: "m-1", Attempts: i + 1}, p)
if err != nil {
t.Fatal(err)
}
if !retry {
t.Fatalf("attempt %d: retry = false", i+1)
}
if got != w {
t.Fatalf("attempt %d: delay = %s, want %s", i+1, got, w)
}
}
}
func TestNextCapsAtMax(t *testing.T) {
p := Policy{MaxAttempts: 10, Base: 100 * time.Millisecond, Max: 250 * time.Millisecond}
want := []time.Duration{
100 * time.Millisecond,
200 * time.Millisecond,
250 * time.Millisecond,
250 * time.Millisecond,
}
for i, w := range want {
got, retry, err := Next(Failure{MsgID: "m-2", Attempts: i + 1}, p)
if err != nil || !retry {
t.Fatalf("attempt %d: retry=%v err=%v", i+1, retry, err)
}
if got != w {
t.Fatalf("attempt %d: delay = %s, want %s", i+1, got, w)
}
}
}
func TestNextStopsAtMaxAttempts(t *testing.T) {
p := Policy{MaxAttempts: 3, Base: time.Second, Max: time.Minute}
delay, retry, err := Next(Failure{MsgID: "m-3", Attempts: 3}, p)
if err != nil {
t.Fatal(err)
}
if retry {
t.Fatal("exhausted failure must not retry")
}
if delay != 0 {
t.Fatalf("delay = %s, want 0", delay)
}
}
func TestNextRejectsInvalidPolicy(t *testing.T) {
if _, _, err := Next(Failure{}, Policy{MaxAttempts: 3, Base: 0, Max: time.Second}); err == nil {
t.Fatal("want error for zero base")
}
if _, _, err := Next(Failure{}, Policy{MaxAttempts: 3, Base: -time.Second, Max: time.Second}); err == nil {
t.Fatal("want error for negative base")
}
if _, _, err := Next(Failure{}, Policy{MaxAttempts: 3, Base: time.Second, Max: 500 * time.Millisecond}); err == nil {
t.Fatal("want error when max is below base")
}
}
func TestReconcileReturnsSortedMissing(t *testing.T) {
expected := []string{"c", "a", "b", "a"}
actual := []string{"b", "d"}
want := []string{"a", "c"}
got := Reconcile(expected, actual)
if !reflect.DeepEqual(got, want) {
t.Fatalf("missing = %v, want %v", got, want)
}
if !reflect.DeepEqual(expected, []string{"c", "a", "b", "a"}) {
t.Fatalf("expected was mutated: %v", expected)
}
if !reflect.DeepEqual(actual, []string{"b", "d"}) {
t.Fatalf("actual was mutated: %v", actual)
}
}
func TestReconcileEmptyWhenNothingMissing(t *testing.T) {
got := Reconcile([]string{"a", "b"}, []string{"b", "a", "c"})
if len(got) != 0 {
t.Fatalf("missing = %v, want empty", got)
}
if got == nil {
t.Fatal("want a non-nil empty slice")
}
}
TestNextBacksOffExponentially 用 100 毫秒起步、1 秒封顶的策略,断言前四次等待是 100、200、400、800 毫秒。它同时检查 shouldRetry=true,确保未耗尽的失败不会被误判成终止。
TestNextCapsAtMax 把上限压到 250 毫秒,断言第三次和第四次都返回 250 毫秒。这个测试防止实现只在循环结束后截断一次,导致中间某次返回值短暂超过上限;对于调用方来说,超过上限的等待就是一次不可接受的延迟。
TestNextStopsAtMaxAttempts 检查 Attempts=3、MaxAttempts=3 时返回 false 和 0 延迟。边界选在“等于”而不是“大于”,因为第 3 次失败后已经没有下一次尝试,继续返回延迟会让调用方多睡一段时间再放弃。
TestNextRejectsInvalidPolicy 覆盖 Base=0、Base 为负、Max<Base 三种配置错误。它们都应该在计算之前失败,而不是返回一个看似合理的延迟。
TestReconcileReturnsSortedMissing 用重复的期望值和带多余项的实际值,断言结果是去重且升序的 ["a", "c"],并检查两个输入切片都没有被修改。最后一条很重要:对账函数如果为了省内存直接排序输入,调用方手里的原始数据会被悄悄改变,后续的重放或审计就会拿到错误顺序。
TestReconcileEmptyWhenNothingMissing 检查没有缺失时返回非 nil 的空切片。返回 nil 和空切片在 JSON 里可能都是 [],但在 Go 调用方眼里不同:前者需要额外判空,后者可以直接 range 和比较长度。
这些测试不验证重试是否真的发送了消息,也不验证死信是否被消费。那些属于集成测试和运维演练;单元测试只把策略函数的边界钉死,让消费者可以放心地调用。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/async/ -count=1 -run 'TestNext|TestReconcile' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestNextBacksOffExponentially
--- PASS: TestNextBacksOffExponentially (0.00s)
=== RUN TestNextCapsAtMax
--- PASS: TestNextCapsAtMax (0.00s)
=== RUN TestNextStopsAtMaxAttempts
--- PASS: TestNextStopsAtMaxAttempts (0.00s)
=== RUN TestNextRejectsInvalidPolicy
--- PASS: TestNextRejectsInvalidPolicy (0.00s)
=== RUN TestReconcileReturnsSortedMissing
--- PASS: TestReconcileReturnsSortedMissing (0.00s)
=== RUN TestReconcileEmptyWhenNothingMissing
--- PASS: TestReconcileEmptyWhenNothingMissing (0.00s)
PASS
ok webapp/internal/async 0.232s
输出里的 0.232s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。-run 'TestNext|TestReconcile' 只匹配本节的退避与对账测试,不包含同包的模式判断和事件幂等测试。
小结
重复、丢失、乱序是消息链路的常态。重试处理暂时性失败,退避避免重试风暴,上限和死信防止坏消息无限循环,对账发现“系统自己都不知道”的丢失,补偿把业务状态拉回正确位置。五层各司其职,缺一层就会把问题推给用户或客服。
重试策略要写清楚 Attempts 的语义,退避必须有上限,达到上限要返回明确的“停止重试”而不是零延迟。对账要把输入当集合,去重、升序、不修改输入,只负责列出缺失项,不负责猜测原因。
下一章会从单条链路转向发布:功能上线的最小发布单元是什么,灰度、回滚和配置变更如何避免把一次发布变成一次全站故障。
阅读导航:上一节:9.2 事件、通知与最终一致 · 下一节:10.1 功能上线的最小发布单元 。
常见错误与边界
- 用重试处理业务拒绝。余额不足、订单关闭这类错误重试不会成功,只会阻塞分区。
- 重试没有上限。一条毒消息可以让整个消费者永远停在同一条记录上。
- 退避没有封顶。等待时间涨到几小时后,“最终一致”对用户来说就是永远不一致。
- 死信队列没有负责人和告警。消息进了死信等于消失,只是换了一个看不见的地方。
- 先确认后处理。进程在确认之后、写库之前崩溃,消息永久丢失且没有错误日志。
- 只监控主队列积压。死信积压、最老消息年龄和重放失败同样需要告警。
- 把对账当成设计缺陷。对账是发现未知丢失的最后一道防线,不是“系统做得不好”的证据。
- 对账直接修改输入切片。调用方的原始顺序被破坏,后续重放和审计会拿到错误数据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。