1. 重试机制:微服务架构里最容易忽略的“保命符”
先说个我自己的真实经历。前几年在一家中等规模的电商公司做 Go 后端,订单服务调用支付网关的时候,偶尔会因为网络抖动或者对端连接池打满直接报错。最开始大家都没当回事,认为失败就失败,大不了让用户重新点一次“支付”按钮。结果有一次大促,瞬时流量上来之后,支付网关有一个节点做重启发布,整个下单链路直接雪崩——订单服务疯狂报错、消费者端不断弹出“支付失败”、客服群被刷屏。后来排查下来才发现,问题出在两个地方:一是服务之间调用没有重试机制,失败即返回;二是即便有重试的代码,也写成了一个固定间隔的 for 循环,直接把对端压得更死。
那之后我就把“重试机制”从“锦上添花”抬到了“高可用基础设施”的位置上。如果你也在写 Go 微服务,或者你正在从单体架构往微服务架构迁移,重试(Retry)这件事,值得花一张图的时间把它彻底搞清楚。
所谓重试机制,简单来说就是:当请求在传输过程中遇到临时性故障(如超时、网络闪断、对端 5xx 错误等),客户端自动重新发起请求,直到请求成功或者达到最大重试次数为止。注意我这里特意强调了“临时性故障”——因为并不是所有的失败都适合重试。比如参数校验失败这类 4xx 错误,重试一百次结果也是一样的,属于客户端自身的问题;而 5xx、网络超时这类错误,大概率是服务端暂时扛不住或者网络出了状况,等待片刻再试一次,成功率往往会显著提升。
用一个生活化的类比来理解:你打电话给客服,第一次占线,你会马上挂了再打一次?不会。你通常会等几秒再拨。如果连续拨了三次都占线,你大概会判断客服线路出问题了,改为线上留言或者过半小时再试。重试机制本质上就是把这套“人肉容错逻辑”程序化、自动化。
适用人群和场景也很明确:你正在做 Go 微服务开发,或者你要处理 RPC 调用、HTTP API 调用、消息队列消费这类场景,那么重试机制是你绕不开的功课。尤其是公司里微服务节点多、链路长的时候,一个服务不稳定,如果所有调用方都同时疯狂重试,整个系统会瞬间变成“重试风暴”,比不重试还要危险。这篇文章我会从原理、策略、Go 代码实现到生产环境踩坑,完整梳理一遍,尽量让读者看完就能在自己的项目里落地。
2. 重试到底解决什么问题?哪些错误才值得重试?
2.1 临时性故障 vs 永久性故障,必须先分清
很多刚接触重试的同学容易犯一个错误:拿到一个 error 就觉得应该重试。结果把业务逻辑错误(比如“余额不足”“订单已关闭”)也重试了好几遍,白白浪费资源不说,还会加剧数据库压力,甚至因为重复执行导致脏数据。
所以在设计重试之前,第一步要做的不是写代码,而是给错误“分类”。在 Go 里,我习惯的做法是定义一批“可重试错误标记”:
package errs import "errors" // Retryable 标记错误是否为可重试错误 type Retryable struct { Err error } func (r *Retryable) Error() string { return r.Err.Error() } func (r *Retryable) Unwrap() error { return r.Err } // NewRetryable 包装一个可重试错误 func NewRetryable(err error) error { return &Retryable{Err: err} } // IsRetryable 判断错误是否可重试 func IsRetryable(err error) bool { var r *Retryable return errors.As(err, &r) }然后在使用方,我会明确区分:
- 网络连接失败、DNS 解析失败、连接池获取超时——可以重试。
- HTTP 503/502/504、gRPC 的 Unavailable/DeadlineExceeded——可以重试。
- HTTP 400/401/403/404、gRPC 的 InvalidArgument/PermissionDenied/NotFound——不要重试。
- 业务错误码(比如库存不足、重复提交)——不要重试。
实际项目里,这些逻辑会被封装成一个IsRetryableError函数,放到公共库中供各个服务复用。你要记住一个核心原则:重试的是“请求过程”,不是“业务结果”。只要请求成功到达服务端并且得到了一个明确的业务响应(哪怕是失败响应),这就不属于你需要重试的范围。
2.2 重试风暴:不好好设计重试,反而死得更快
既然重试能提高成功率,那是不是“遇错就重试、多试几次”就行了?绝对不行。
这里必须提到分布式系统里的一个经典问题——重试风暴(Retry Storm)。假设服务 A 调用服务 B,B 超时了,A 重试 3 次;B 同时被 C、D、E 多个服务调用,如果大家一窝蜂地同时重试,B 的请求量会在短时间内翻 3 倍甚至更多。如果 B 本身是因为负载过高才超时,这么一搞,相当于雪上加霜。
我遇到过最夸张的一个案例是:某服务发布变更后启动缓慢,健康检查还没通过,但注册中心已经把旧实例摘除了、新实例还没就绪,结果所有上游服务同时在 1 秒内重试了 5 次,直接把新实例打懵,导致启动流程反复中断,发布一直不成功。后来我们除了修复健康检查逻辑之外,还给重试加上了“随机抖动”和“最大并发重试数”限制,问题才算彻底解决。
所以在设计重试机制之前,你脑子里要始终有这根弦:重试不是越刚越好,而是越“柔”越好。这个“柔”体现在退避策略上,就是我们接下来要讲的内容。
3. 重试策略的核心:退避算法与参数选择
3.1 固定间隔重试为什么危险
最简单的重试策略就是固定间隔重试,比如失败之后每隔 1 秒钟重试一次,总共重试 3 次。实现起来一行time.Sleep就搞定,但它在生产环境的杀伤力非常大。
问题在于:如果你有 100 个调用方,大家都用固定的 1 秒间隔,那么所有调用方的重试请求会在同一时刻发起,形成周期性的“脉冲式流量”。对端服务刚恢复一点,又被这波脉冲打趴下,如此循环往复。而且固定间隔对瞬时抖动的适应性很差——有时候网络只是抖动了 200 毫秒,你却傻等 1 秒才重试,白白增加了接口延迟。
固定间隔只适合一个场景:你的重试频率极低、调用方数量极少、对端基本不会被打爆。但微服务架构下,这种场景几乎不存在,所以我不建议你在生产环境使用固定间隔。
3.2 指数退避 + 抖动:重试策略的黄金组合
指数退避(Exponential Backoff)的思路是:每次重试的等待时间按照指数增长,比如第一次等 100ms,第二次等 200ms,第三次等 400ms,第四次等 800ms……这样既给了对端恢复的时间,又能避免请求过于集中。
但这还不够,因为指数退避本身是“确定性的”——如果所有调用方都遵循同样的退避公式,那它们依然会在同一时刻发起重试。这时候就需要引入“抖动(Jitter)”。
抖动的实现方式有很多,业界最常用的有两种:
- 全抖动(Full Jitter):等待时间在 [0, 当前退避上限] 内随机取一个值。
- 等量抖动(Equal Jitter):等待时间在 [当前退避值的一半, 当前退避值] 内随机取一个值。
我用得比较多的是全抖动,写法如下:
package backoff import ( "math/rand" "time" ) // NextDelay 计算下一次重试等待时间,采用指数退避 + 全抖动 // attempt 从 0 开始计数 func NextDelay(attempt int, base time.Duration, max time.Duration) time.Duration { // 指数退避:base * 2^attempt exp := base * (1 << attempt) if exp > max { exp = max } // 全抖动:在 [0, exp) 之间随机 if exp <= 0 { return 0 } return time.Duration(rand.Int63n(int64(exp))) }为什么全抖动有效?可以想象一下:100 个服务同时失败,如果它们都在 [0, 1s] 内随机选一个时间去重试,那么这些重试请求会被“摊平”在一个时间窗口里,而不是同时打向对端。这就像你去食堂打饭,如果所有人都下课铃一响就冲过去,窗口必然堵死;但如果每个人出门前随机磨蹭几秒,队伍就顺畅多了。
关于参数的设置,我通常按照下面的经验值起步:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 初始间隔 base | 50ms ~ 200ms | 不宜太小,否则退避效果不明显 |
| 最大间隔 max | 2s ~ 10s | 防止重试等待时间过长 |
| 最大重试次数 | 3 ~ 5 次 | 超过 5 次收益急剧下降 |
| 总计超时时间 | 小于上游合理等待时间 | 确保不拖垮调用链 |
这里有一个计算逻辑供你参考:假设 base=100ms、max=5s、maxAttempts=5,那么第 1 到第 5 次重试的等待时间理论值分别是 100ms、200ms、400ms、800ms、1.6s,加上抖动后整体请求完成时间大约在 3~5 秒左右。如果你的上游接口超时时间是 3 秒,那么这个配置显然会拖累上游,你就需要把 maxAttempts 降为 3,或者把 base 缩短为 50ms。
3.3 服务端返回 Retry-After 头怎么办
在 HTTP 场景里,服务端有时候会在响应头里主动告诉你“多久之后再来”,这就是Retry-After头。这比客户端自己瞎猜准确得多。我处理这种情况时,会优先读取该头,只有头不存在时才使用本地退避算法:
func getRetryDelay(resp *http.Response, fallback time.Duration) time.Duration { if resp == nil { return fallback } if v := resp.Header.Get("Retry-After"); v != "" { if secs, err := strconv.Atoi(v); err == nil { return time.Duration(secs) * time.Second } if t, err := http.ParseTime(v); err == nil { return time.Until(t) } } return fallback }一些小细节你需要注意:整数形式的Retry-After表示“多少秒之后”,HTTP 日期形式的Retry-After表示“具体什么时间之后”。处理不当会出现“时区偏移”“类型转换失败”之类的低级错误,所以我们内部约定:统一使用整数形式,服务端在返回 429 或 503 时都会带上这个头。
4. Go 实现重试的几种方案与选型
4.1 方案一:手写 for 循环,最可控也最容易跑偏
在早期项目里,我是直接从最简单的手写循环开始的。核心逻辑如下:
func Retry(ctx context.Context, attempts int, fn func() error) error { var err error for i := 0; i < attempts; i++ { if err = fn(); err == nil { return nil } if !IsRetryable(err) { return err } // 检查 ctx 是否被取消 select { case <-ctx.Done(): return ctx.Err() case <-time.After(backoff.NextDelay(i, 100*time.Millisecond, 3*time.Second)): } } return err }这段代码的优点是:没有任何依赖、逻辑透明、出问题排查也容易。缺点是:只适合简单的本地调用,一旦你需要在多个服务之间复用这段逻辑,就会开始复制粘贴,然后在不同版本里越改越不一致;而且在处理 gRPC 状态码、HTTP 状态码、业务错误码的多重判断时,手写版本很容易漏掉一些边界情况。
所以我的建议是:如果你的服务少于 5 个、调用关系简单,手写没问题;如果服务规模变大,尽早抽成公共库。
4.2 方案二:使用开源库 retry-go,省心但不等于无脑
Go 生态里最常用的重试库是github.com/avast/retry-go。它把重试次数、延迟策略、错误判断都抽象成了可选参数,代码可以写得很优雅:
package main import ( "context" "fmt" "time" "github.com/avast/retry-go/v4" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := retry.Do( func() error { // 这里放你的 RPC/HTTP 调用 return callPaymentService(ctx) }, retry.Attempts(4), retry.Delay(100*time.Millisecond), retry.MaxDelay(3*time.Second), retry.MaxJitter(500*time.Millisecond), retry.RetryIf(func(err error) bool { return isRetryableError(err) }), retry.OnRetry(func(n uint, err error) { fmt.Printf("第 %d 次重试,错误: %v\n", n, err) }), retry.LastErrorOnly(true), ) if err != nil { fmt.Println("最终失败:", err) } }retry-go的 API 设计很友好,参数覆盖了绝大多数需求。但我要提醒的是:库只是把 for 循环封装起来了,策略还是需要你根据自己的场景调优。库提供的默认值(3 次、100ms 起步)在很多场景下偏激进,建议显式配置,不要直接依赖默认值。
4.3 方案三:自带重试的客户端,比如 gRPC 的 WithRetry
如果你的微服务通信用的是 gRPC,其实你不一定需要自己写重试,gRPC 官方提供了客户端重试能力。可以通过grpc.WithDefaultServiceConfig传入重试配置:
package main import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func main() { // 启用 gRPC 内置重试 serviceConfig := `{ "methodConfig": [{ "name": [{"service": "payment.PaymentService"}], "retryPolicy": { "MaxAttempts": 4, "InitialBackoff": "0.1s", "MaxBackoff": "3s", "BackoffMultiplier": 2.0, "RetryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"] } }] }` conn, _ := grpc.NewClient( "localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultServiceConfig(serviceConfig), ) defer conn.Close() }这种方式的好处是:由 gRPC 框架内部处理重试,不需要改动业务代码;坏处是:配置不够灵活,无法做“业务错误码级别”的重试判断,也无法在重试之间插入自定义的日志或监控。所以我更推荐的方式是:在 gRPC 调用层做一次拦截器(Interceptor)级别的重试封装,既统一了逻辑又能访问上下文信息。
下面我会重点展示一个基于拦截器的完整方案——这也是我目前在项目中最常用的做法。
5. 生产级 Go 重试组件落地:基于 gRPC 拦截器的完整实践
5.1 整体设计思路
在微服务里,每个服务既要作为调用方去请求别人的接口,也要作为被调用方接收别人的请求。如果我们在“调用方”这一侧统一做重试,就能保证任何服务间调用的重试策略是一致的。实现“统一”最好的方式就是拦截器。
我设计的组件包含三层:
- 第一层:
RetryConfig,定义重试次数、退避参数、可重试状态码列表,每个服务可以根据自己的接口特点覆盖配置。 - 第二层:
RetryInterceptor,实现 gRPC 的UnaryClientInterceptor,在发起 RPC 之前包一层重试逻辑。 - 第三层:
Metrics,记录重试次数、首次失败率、最终失败率等指标,方便监控和告警。
5.2 核心代码实现
下面是拦截器的核心实现,我加了比较详细的注释:
package retry import ( "context" "errors" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // Config 重试配置 type Config struct { // MaxAttempts 最大尝试次数(包含第一次请求) MaxAttempts uint // InitialBackoff 初始退避时间 InitialBackoff time.Duration // MaxBackoff 最大退避时间 MaxBackoff time.Duration // BackoffMultiplier 退避倍率,默认 2.0 BackoffMultiplier float64 // RetryableCodes 可重试的 gRPC 状态码 RetryableCodes []codes.Code // RetryableFunc 自定义的可重试判断函数,优先级最高 RetryableFunc func(error) bool } func DefaultConfig() *Config { return &Config{ MaxAttempts: 4, InitialBackoff: 100 * time.Millisecond, MaxBackoff: 3 * time.Second, BackoffMultiplier: 2.0, RetryableCodes: []codes.Code{ codes.Unavailable, codes.ResourceExhausted, codes.Aborted, }, } } // UnaryClientInterceptor 返回一个 gRPC 客户端拦截器 func UnaryClientInterceptor(cfg *Config) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { var ( err error attempt uint totalBudget time.Duration ) // 如果配置了总预算,则用它来限制整个重试过程 if deadline, ok := ctx.Deadline(); ok { totalBudget = time.Until(deadline) } for attempt = 0; attempt < cfg.MaxAttempts; attempt++ { if attempt > 0 { // 计算退避时间 delay := nextDelay(attempt, cfg) // 如果超时预算不足,直接返回错误 if totalBudget > 0 && delay >= totalBudget { return err } select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } } // 执行真正的 RPC 调用 err = invoker(ctx, method, req, reply, cc, opts...) if err == nil { return nil } if !isRetryable(cfg, err) { return err } // 每次失败后刷新预算 if deadline, ok := ctx.Deadline(); ok { totalBudget = time.Until(deadline) } } return err } } func isRetryable(cfg *Config, err error) bool { if cfg.RetryableFunc != nil { return cfg.RetryableFunc(err) } st, ok := status.FromError(err) if !ok { // 纯网络层错误,通常可以重试 return errors.Is(err, context.DeadlineExceeded) == false } for _, code := range cfg.RetryableCodes { if st.Code() == code { return true } } return false } func nextDelay(attempt uint, cfg *Config) time.Duration { if attempt <= 1 { return cfg.InitialBackoff } mult := cfg.BackoffMultiplier if mult <= 0 { mult = 2.0 } exp := float64(cfg.InitialBackoff) * pow(mult, float64(attempt-1)) if exp > float64(cfg.MaxBackoff) { exp = float64(cfg.MaxBackoff) } // 加入 0.5 * base 范围内的抖动 jitter := time.Duration(rand.Int63n(int64(exp / 2))) return time.Duration(exp) + jitter } func pow(base, exp float64) float64 { result := 1.0 for i := 0; i < int(exp); i++ { result *= base } return result }有几个细节我想单独说一下。
totalBudget的处理:不要等所有重试都执行完才去管 ctx 的超时,每次退避之前要先判断“剩余时间是否足够”。如果不判断,可能出现一种情况:整体 ctx 10 秒超时,重试 3 次的退避加起来却要 12 秒,那么等所有重试执行完,请求早就超时了。这种情况要么减少重试次数,要么缩短退避时间。
isRetryable的判断:代码里对“非 gRPC 错误”的处理是从简的。真实项目里,如果是 HTTP 库返回的错误,你可能还要往上层传一个RetryableError标记。我建议在做微服务网关层时,统一把所有调用错误在边界处转换为 gRPC status,这样下游重试逻辑能更干净。
5.3 在服务里接入拦截器
拦截器写好后,接入非常方便。以grpc-go为例:
package main import ( "context" "log" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/codes" "your-module/retry" ) func main() { cfg := retry.DefaultConfig() // 针对支付服务,我们允许重试 5 次,但退避更保守 cfg.MaxAttempts = 5 cfg.InitialBackoff = 200 * time.Millisecond cfg.MaxBackoff = 5 * time.Second conn, err := grpc.NewClient( "payment-service:9090", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithUnaryInterceptor(retry.UnaryClientInterceptor(cfg)), ) if err != nil { log.Fatalf("连接失败: %v", err) } defer conn.Close() // 后续使用 conn 发起 RPC 调用时,会自动带上重试逻辑 }这样的好处是:服务里任何基于conn的 RPC 调用都自动具备了统一的重试能力,不需要在业务代码里到处写for循环。你只需要针对少数特殊接口(比如幂等性无法保障的写操作)单独调整配置。
5.4 配套监控:没有可观测性的重试等于盲人摸象
重试逻辑上线后,你一定会面临三个问题:重试生效了吗?重试成功了多少?重试之后最终失败的有多少?如果不做监控,这些问题全都回答不了。
我的做法是在拦截器里埋几个 Prometheus 指标:
rpc_retry_total{method, retry_count}:按方法和重试次数统计的重试总次数。rpc_retry_success_total{method}:重试后最终成功的总次数。rpc_retry_failed_total{method}:重试后最终仍失败的总次数。rpc_first_attempt_latency_seconds{method}:首次请求耗时。
有了这些指标,你就能在 Grafana 里看到类似“支付服务调用成功率 99.2%,其中 0.6% 是重试救回来的”这种数据,进而判断重试策略是否需要调整。我在实际项目里见过有人重试配了 8 次仍然失败率很高,一查监控发现对端服务已经彻底宕机了——这种重试不仅没帮助,反而把调用延迟拉长了 30 秒。如果“重试成功率”这个指标能直观暴露出来,这个问题早就应该被发现。
6. 重试机制的隐藏陷阱:幂等、超时与链路传播
6.1 幂等性:重试的前提条件,不能只靠代码解决
重试最怕的是什么?同一个请求被执行了两遍,产生了两份订单、扣了两次款。这就是幂等性问题。
在微服务架构中,重试的幂等性不能只依赖被调用方“恰好做了幂等处理”,调用方必须在设计上就考虑这层风险。比如你在发支付请求时,每次请求都要带上全局唯一的request_id或者idempotency_key,服务端在接收到重复 key 的请求时直接返回第一次的处理结果。
type PaymentRequest struct { OrderID string `json:"order_id"` RequestID string `json:"request_id"` // 幂等键 Amount int64 `json:"amount"` }服务端收到请求后,可以先查一下Redis: idempotent:{requestID}是否已经有了结果。如果有则直接返回;没有才执行扣款逻辑,并将结果写入 Redis 并设置适当的过期时间(一般 24 小时)。这套方案虽然简单,却能挡住绝大多数因重试导致的重复请求。
这里我想特别提一个容易被忽略的点:重试不仅会发生在客户端,也会发生在服务端。比如你调用一个下游服务,下游处理完业务但返回响应超时了,你的重试请求到达下游,下游如果不做幂等处理,就会重复执行。所以你在设计任何写接口时,都要假设“同一个请求可能被送过来多次”。
6.2 超时控制:重试的“总闸门”,千万别忘
很多人只设置了单次请求的超时时间和重试次数,却忘了设置“整个重试过程的总超时时间”。结果就是:单次请求 1 秒超时,重试 5 次,最坏情况下整个调用耗时 6 秒(5 次请求 + 5 次退避),直接把上层服务的 goroutine 全部拖住。
我的建议是:每次重试执行之前,先检查一下剩余的超时时间预算。如果剩余时间已经不足以完成一次新的请求,就直接放弃重试,把错误返回给上层。用 Go 的context来实现这个非常方便:
func withRetryBudget(parent context.Context, budget time.Duration) (context.Context, context.CancelFunc) { ctx, cancel := context.WithTimeout(parent, budget) return ctx, cancel }在你的业务代码入口处设置一次总预算,比如整个 RPC 调用最多 3 秒,内部即使最多重试 5 次,也必须在 3 秒内完成。这样就不会出现“Super 长的重试链”把整个调用链路拖垮的情况。
6.3 重试会放大流量:熔断与限流必须配合
重试机制再高级,也只是“战术层面的防空炮”;如果要挡住“战略级的流量洪峰”,必须和熔断、限流配合使用。
举一个我实际遇到过的情况:大促期间,某下游服务的一个节点频繁发生 Full GC,响应时间从 50ms 飙升到 3s,上游服务的重试逻辑开始密集触发。由于我们的重试退避带抖动,虽然没把所有请求同时打过去,但整体请求量还是变成了原来的 3 倍。下游服务本就已经不稳定,这 3 倍流量直接把它压垮。最后我们紧急在调用方加了一个熔断器(用的是开源库github.com/sony/gobreaker),当失败率达到 50% 时直接熔断 3 秒,不再发起任何新请求,才把系统稳住。
所以正确的组合策略应该是:重试负责救回“偶发抖动”,熔断负责挡住“持续故障”。触发重试的条件是失败次数少、对端可能还活着;触发熔断的条件是失败率连续超过阈值,对端大概率已经不行了。两者结合,才能让系统既有韧性,又不会过度放大流量。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查思路与解法 |
|---|---|---|
| 重试了但接口依然失败 | 对端服务确实挂了,或者重试策略对错误类型判断错误 | 看监控确认对端状态;检查IsRetryable是否正确覆盖了对应的错误码 |
| 重试后接口延迟很高 | 退避时间过长、重试次数过多 | 调小MaxBackoff,减少MaxAttempts,或设置总超时预算 |
| 下游流量突然暴增 | 所有调用方同时重试,形成重试风暴 | 引入全抖动,控制重试并发,配合熔断器 |
| 重试导致重复扣款/重复下单 | 接口不是幂等的 | 在被调用方实现幂等键去重;调用方传递request_id |
| 重试时 ctx 已经被取消 | 上层超时时间小于重试总时长 | 在重试循环里每次都检查ctx.Err(),并在退避前判断剩余预算 |
| gRPC 重试不生效 | 服务端未开启重试支持,或者状态码不在RetryableStatusCodes列表里 | 确认 gRPC 版本;确认状态码配置正确 |
7.2 我踩过的两个比较隐晦的坑
第一个坑和context的传递有关。早期我在重试循环里调用了invoker(ctx, ...),而这个ctx是外层的长生命周期 context,没有设置超时。结果有一个下游服务挂起不返回,重试循环里的每次调用都一直在等,goroutine 不断堆积,最终内存飙升。排查了很久才发现是超时没有设置。从那以后,我给自己立了一个规矩:所有进入重试逻辑的 context,必须是带超时的 context。没有超时的重试,等于在给自己埋雷。
第二个坑是关于“重试后立即成功,但业务侧收到两条响应”的奇怪现象。原因是某次请求其实服务端已经处理成功了,只是在返回响应时网络超时,客户端重试发了第二次请求。由于服务端没有做幂等处理,订单被创建了两份。这属于幂等机制设计缺失,我在这里再强调一遍:防重试风暴是保护系统,防重复执行是保护业务,两者缺一不可。
7.3 如何验证重试机制是否靠谱
我推荐三个验证手段:
第一,单元测试。模拟一个第一次必定失败、第二次成功的函数,断言重试逻辑能正确返回成功结果,并且重试次数等于预期。
第二,本地混沌测试。写一个小脚本,随机让下游服务返回Unavailable或丢弃请求,观察上游重试是否触发、成功率是否提升、延迟是否在可控范围内。
第三,生产灰度发布。先让 5% 的流量使用新重试配置,对比这 5% 的流量与其余流量的成功率、时延、下游负载等指标。对比通过后再全量放开。
我个人最推荐第二步和第三步结合,因为单元测试只能证明“代码逻辑没写错”,而真正的重试效果只能在接近生产的环境中验证。
8. 一个实例:订单服务调用支付服务时的重试配置
最后分享一个真实项目里的配置案例,你可以直接参考。
场景:订单服务调用支付服务,接口要求处理时间不超过 5 秒,支付服务偶尔因为连接池打满返回Unavailable错误。
配置如下:
cfg := &retry.Config{ MaxAttempts: 4, InitialBackoff: 100 * time.Millisecond, MaxBackoff: 2 * time.Second, BackoffMultiplier: 1.5, RetryableCodes: []codes.Code{ codes.Unavailable, codes.ResourceExhausted, codes.DeadlineExceeded, }, }计算一下最坏情况:第 1 次请求失败,等待约 100ms;第 2 次失败,等待约 150ms;第 3 次失败,等待约 225ms;第 4 次失败,总计耗时约 500ms + 4 次请求时间。即使每次请求都打满 500ms 超时,整个重试过程也控制在 2.5 秒左右,小于 5 秒的总预算,不会拖垮上游。
同样这个场景,如果我把MaxAttempts设成 8,理论上最坏情况就会超过 5 秒,那就不合适了。所以参数不是拍脑袋定的,而是根据接口的超时预算和下游负载能力反推出来的。我建议你在每一个服务接入重试时,都拿出一张纸算一下“最坏情况下的总耗时”,心里有数再上线。
重试机制在 Go 微服务里的价值,不是一个“try again”那么简单。它涉及到错误分类、退避算法、抖动、幂等、超时控制、熔断配合、监控告警等一连串问题。希望这篇文章能帮你把这些点串起来,在自己的项目里把重试做成一个可靠的、可观测的基础能力,而不是临到线上故障时才想起来补的救火工具。