学习目标
学完本文,你应该能够:
- 一句话说清为什么"故障"不是二选一:跨服务调用有成功、失败、**未知(超时)**三种结果,绝大多数事故是把"未知"当成"失败"去重试试出来的。
- 讲清超时预算(timeout budget)该怎么分配,并能当场指出"每一跳各自写
WithTimeout(5s)“这个反模式为什么会造成雪崩。 - 说出重试的三条铁律:只重试幂等请求与特定错误码、退避必须带抖动、重试预算(令牌桶)优于重试次数。
- 用 Go 跑通一条完整的客户端防线:舱壁隔离 + 预算感知的重试 + 降级兜底,看到"剩余预算不足以再等一次退避"时程序如何主动放弃。
- 把最终的落脚点接到数据一致性上:Outbox 本地消息表 + 至少一次投递 + 消费幂等 + 定时对账,并说清 Saga/TCC 为什么要用、以及空补偿/悬挂/幂等三坑。
前置知识:写过 Go 的 context(至少知道 WithTimeout 配 defer cancel());知道 gRPC 或 HTTP 怎么发请求;了解「本站《海量并发下分布式事务一致性
》」里讲过的本地消息表与手动 Ack(本文第五道防线与它是同源方案,本文讲调用侧,那篇讲投递侧,建议对照读)。
本章你会动手做的事:
- 跑通一个带「舱壁 + 抖动退避 + 重试预算 + 降级」的 Go 客户端,用 70% 故障率的下游观察它如何收敛而不是雪崩。
- 手写一个幂等消费的 SQL 范式(
INSERT 去重表 + UPDATE 状态机 CAS),理解为什么先查重再执行一定会出现竞态。 - 画一张"五道防线"考点脑图(见下方),把整条链路串成一张网。
一、考点脑图
先给整篇文章一张总览图。这道题考的不是某个中间件的 API,而是你怎么按顺序布防线、以及每一行 defense code 背后的取舍。
微服务调用链可靠性架构总图
看图要点:这张图把"五道防线"挂到了一条调用链上。面试官会追问:超时预算为什么不能每层各自
WithTimeout(总等待失控);重试预算与重试次数的区别、重试放大怎么算(下游故障率 × 重试倍数);熔断三态的回边条件(Closed→Open 的错误率阈值、Half-Open 探测成功后如何逐步放量);限流为何是"被打的人"的自我保护;降级为什么写请求绝不能本地兜底。
二、先建立直觉:调用结果有三种,不是两种
2.1 生活类比:快递签收那 3 秒
如果你因为"没接"就跑去物业再登记一次代收,就可能出现两份代收记录。
对应到工程里:网络超时 = 你不知道下游到底执行了没有,这是所有幂等问题的源头。
2.2 严谨定义
跨服务调用的结果空间只有三个元素:
| 结果 | 含义 | 处理策略 |
|---|---|---|
| 成功 | 明确拿到成功响应 | 正常推进 |
| 失败 | 明确拿到失败响应(如 4xx / INVALID_ARGUMENT) | 该告警告警,一般不重试 |
| 未知 | 超时 / 连接重置 / 调用方在拿到响应前崩溃 | 只能靠幂等来兜,不能盲目重放 |
工程里真正难的不是"失败”,而是"未知"。把未知当成失败去重试,是绝大多数重复下单、重复扣款的直接原因。
第一层,发起前:生成幂等键、确定超时预算、判断这个请求值不值得重试。
第二层,调用中:用 ctx 的 deadline 做预算透传,重试带指数退避和抖动,并用"重试预算"而不是"重试次数"来限流。
第三层,故障放大控制:熔断 + 舱壁隔离,别让一个慢依赖拖垮整个连接池。
第四层,失败后的兜底:分级降级,且降级结果必须带标记透传给前端。
第五层,数据最终一致:写操作走 Outbox 本地消息表 + 至少一次投递 + 消费幂等,最后用定时对账兜底。
核心思路是:我们消除不了失败,只能控制失败的半径、失败的形态,以及失败后的收敛手段。
三、第一道防线:发起前的预算与准入
3.1 幂等键必须由最上游生成
幂等键(request_id / 业务号 order_no)要在链路的最入口(网关或第一个服务)生成,然后通过 gRPC metadata / HTTP Header 一路透传下去。
// 入口:一次性生成,之后所有下游复用同一个值
reqID := uuid.NewString() // 或业务幂等号,如 U123-TIMESTAMP-RANDOM
ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", reqID)
3.2 超时预算必须逐层递减
这是最经典的坑:网关 5s、用户服务 5s、订单服务 5s、库存 5s,四个 5s 叠起来最坏情况要等 20s,而入口早就超时了——中间几个服务白等、白占连接。正确做法是「预算瀑布」:每一跳先把自己的开销扣掉,剩下的才交给下游。
Go 里的落地就两行,但顺序不能错:
func (h *CreateOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 1) 在最外层一次性定完整个请求的预算
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel() // 不 cancel = timer + goroutine 泄漏(面试高频追问)
// 2) 下游调用全部继承这个 ctx,绝不自己再造一个新的 5s
err := h.orderClient.CreateOrder(ctx, req)
}
ctx 带着 deadline 往下传时,gRPC 会自动把它转成 grpc-timeout 元数据,下游再用 ctx.Deadline() 拿到剩余预算,天然形成瀑布。你要做的只是忍住别在中间层重新 WithTimeout。
ctx deadline 不会自己变成 header——要么手动把剩余预算写进自定义 header(如 X-Timeout-Ms)并在下游解析重建 ctx,要么用框架层统一处理。很多团队"HTTP 调用链超时层层失控"的根因就在这:以为传了 ctx 就万事大吉,其实预算根本没跨进程传下去。四、第二道防线:重试的三条铁律
4.1 铁律一:只重试"安全且值得"的请求
第一,只重试幂等请求。读请求天然幂等;写请求必须带幂等键 + 下游去重表,否则"网络抖一下"就是两张订单。
第二,只重试特定错误码。gRPC 里通常只有
UNAVAILABLE(14) 和连接失败值得重试;INVALID_ARGUMENT、业务失败无论如何重试都不会变成功,纯属浪费。第三,
DEADLINE_EXCEEDED 要极度谨慎——它意味着"我不知道下游有没有执行",正是 2.2 节说的「未知」,重试前必须确认接口幂等。4.2 铁律二:退避必须带抖动
指数退避是标配,但只有退避没有抖动,会产生重试风暴:下游在某一瞬间恢复,所有调用方的退避定时器恰好同时到期,新一轮流量整齐地打上去,刚恢复的服务二次被打挂。
func backoff(attempt int, base, max time.Duration) time.Duration {
d := base << (attempt - 1) // ① 指数增长
if d > max { d = max } // ② 封顶,避免无限增长
jitter := time.Duration(rand.Int63n(int64(d))) // ③ 抖动:把重试时间点打散
return d + jitter
}
4.3 铁律三:用"重试预算"而不是"重试次数"
这是区分「背过」和「做过」的分水岭。固定重试 3 次意味着下游整体故障时,你的流量会放大到 4 倍;而重试预算是按比例控制的:只允许一小部分请求(比如 10%)发起重试,超了就停。
4.4 Go:把三条铁律写成可运行的客户端
下面的程序把「舱壁隔离 + 预算感知的重试 + 抖动退避 + 降级兜底」串成一条完整链路,直接 go run 可见效果:一个 70% 故障率的下游,被硬性收敛而不是拖垮。
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"time"
)
var (
ErrUnavailable = errors.New("order: unavailable") // 可重试:下游抖动
ErrBadParam = errors.New("order: invalid param") // 不可重试:参数就是错的
)
// remoteOrderService 模拟订单服务:处理耗时抖动 + 随机故障
func remoteOrderService() error {
time.Sleep(time.Duration(20+rand.Intn(120)) * time.Millisecond)
switch rand.Intn(10) {
case 0, 1, 2:
return nil // 30% 成功
case 3:
return ErrBadParam // 10% 不可重试错误
default:
return ErrUnavailable // 60% 可重试错误
}
}
// Result 调用结果:降级必须能被感知,不能假装成功
type Result struct {
ReqID string
Accepted bool // 是否被受理
Degraded bool // 是否走了兜底(数据可能不是终态)
Err error
}
type OrderClient struct {
sem chan struct{} // 舱壁:并发配额
maxAttempt int
base time.Duration
maxBackoff time.Duration
budget chan struct{} // 重试预算:按速率补充令牌
}
func NewOrderClient(concurrency, maxAttempt int, base time.Duration, retryRPS int) *OrderClient {
c := &OrderClient{
sem: make(chan struct{}, concurrency),
maxAttempt: maxAttempt,
base: base,
maxBackoff: 2 * time.Second,
budget: make(chan struct{}, 3),
}
// 后台按固定速率补充重试令牌(生产环境建议按请求量做百分比控制)
go func() {
ticker := time.NewTicker(time.Second / time.Duration(retryRPS))
defer ticker.Stop()
for range ticker.C {
select {
case c.budget <- struct{}{}:
default: // 令牌满了就丢弃,预算不做累积
}
}
}()
return c
}
// callWithDeadline 把调用挂到 ctx 上:ctx 一取消,调用立刻失效
func callWithDeadline(ctx context.Context) error {
done := make(chan error, 1)
go func() { done <- remoteOrderService() }()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err() // 真实 gRPC 客户端直接支持 ctx,不需要这层封装
}
}
func (c *OrderClient) backoff(attempt int) time.Duration {
d := c.base << (attempt - 1)
if d > c.maxBackoff {
d = c.maxBackoff
}
jitter := time.Duration(rand.Int63n(int64(c.base))) // 抖动:打散重试时刻
return d + jitter
}
func (c *OrderClient) CreateOrder(ctx context.Context, reqID string) Result {
// ① 舱壁:拿不到并发配额就立刻失败,绝不排队积压(等待也是一种失败)
select {
case c.sem <- struct{}{}:
defer func() { <-c.sem }()
default:
return Result{ReqID: reqID, Err: errors.New("bulkhead rejected: 并发配额已满")}
}
var err error
Loop:
for attempt := 1; attempt <= c.maxAttempt; attempt++ {
err = callWithDeadline(ctx)
if err == nil {
return Result{ReqID: reqID, Accepted: true}
}
// ② 不可重试的错误立即返回,别浪费预算
if !errors.Is(err, ErrUnavailable) {
return Result{ReqID: reqID, Err: fmt.Errorf("不可重试错误: %w", err)}
}
wait := c.backoff(attempt)
// ③ 预算感知:剩余时间不够等这一次退避,主动放弃
if d, ok := ctx.Deadline(); ok && time.Until(d) < wait {
fmt.Printf("[retry] %s 剩余预算 %v 不足以再等 %v,放弃重试\n", reqID, time.Until(d), wait)
break Loop
}
// ④ 重试预算:令牌拿不到就停止重试
if attempt > 1 {
select {
case <-c.budget:
default:
fmt.Printf("[retry] %s 重试预算耗尽,停止重试\n", reqID)
break Loop
}
}
select {
case <-time.After(wait):
case <-ctx.Done():
err = ctx.Err()
break Loop
}
}
// ⑤ 兜底:返回「受理中」,后续由异步链路收敛(详见第七节)
fmt.Printf("[fallback] %s 降级受理,等待异步确认\n", reqID)
return Result{ReqID: reqID, Accepted: true, Degraded: true}
}
func main() {
client := NewOrderClient( /* 并发配额 */ 8, /* 最大重试 */ 3, /* base */ 50*time.Millisecond, /* 重试RPS */ 5)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
reqID := fmt.Sprintf("REQ-%03d", i)
// 每跳只在最外层设一次预算,后续调用全部继承这个 ctx
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
res := client.CreateOrder(ctx, reqID)
fmt.Printf("%s accepted=%v degraded=%v err=%v\n", res.ReqID, res.Accepted, res.Degraded, res.Err)
}(i)
}
wg.Wait()
}
运行后你会看到三种典型输出:degraded=true(预算耗尽转为异步受理)、bulkhead rejected(被隔离挡住)、以及 不可重试错误(一眼识别出参数错误,一次都没重试)。
①
callWithDeadline 这种"用 goroutine 包一层 + select"的写法,超时后那个 goroutine 会泄漏到调用结束为止——生产环境中 gRPC / http.Client 本身就吃 ctx,直接传进去就行,别自己包。② HTTP 侧的
http.Transport 默认 MaxIdleConnsPerHost = 2,高并发下会疯狂建 TCP 连接并留下满屏 TIME_WAIT,必须显式调大:Transport: &http.Transport{MaxIdleConnsPerHost: 100, MaxConnsPerHost: 200}。③ gRPC 的
ClientConn 要长生命周期复用(它自带重连与健康检查),绝对不要每次请求新建一个连接。4.5 进阶加分项:对冲请求(hedged request)
重试是"失败后再来一次",对冲是"还没失败就同时多路发":请求发出后如果超过 P95 延迟仍未返回,不等超时,直接向另一个副本再发一个相同的请求,谁先回来用谁,另一个取消。Bigtable、DynamoDB 都在用,gRPC 也原生支持(service config 的 hedgingPolicy)。
两条边界:只对幂等读(对冲 = 必然多发流量,写请求直接出局);限制在途对冲数量(比如每个请求最多对冲一次),否则等于给下游永久双倍流量——P99 是救回来了,容量先炸了。
五、第三道防线:熔断与隔离
重试解决的是偶发抖动,熔断解决的是持续故障。区别在于:重试对每一个失败都投入一次资源;熔断的目标是"既然已经确定它坏了,就别再折腾它了"。
5.1 三态状态机
stateDiagram-v2
[*] --> Closed
Closed --> Open: 错误率超阈值
Open --> HalfOpen: 冷却时间到
HalfOpen --> Closed: 探测成功
HalfOpen --> Open: 探测仍失败
note right of Closed
统计滑动窗口内的失败率
正常放行所有请求
end note
note right of Open
直接返回错误,不再发出请求
给下游留出恢复时间窗
end note
note right of HalfOpen
放行少量请求试探
按成功比例逐步恢复
end note5.2 四个容易答错的细节
② 统计口径要和层序配套。拦截器顺序不同,语义完全不同:熔断在重试外层(超时 → 熔断 → 重试)时统计的是"重试后的最终结果",重试成功不算失败;熔断在重试内层(超时 → 重试 → 熔断)时每次尝试都被计数,熔断开得更快,但一次"失败后重试成功"也会留下失败记录。两种都能用,关键是别让重试成功的请求把失败率撑高到误开熔断。
③ 粒度是「下游 + 接口」而不是全局。库存服务挂了,不该让查商品详情也被熔断。
④ 半开不要瞬间全开。恢复瞬间放全量等于二次冲击,应该按比例逐步放量。也可以直接用 go-zero 那种基于成功率的自适应熔断,动态计算丢弃概率,比固定阈值三态平滑得多。
Go 生态里常用的是 sony/gobreaker、go-zero 的自适应熔断、Sentinel-Go(hystrix-go 已停止维护,别在简历上写)。
var cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "order-service",
MaxRequests: 5, // 半开状态下放行的探测请求数
Interval: 10 * time.Second, // Closed 状态下计数器清零周期
Timeout: 30 * time.Second, // Open 持续时间
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.Requests >= 20 && float64(counts.Failures)/float64(counts.Requests) > 0.5
},
})
func createOrder(ctx context.Context, req Req) (Resp, error) {
r, err := cb.Execute(func() (interface{}, error) { return orderClient.Create(ctx, req) })
if err != nil {
return fallback(req) // 熔断/失败都要有兜底,见下一节
}
return r.(Resp), nil
}
如果你在用 kratos,这一层不用自己拼:客户端中间件链里有现成的 recovery / circuitbreaker / ratelimit,selector 层还支持自定义过滤器——把连续失败的节点直接摘出负载均衡,相当于熔断的节点级形态。
5.3 舱壁隔离:雪崩真正的传导路径
很多人以为雪崩是"下游挂了 → 上游报错",其实真正的传导路径是:下游变慢 → 上游连接池被占满 → 上游连健康的下游也调不动了。所以必须给每个下游独立的资源配额(连接池 / 信号量 / goroutine 池)——这正是第四节代码里 sem 那一段在做的事。
还有一半别忘了:被调用方自己的自我保护——服务端限流(golang.org/x/time/rate,或 kratos / go-zero 内置的 ratelimit 中间件)。调用方做得再好,也挡不住上游突发流量砸过来;限流是"被打的人"最后的话语权,它把过载从「雪崩」变成「可控的丢弃 + 明确的 429」。
六、第四道防线:兜底与降级
分级 fallback 的顺序是固定的:主调用 → 备用链路(另一可用区 / 另一实现)→ 过期缓存 → 静态默认值。三条注意点:
关于数据不准:缓存兜底必须携带降级标记。返回 30 分钟前的库存数,前端要能显示"数据可能不是最新的",否则用户按脏数据下单,后面全是客诉。
还有一条铁律:写请求没有本地兜底。用户服务调订单创建失败,绝不能在本地插一条假装成功;正确做法是返回"处理中"+ 流水号,靠后面的异步链路收敛。
另外,必须有人工开关:在配置中心放一个降级开关,能一键跳过某个非核心依赖(风控、推荐、积分)。这是大促保命的最后一招。
七、第五道防线:最终一致
到这里才是真正难的部分。核心结论先给:优先消灭分布式事务,实在消灭不了才用。 详细投递侧实现可以对照本站《海量并发下分布式事务一致性 》,这里讲它与调用侧的接口。
7.1 为什么必须是 Outbox
| 写法 | 后果 |
|---|---|
| 先写库,再发 MQ | MQ 发送失败或进程崩了 → 数据已改、消息永久丢失 |
| 先发 MQ,再写库 | MQ 成功但本地事务回滚 → 无中生有的订单,比丢消息更难查 |
| 把发 MQ 塞进本地事务里 | 事务持有行锁期间等网络,事务时长翻十倍;MQ 超时回滚本身也不确定 |
正解是:把消息当成业务数据先存下来(outbox 表,与业务表同一本地事务),再由「另一个事务」负责发出去。发出失败就重试——本地事务成功 ⇒ 消息必达。
sequenceDiagram
participant U as 用户服务
participant DB as 业务库(orders + outbox)
participant MQ as 消息队列
participant O as 订单服务
U->>DB: ① 本地事务:写订单 + 写 outbox
Note over DB: 同库同事务,要么都在,要么都不在
DB->>MQ: ② 投递协程 / CDC 发送未投递记录
MQ->>O: ③ 至少一次投递
O->>O: ④ 幂等去重 + 状态机 CAS
O->>MQ: ⑤ 手动 Ack
Note over U,O: ⑥ 兜底:定时任务扫描长时间未进终态的流水7.2 消费端幂等:不要先查再执行
SELECT 查重、发现不存在再 INSERT 执行——两条消息同时查出"不存在",然后都执行了,去重完全失效。正确做法是「在同一个事务里 INSERT 去重表 + 执行业务」,靠唯一索引冲突回滚来保证互斥:
CREATE TABLE consume_dedup (
biz_key VARCHAR(64) PRIMARY KEY, -- request_id / order_no
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
// 幂等消费(示意节选,省略 SQL 错误分类与 Msg 定义)
func Consume(tx *sql.Tx, msg Msg) error {
// ① 尝试占用去重槽位:重复消息会在这里撞唯一索引
_, err := tx.Exec(`INSERT INTO consume_dedup (biz_key) VALUES (?)`, msg.BizKey)
if err != nil {
if isDuplicateKey(err) {
return nil // 重复投递,直接跳过(幂等成功)
}
return err
}
// ② 状态机 CAS:只放行 pending → paid 这一条迁移路径
res, err := tx.Exec(`UPDATE orders SET status='paid' WHERE order_no=? AND status='pending'`, msg.BizKey)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
// 注意:走到这里说明订单已经不在 pending,但**不一定是重复消费**——
// 也可能是非法状态迁移(比如订单还停在 created,消息却要求置 paid)。
// 重复消费可以静默成功;非法迁移必须返回错误并告警,
// 否则流水永远停在中间态,只能等对账任务来捞。
return fmt.Errorf("order %s 状态非法迁移,需人工介入", msg.BizKey)
}
return nil
}
如果想轻量些可以用 Redis SETNX,但要记得设过期时间,且Redis 不可用时能降级回 DB 兜底——否则你的幂等机制自己成了单点。
7.3 什么时候才轮到 Saga / TCC
| 方案 | 一致性 | 侵入成本 | 适用场景 |
|---|---|---|---|
| Outbox + 消息 | 最终 | 低(多一张表) | 首选,覆盖 90% 场景 |
| Saga(编排 / 协同) | 最终 | 中(每步都要写补偿) | 步骤多且有回滚语义:取消订单要退券、退库存、退款 |
| TCC | 准强一致(有隔离性) | 高(Try/Confirm/Cancel 三个接口) | 资金敏感且需资源预留:账户余额、保证金 |
| 2PC / XA | 强 | 侵入小但成本高 | 高并发场景基本不用:锁持有时间长、协调者单点 |
用了 Saga / TCC,就必须能答出这三个坑:
② 悬挂(Cancel 比 Try 先到 → 迟到的 Try 必须被拒绝)
③ 幂等(Confirm / Cancel 都可能重复投递,自身必须幂等)
答不出这三条,面试官基本判定你只背过概念。
7.4 最后一道防线:对账(反熵)
任何消息链路都有小概率不一致:网络、代码 bug、灰度空窗。所以必须有定时任务扫描「停留在中间态超过 N 分钟」的流水,自动补单或告警人工处理。
八、把五道防线拼起来:用户模块 → 订单模块
创建订单这条链路的推荐形态:
- 入口生成
order_no(幂等键),随 gRPC metadata 透传。 - 800ms 预算由网关定死,每一跳只花掉自己的部分。
- 订单服务本地事务写入「orders + outbox」,返回**「受理中」而非最终成功**。
- 库存 / 积分通过消息消费各自完成,状态机推进到终态。
- 前端轮询或 WebSocket 推最终结果。
- 兜底由「超时未终态」的定时任务负责。
这样即使订单服务在写库后抖动,用户重放同一个 order_no 也不会产生两张单。
九、坑点清单(面试速查)
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | 每层独立设超时 | 总等待失控,重试后翻倍 | 预算逐层递减 + ctx deadline 透传 |
| 2 | 忘写 defer cancel() | timer / goroutine 泄漏 | WithTimeout 必配对 cancel |
| 3 | 无脑重试所有错误 | 一次抖动放大成雪崩 | 限定错误码 + 退避抖动 + 重试预算 |
| 4 | 重试非幂等写 | 重复下单、重复扣款 | 幂等键 + 下游唯一去重 |
| 5 | 超时就重试 | 请求其实已成功 | 依赖幂等,别盲目重放 |
| 6 | goroutine 里用 context.Background() | 链路追踪断裂、无法取消 | 透传 ctx,火后即忘场景用 context.WithoutCancel(Go 1.21+,保住 trace 又脱离取消);MQ 从消息头还原 traceID |
| 7 | HTTP 连接池用默认值 | TIME_WAIT 打满 | 调大 MaxIdleConnsPerHost(默认仅 2) |
| 8 | 所有下游共享连接池 | 一个慢依赖拖死全部调用 | 舱壁:独立池 + 并发上限 |
| 9 | 熔断没有 fallback | 熔断变成了"更快的失败" | 熔断必须配降级逻辑 |
| 10 | 半开瞬间全开 | 恢复即二次打挂 | 逐步放量或自适应熔断 |
| 11 | 写请求本地兜底 | 数据双写不一致 | 返回"处理中" + 异步收敛 |
| 12 | 只监控"服务是否存活" | 慢故障完全监控不到 | Google SRE 四大黄金信号:延迟(P99)、错误率、流量、饱和度 |
十、自测题与动手练习
下面几道题专门用来检验你是"真懂"还是"只会背"。
难在"未知"没法判断状态:失败可以安全地重试(下游明确没执行),而超时可能是"已经执行了但响应丢了"。所以处理未知只能靠幂等键 + 下游去重,而不是简单重试——这就是重复下单、重复扣款的根因。
正确做法是超时预算瀑布:最外层定总预算(如 800ms),每跳先扣掉自己的本地开销,把「剩余 deadline」通过
ctx 传给下游,并留 10%~20% 的缓冲给网络。重试预算管的是整体:固定重试 3 次意味着下游整体故障时你的流量放大到 4 倍;而按令牌桶限制"只允许 10% 的请求发起重试",能在下游持续故障时自动收敛。前者管单个请求别太轴,后者管整体别添乱。
加分项:对冲请求(hedged request)——延迟超过 P95 还没返回就向另一个副本再发一个,谁先回用谁;只对幂等读、必须限制在途对冲数量,否则等于双倍流量。
另外三条要检查:统计口径和层序是否配套(熔断在重试外层统计最终结果、在内层统计每次尝试,别让重试成功把失败率撑高到误开熔断);粒度是不是按"下游+接口"(全局熔断会误伤健康接口);半开是不是瞬间全开(造成二次冲击)。
正解是 Outbox:业务数据与消息在同一本地事务落一张
outbox 表,再由投递协程或 binlog CDC 发出去,失败就重试——本地事务成功 ⇒ 消息必达。消费侧用「INSERT 去重表 + 业务」同事务保证幂等(而不是先查再插);CAS 更新影响行数为 0 时要区分"重复消费"和"非法状态迁移"(前者静默成功、后者必须告警);最后用定时对账扫描长时间未进终态的流水兜底。动手练习(建议真做一遍):
- 把第四节的程序跑起来,把下游失败率改成 95%,观察输出里
重试预算耗尽与bulkhead rejected的比例如何变化——把maxAttempt改成 10,看看"次数限制"和"预算限制"哪个真正在起作用。 - 故意注释掉
defer cancel(),用go run -race加 pprof 观察 goroutine 数变化,体会"泄漏"是怎么发生的。 - 把 7.2 节的幂等消费改成"先
SELECT查重再INSERT",用并发压一下,亲眼看看会重复写入几次。
十一、本章小结
- 跨服务调用只有三种结果:成功、失败、未知。绝大多数事故是把"未知"当"失败"去重试试出来的——这是所有幂等设计的源头。
- 前四道防线管的是"失败的形态":超时预算(别白等)、退避抖动 + 重试预算(别添乱)、熔断隔离(控半径)、降级兜底(给结果)。目标是把"挂死"变成"快速失败 + 明确降级"。
- 第五道防线管的是"失败后的收敛":Outbox 本地消息表 + 至少一次投递 + 消费幂等 + 定时对账。记住一句话——没有对账的最终一致等于薛定谔的一致。
- 一句话主线:我们消除不了失败,只能控制失败的半径(隔离 / 熔断)、失败的形态(快速失败而非挂死),以及失败后的收敛手段(幂等 + 异步补偿 + 对账)。
- 下一篇可以往库存扣减的锁选型(悲观锁
SELECT ... FOR UPDATEvs 乐观锁 CAS,见《亿级商品存储下,分布式系统的原理性问题 》)或者 grpc-go 拦截器链与 OpenTelemetry trace 透传深挖,它们都是本文这条链路上的具体实现细节。