Go 微服务调用可靠性:从超时重试到最终一致

2026-09-12T02:45:00+08:00 | 23分钟阅读 | 更新于 2026-09-12T02:45:00+08:00

@

学习目标

学完本文,你应该能够:

  1. 一句话说清为什么"故障"不是二选一:跨服务调用有成功、失败、**未知(超时)**三种结果,绝大多数事故是把"未知"当成"失败"去重试试出来的。
  2. 讲清超时预算(timeout budget)该怎么分配,并能当场指出"每一跳各自写 WithTimeout(5s)“这个反模式为什么会造成雪崩。
  3. 说出重试的三条铁律:只重试幂等请求与特定错误码、退避必须带抖动、重试预算(令牌桶)优于重试次数。
  4. 用 Go 跑通一条完整的客户端防线:舱壁隔离 + 预算感知的重试 + 降级兜底,看到"剩余预算不足以再等一次退避"时程序如何主动放弃。
  5. 把最终的落脚点接到数据一致性上:Outbox 本地消息表 + 至少一次投递 + 消费幂等 + 定时对账,并说清 Saga/TCC 为什么要用、以及空补偿/悬挂/幂等三坑。

前置知识:写过 Go 的 context(至少知道 WithTimeout 配 defer cancel());知道 gRPC 或 HTTP 怎么发请求;了解「本站《海量并发下分布式事务一致性 》」里讲过的本地消息表与手动 Ack(本文第五道防线与它是同源方案,本文讲调用侧,那篇讲投递侧,建议对照读)。

本章你会动手做的事:

  • 跑通一个带「舱壁 + 抖动退避 + 重试预算 + 降级」的 Go 客户端,用 70% 故障率的下游观察它如何收敛而不是雪崩。
  • 手写一个幂等消费的 SQL 范式(INSERT 去重表 + UPDATE 状态机 CAS),理解为什么先查重再执行一定会出现竞态。
  • 画一张"五道防线"考点脑图(见下方),把整条链路串成一张网。

一、考点脑图

先给整篇文章一张总览图。这道题考的不是某个中间件的 API,而是你怎么按顺序布防线、以及每一行 defense code 背后的取舍。

跨服务调用可靠性① 预算与准入幂等键由上游生成超时预算逐层递减② 超时与重试ctx deadline 透传退避+抖动+重试预算③ 熔断与隔离三态机 + 半开探测舱壁:独立并发配额④ 兜底与降级缓存兜底必须带标记写请求绝不本地兜底⑤ 最终一致Outbox 本地消息表幂等消费 + 对账兜底

微服务调用链可靠性架构总图

