Golang重试机制,如何应对各种失败的场景"

admin 2026-10-22 07:34:08 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

Golang重试机制,如何应对各种失败的场景

原创

go
go

Go语言教程

2026年6月13日 13:02
陕西

在小说阅读器读本章

去阅读

接口超时了,第一反应就加重试,这地方我一般先皱眉。

不是不能重试,是很多系统被重试打崩的。原本下游只是抖了一下,上游几十台机器一起“帮忙”,每个请求再补三次,流量瞬间翻几倍。日志里看起来很勤奋:

pay callback timeout, retry=1
pay callback timeout, retry=2
pay callback timeout, retry=3

但下游看到的不是勤奋,是踩油门。

重试这东西,在 Go 里写起来太简单,真正麻烦的是判断:什么失败值得重试,什么失败重试就是添乱。

我平时会先把失败分几类。

网络闪断、连接被重置、临时 DNS 抖动,可以重试。

HTTP 502、503、504,可以重试,但要慢一点。

429 这种限流,别傻乎乎马上重试,要看 Retry-After,没有就退避。

业务错误,比如余额不足、参数非法、订单状态不对,别重试。再试十次也还是错。

还有一种最坑:请求其实成功了,只是响应超时。比如扣款接口,服务端已经扣了钱,客户端没收到响应。你再补一枪,业务没有幂等兜底,事故就来了。

所以我写重试,第一行不是 for 循环,而是先写判断函数。

package retryx

import (
 \"context\"
 \"errors\"
 \"math/rand\"
 \"net\"
 \"net/http\"
 \"strconv\"
 \"time\"
)

type Result struct {
 StatusCode int
 Header     http.Header
}

type RetryableError interface {
 error
 Temporary() bool
}

func shouldRetry(res *Result, err error) bool {
 if err != nil {
  if errors.Is(err, context.Canceled) {
   return false
  }
  if errors.Is(err, context.DeadlineExceeded) {
   return true
  }

  var ne net.Error
  if errors.As(err, &ne) {
   return ne.Timeout() || ne.Temporary()
  }

  var re RetryableError
  if errors.As(err, &re) {
   return re.Temporary()
  }

  return false
 }

 if res == nil {
  return false
 }

 switch res.StatusCode {
 case http.StatusTooManyRequests,
  http.StatusBadGateway,
  http.StatusServiceUnavailable,
  http.StatusGatewayTimeout:
  return true
 default:
  return false
 }
}

这里我不会把 500 一股脑放进去。有些系统的 500 是代码空指针,有些是库存不足也给你包装成 500。这个要看你们接口约定。接口约定不清楚,别在客户端自作聪明。

再看重试主体。

type Config struct {
 MaxAttempts int
 BaseDelay   time.Duration
 MaxDelay    time.Duration
}

func Do(ctx context.Context, cfg Config, fn func(context.Context) (*Result, error)) (*Result, error) {
 if cfg.MaxAttempts <= 0 {
  cfg.MaxAttempts = 3
 }
 if cfg.BaseDelay <= 0 {
  cfg.BaseDelay = 80 * time.Millisecond
 }
 if cfg.MaxDelay <= 0 {
  cfg.MaxDelay = 2 * time.Second
 }

 var lastErr error
 var lastRes *Result

 for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
  callCtx, cancel := context.WithTimeout(ctx, 900*time.Millisecond)
  res, err := fn(callCtx)
  cancel()

  lastRes, lastErr = res, err

  if !shouldRetry(res, err) {
   return res, err
  }

  if attempt == cfg.MaxAttempts {
   break
  }

  delay := backoff(cfg.BaseDelay, cfg.MaxDelay, attempt)

  if res != nil && res.StatusCode == http.StatusTooManyRequests {
   if v := retryAfter(res.Header); v > 0 {
    delay = v
   }
  }

  select {
  case <-ctx.Done():
   return lastRes, ctx.Err()
  case <-time.After(delay):
  }
 }

 return lastRes, lastErr
}

func backoff(base, max time.Duration, attempt int) time.Duration {
 d := base << (attempt - 1)
 if d > max {
  d = max
 }

 // 加一点抖动,别让所有机器同一毫秒冲回去
 jitter := time.Duration(rand.Int63n(int64(d / 2)))
 return d/2 + jitter
}

func retryAfter(h http.Header) time.Duration {
 v := h.Get(\"Retry-After\")
 if v == \"\" {
  return 0
 }
 sec, err := strconv.Atoi(v)
 if err != nil || sec <= 0 {
  return 0
 }
 return time.Duration(sec) * time.Second
}

这段代码里有几个我比较在意的点。

第一个,每次调用都有自己的 timeout。不要只给最外层一个 context,然后里面请求卡住半天。重试不是无限续命,单次调用也必须有边界。

第二个,退避要带 jitter。线上集群最怕整齐,整齐就容易一起死。100 台机器同一时间失败,同一时间 100ms 后重试,再同一时间 200ms 后重试,这不叫容错,这叫排队冲锋。