Client 调用方最上游生成幂等键
<rect x="250" y="260" width="180" height="72" rx="8"/>
<text x="340" y="288" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">注册中心 / 服务发现</text>
<text x="340" y="310" text-anchor="middle" fill="#666" font-size="11" stroke="none">拉取健康实例</text>
<rect x="470" y="260" width="170" height="72" rx="8"/>
<text x="555" y="288" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">负载均衡</text>
<text x="555" y="310" text-anchor="middle" fill="#666" font-size="11" stroke="none">selector 选节点</text>
<rect x="680" y="260" width="210" height="72" rx="8"/>
<text x="785" y="288" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">网关 / 服务端</text>
<text x="785" y="310" text-anchor="middle" fill="#666" font-size="11" stroke="none">执行业务 + 自我保护</text>
<!-- 控制框 -->
<rect x="40" y="60" width="200" height="64" rx="8"/>
<text x="140" y="86" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">① 超时控制</text>
<text x="140" y="106" text-anchor="middle" fill="#666" font-size="11" stroke="none">预算逐层递减 / ctx 透传</text>
<rect x="280" y="60" width="210" height="64" rx="8"/>
<text x="385" y="86" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">② 重试 + 重试预算</text>
<text x="385" y="106" text-anchor="middle" fill="#B03030" font-size="11" font-weight="700" stroke="none">放大风险:下游故障 ×N</text>
<rect x="520" y="44" width="250" height="104" rx="8" stroke-width="3"/>
<text x="645" y="66" text-anchor="middle" fill="#B03030" font-size="13" font-weight="700" stroke="none">③ 熔断(三态状态机)</text>
<rect x="532" y="78" width="68" height="28" rx="5"/>
<text x="566" y="97" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">Closed</text>
<rect x="620" y="78" width="68" height="28" rx="5"/>
<text x="654" y="97" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">Open</text>
<rect x="602" y="116" width="96" height="24" rx="5"/>
<text x="650" y="132" text-anchor="middle" fill="#1a1a1a" font-size="10.5" stroke="none">Half-Open</text>
<rect x="790" y="60" width="180" height="64" rx="8"/>
<text x="880" y="86" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">④ 服务端限流</text>
<text x="880" y="106" text-anchor="middle" fill="#666" font-size="11" stroke="none">429 可控丢弃</text>
<!-- 底部 -->
<rect x="680" y="400" width="210" height="64" rx="8"/>
<text x="785" y="424" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">⑤ 降级兜底</text>
<text x="785" y="444" text-anchor="middle" fill="#666" font-size="11" stroke="none">写请求返回受理中,不本地兜底</text>
<rect x="30" y="410" width="430" height="80" rx="8" stroke-dasharray="7 4"/>
<text x="245" y="436" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">最终一致:Outbox 本地消息表</text>
<text x="245" y="458" text-anchor="middle" fill="#666" font-size="11" stroke="none">至少一次投递 → 幂等消费 → 定时对账(反熵)</text>
<text x="245" y="478" text-anchor="middle" fill="#666" font-size="11" stroke="none">虚线 = 异步补偿链路</text>
发现实例选节点RPC 调用Closed→Open(超阈值)→Half-Open(冷却)→Closed(探测成功·回边)降级结果带标记透传

看图要点:这张图把"五道防线"挂到了一条调用链上。面试官会追问:超时预算为什么不能每层各自 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)
复习提示:为什么不能让每个服务自己生成? 幂等键的作用是"识别同一次业务尝试"。如果每个服务各生成各的,调用方重试时下游看到的是两个不同的 key,去重完全失效——工作量做了一倍,防护效果是零。

3.2 超时预算必须逐层递减

这是最经典的坑:网关 5s、用户服务 5s、订单服务 5s、库存 5s,四个 5s 叠起来最坏情况要等 20s,而入口早就超时了——中间几个服务白等、白占连接。正确做法是「预算瀑布」:每一跳先把自己的开销扣掉,剩下的才交给下游。

超时预算瀑布:每一跳只把自己开销之后的「剩余量」交出去本跳自身消耗留给下游的剩余预算网络与安全缓冲入口总预算 800ms用户服务本地逻辑 250ms剩余 550ms订单服务本地 150ms剩余 400ms库存 / DB本地 + 网络 300ms缓冲 100ms反模式:每一跳各自 WithTimeout(5s) —— 总等待时间失控,一旦叠加重试会成倍放大

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。

复习提示:注意:只有 gRPC 链路有这种自动透传。HTTP / REST 的 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(被隔离挡住)、以及 不可重试错误(一眼识别出参数错误,一次都没重试)。

复习提示:三个 Go 专属坑:
① 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 note

5.2 四个容易答错的细节

复习提示:① 熔断必须配 fallback。没有兜底的熔断只是"把一个错误换成另一个错误",用户体感反而更差(连等待都没有了,瞬间失败)。熔断真正的价值是给降级逻辑让出时间窗。
② 统计口径要和层序配套。拦截器顺序不同,语义完全不同:熔断在重试外层(超时 → 熔断 → 重试)时统计的是"重试后的最终结果",重试成功不算失败;熔断在重试内层(超时 → 重试 → 熔断)时每次尝试都被计数,熔断开得更快,但一次"失败后重试成功"也会留下失败记录。两种都能用,关键是别让重试成功的请求把失败率撑高到误开熔断。
③ 粒度是「下游 + 接口」而不是全局。库存服务挂了,不该让查商品详情也被熔断。
④ 半开不要瞬间全开。恢复瞬间放全量等于二次冲击,应该按比例逐步放量。也可以直接用 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

写法后果
先写库,再发 MQMQ 发送失败或进程崩了 → 数据已改、消息永久丢失
先发 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 消费端幂等:不要先查再执行

复习提示:新手必踩的坑:check-then-act 竞态。先 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 到达时正向还没执行 → 要识别并直接返回成功)
② 悬挂(Cancel 比 Try 先到 → 迟到的 Try 必须被拒绝)
③ 幂等(Confirm / Cancel 都可能重复投递,自身必须幂等)
答不出这三条,面试官基本判定你只背过概念。

7.4 最后一道防线:对账(反熵)

任何消息链路都有小概率不一致:网络、代码 bug、灰度空窗。所以必须有定时任务扫描「停留在中间态超过 N 分钟」的流水,自动补单或告警人工处理。

复习提示:没有对账的最终一致 = 薛定谔的一致。 你可以没有 TCC,但不能没有对账——这是面试里最容易漏、也最能体现"真做过"的一句话。

八、把五道防线拼起来:用户模块 → 订单模块

创建订单这条链路的推荐形态:

  1. 入口生成 order_no(幂等键),随 gRPC metadata 透传。
  2. 800ms 预算由网关定死,每一跳只花掉自己的部分。
  3. 订单服务本地事务写入「orders + outbox」,返回**「受理中」而非最终成功**。
  4. 库存 / 积分通过消息消费各自完成,状态机推进到终态。
  5. 前端轮询或 WebSocket 推最终结果。
  6. 兜底由「超时未终态」的定时任务负责。

这样即使订单服务在写库后抖动,用户重放同一个 order_no 也不会产生两张单。


九、坑点清单(面试速查)

#坑现象解法
1每层独立设超时总等待失控,重试后翻倍预算逐层递减 + ctx deadline 透传
2忘写 defer cancel()timer / goroutine 泄漏WithTimeout 必配对 cancel
3无脑重试所有错误一次抖动放大成雪崩限定错误码 + 退避抖动 + 重试预算
4重试非幂等写重复下单、重复扣款幂等键 + 下游唯一去重
5超时就重试请求其实已成功依赖幂等,别盲目重放
6goroutine 里用 context.Background()链路追踪断裂、无法取消透传 ctx,火后即忘场景用 context.WithoutCancel(Go 1.21+,保住 trace 又脱离取消);MQ 从消息头还原 traceID
7HTTP 连接池用默认值TIME_WAIT 打满调大 MaxIdleConnsPerHost(默认仅 2)
8所有下游共享连接池一个慢依赖拖死全部调用舱壁:独立池 + 并发上限
9熔断没有 fallback熔断变成了"更快的失败"熔断必须配降级逻辑
10半开瞬间全开恢复即二次打挂逐步放量或自适应熔断
11写请求本地兜底数据双写不一致返回"处理中" + 异步收敛
12只监控"服务是否存活"慢故障完全监控不到Google SRE 四大黄金信号:延迟(P99)、错误率、流量、饱和度

十、自测题与动手练习

下面几道题专门用来检验你是"真懂"还是"只会背"。