第三个,最大次数别贪。大部分接口 2 到 3 次已经够了。再多通常不是“恢复”,而是在扩大故障面。

HTTP 请求可以这么包一层:

func callInventory(ctx context.Context, client *http.Client, sku string) error {
 _, err := retryx.Do(ctx, retryx.Config{
  MaxAttempts: 3,
  BaseDelay:   100 * time.Millisecond,
  MaxDelay:    800 * time.Millisecond,
 }, func(c context.Context) (*retryx.Result, error) {
  req, _ := http.NewRequestWithContext(c, http.MethodPost,
   \"http://inventory-inner/lock?sku=\"+sku, nil)

  resp, err := client.Do(req)
  if err != nil {
   return nil, err
  }
  defer resp.Body.Close()

  return &retryx.Result{
   StatusCode: resp.StatusCode,
   Header:     resp.Header,
  }, nil
 })

 return err
}

但这还不够。

只要接口涉及“写”,我一定会问一句:有没有幂等键?

比如创建订单、扣库存、发优惠券,这些操作不能靠客户端“少重试一点”来保平安。客户端会超时,网关会重放,MQ 会重复投递,人还会手工补偿。没有幂等,迟早出事。

请求里带一个业务唯一键,服务端按这个键去重:

func buildIdempotentKey(userID, bizNo string) string {
 return \"lock_stock:\" + userID + \":\" + bizNo
}

这个 key 不要用随机 UUID 糊弄。随机 UUID 每次重试都不一样,那叫制造重复,不叫幂等。

数据库失败也一样,不是所有 error 都能重试。

死锁、锁等待超时,可以短暂重试。唯一键冲突,大概率说明请求已经处理过,应该查结果,不是再插一遍。SQL 语法错误、字段超长,重试没有意义。

MQ 发送失败也要小心。生产者发送超时,消息可能已经进 broker 了。你再发一次,消费者必须能扛重复。别把“生产者重试”和“消费者幂等”拆开看,它们是同一个坑的两边。

日志也别只打一行 retry。

我更喜欢这种:

retry call=lock_stock sku=10086 attempt=2 cost=904ms delay=187ms err=\"context deadline exceeded\"

线上排障时,这一行比一堆“调用失败”有用多了。至少能看出来第几次失败、睡了多久、是不是单次 timeout 太短。

最后还有个东西容易被忽略:重试预算。

一个接口本来 QPS 1000,下游抖动后每个请求重试 3 次,理论流量直接变 3000。所以上游最好配合限流或者熔断。重试救的是偶发失败,不是拿来硬扛下游雪崩的。

我见过不少代码,失败就 for 三次,sleep 一秒,最后还把原始错误吞了,只返回一句“系统繁忙”。这种代码短期看没问题,真到线上出事,查起来很恶心。

重试机制写到最后,其实就几句话:

能判断的失败再重试。

写操作先谈幂等。

退避必须带抖动。

次数别贪。

日志要留下现场。

否则你以为自己写的是容错,线上看起来更像放大器。


Golang重试机制,如何应对各种失败的场景" 网络安全文章

Golang重试机制,如何应对各种失败的场景"

本文系统解析Golang重试机制的设计要点,指出盲目重试可能放大故障风险。核心在于区分可重试错误(如网络闪断、5xx状态码)与不可重试错误(如业务逻辑错误),并强调写操作必须依赖服务端幂等性。代码示例展示了包含退避抖动、Retry-After头解析的重试框架,建议限制重试次数(2-3次)、添加详细日志并配合熔断限流控制重试预算。
独家丨别把LLM提取的主题当成真实变量" 网络安全文章

独家丨别把LLM提取的主题当成真实变量"

本文讨论了将大语言模型(LLM)从文本中提取的`主题标签`直接用作因果分析中的真实变量所带来的风险。文章指出,这类由模型生成的标签被称为`生成变量`,它继承了文本产生机制、模型提取过程和样本选择中的隐含假设,可能在分析中引入选择偏差、时序偏差和测量误差。文章通过模拟案例展示了错误地使用这些变量如何导致干预效应方向被反向估计,并强调在进行因果推断前,必须明确其变量角色、生成时间和可靠性,以避免将模型输出误认为事实本身,从而提升分析的可靠性。
一文梳理三大欧盟标准组织全系列网络安全标准|CRA、RED、工控、消费IoT全覆盖" 网络安全文章

一文梳理三大欧盟标准组织全系列网络安全标准|CRA、RED、工控、消费IoT全覆盖"

本文系统梳理欧盟三大标准化组织CEN、CENELEC和ETSI在网络安全领域的职责分工与协作机制。CEN主导通用IT安全框架和评估标准如EN17927SESIP和EN40000系列;CENELEC专注工控电气设备安全如ENIEC62443;ETSI负责无线通信和消费物联网标准如EN303645。文章详解各组织核心标准适用范围及CRA法案合规要求,并提供按产品类型匹配对应标准的实操指南,帮助出口企业规避高额罚款并提升认证效率。
评论:0   参与:  0