面试官
跨服务调用有几种结果?为什么说"未知"最难处理?
候选人
三种:成功、失败、未知(超时)。
难在"未知"没法判断状态:失败可以安全地重试(下游明确没执行),而超时可能是"已经执行了但响应丢了"。所以处理未知只能靠幂等键 + 下游去重,而不是简单重试——这就是重复下单、重复扣款的根因。
面试官
为什么"每一跳各自 WithTimeout(5s)“是反模式?正确做法是什么?
候选人
因为层层叠加后总等待时间失控:4 跳 × 5s 最坏要等 20s,而入口早就超时了,中间几层全在白等、白占连接;一旦叠加重试还会成倍放大。
正确做法是超时预算瀑布:最外层定总预算(如 800ms),每跳先扣掉自己的本地开销,把「剩余 deadline」通过 ctx 传给下游,并留 10%~20% 的缓冲给网络。
面试官
重试为什么必须带抖动?为什么"重试预算"比"重试次数"更好?
候选人
抖动是为了打散重试时刻:只有指数退避没有抖动,下游恢复的瞬间所有调用方的定时器会同时到期,新一轮流量整齐地打上去,把刚恢复的服务二次打挂(重试风暴)。
重试预算管的是整体:固定重试 3 次意味着下游整体故障时你的流量放大到 4 倍;而按令牌桶限制"只允许 10% 的请求发起重试",能在下游持续故障时自动收敛。前者管单个请求别太轴,后者管整体别添乱。
加分项:对冲请求(hedged request)——延迟超过 P95 还没返回就向另一个副本再发一个,谁先回用谁;只对幂等读、必须限制在途对冲数量,否则等于双倍流量。
面试官
熔断配了以后线上反而报错更多了,问题出在哪?
候选人
大概率是没有配 fallback——熔断只是把"慢速失败"换成了"快速失败",如果熔断分支直接返回一个错误,用户体感只会更差。
另外三条要检查:统计口径和层序是否配套(熔断在重试外层统计最终结果、在内层统计每次尝试,别让重试成功把失败率撑高到误开熔断);粒度是不是按"下游+接口"(全局熔断会误伤健康接口);半开是不是瞬间全开(造成二次冲击)。
面试官
写服务时同时发 MQ,为什么一定会出问题?怎么兜底?
候选人
因为两者不在同一个事务里:先写库后发 MQ,MQ 失败则消息永久丢失;先发 MQ 后写库,本地回滚则无中生有;把发 MQ 塞进本地事务则会长时间持锁拖垮性能。
正解是 Outbox:业务数据与消息在同一本地事务落一张 outbox 表,再由投递协程或 binlog CDC 发出去,失败就重试——本地事务成功 ⇒ 消息必达。消费侧用「INSERT 去重表 + 业务」同事务保证幂等(而不是先查再插);CAS 更新影响行数为 0 时要区分"重复消费"和"非法状态迁移"(前者静默成功、后者必须告警);最后用定时对账扫描长时间未进终态的流水兜底。

动手练习(建议真做一遍):

  1. 把第四节的程序跑起来,把下游失败率改成 95%,观察输出里 重试预算耗尽 与 bulkhead rejected 的比例如何变化——把 maxAttempt 改成 10,看看"次数限制"和"预算限制"哪个真正在起作用。
  2. 故意注释掉 defer cancel(),用 go run -race 加 pprof 观察 goroutine 数变化,体会"泄漏"是怎么发生的。
  3. 把 7.2 节的幂等消费改成"先 SELECT 查重再 INSERT",用并发压一下,亲眼看看会重复写入几次。

十一、本章小结

复习提示:
  • 跨服务调用只有三种结果:成功、失败、未知。绝大多数事故是把"未知"当"失败"去重试试出来的——这是所有幂等设计的源头。
  • 前四道防线管的是"失败的形态":超时预算(别白等)、退避抖动 + 重试预算(别添乱)、熔断隔离(控半径)、降级兜底(给结果)。目标是把"挂死"变成"快速失败 + 明确降级"。
  • 第五道防线管的是"失败后的收敛":Outbox 本地消息表 + 至少一次投递 + 消费幂等 + 定时对账。记住一句话——没有对账的最终一致等于薛定谔的一致。
  • 一句话主线:我们消除不了失败,只能控制失败的半径(隔离 / 熔断)、失败的形态(快速失败而非挂死),以及失败后的收敛手段(幂等 + 异步补偿 + 对账)。
  • 下一篇可以往库存扣减的锁选型(悲观锁 SELECT ... FOR UPDATE vs 乐观锁 CAS,见《亿级商品存储下,分布式系统的原理性问题 》)或者 grpc-go 拦截器链与 OpenTelemetry trace 透传深挖,它们都是本文这条链路上的具体实现细节。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!