重试与幂等:可重试判定、退避与幂等键

重试是最便宜的高可用手段:一次网络抖动、一次 Pod 滚动重启导致的 Unavailable,重试一次就过去了。它也是把故障放大成雪崩的最快方式——固定间隔重试、对非幂等写重试、内外层叠加重试,都会让一个后端故障升级成全站故障。本章讲清三件事:什么错才能重试、退避怎么配、幂等键怎么设计。

判定一:只有幂等方法才可重试

读操作(Get/List)天然幂等。写操作必须在业务上定义"同样的请求执行多次 == 执行一次",常见三种做法:

  1. 天然幂等:条件更新 UPDATE ... SET status=2 WHERE id=? AND status=1、SETNX、删除不存在的东西;
  2. 幂等键去重:客户端带键,服务端首次执行并把结果缓存,重复请求直接返回缓存结果(见下文);
  3. 状态机 + 版本号:请求带 expected_version,版本不符返回 Aborted,调用方重新读取后再决定。

没有这三者之一的写接口(发消息、扣费、下单)默认不可重试。

判定二:只有可恢复的状态码才可重试

状态码典型原因可重试性
Unavailable(14)连接断、Pod 未就绪、LB 摘除首选,配合退避重试
ResourceExhausted(8)服务端限流、流控仅在服务端明确给出 RetryInfo 时重试,否则雪上加霜
Aborted(10)并发冲突、事务冲突幂等且有冲突重试语义时可用
DeadlineExceeded(4)超时默认不重试(预算已花光);仅当整个 deadline 还有大余量时允许一次
Internal(13)服务端 bug、panic不重试,打到同一个坏节点大概率还是失败
InvalidArgument(3) / NotFound(5) / AlreadyExists(6) / FailedPrecondition(9)参数与业务前置条件不重试,结果不会变
Unauthenticated(16) / PermissionDenied(7)鉴权失败不重试(除非先刷新令牌)
Unimplemented(12) / DataLoss(15)版本不匹配、数据损坏不重试
Canceled(1)上游主动取消不重试,取消还重试等于对抗调用方

用服务配置声明重试策略

grpc-go 的重试走服务配置(service config),不要自己写 for 循环——框架会把重试、退避、次数与 deadline 检查一起做掉:

const retryPolicy = `{
  "methodConfig": [{
    "name": [{"service": "im.logic.v1.LogicService"}],
    "waitForReady": false,
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE"]
    }
  }]
}`

conn, err := grpc.NewClient("dns:///logic-svc:9000",
	grpc.WithTransportCredentials(insecure.NewCredentials()),
	grpc.WithDefaultServiceConfig(retryPolicy),
	// grpc.WithDisableRetry(), // 排障期间一键关掉重试,方便定位首错
)
  • maxAttempts 包含首次调用:3 = 首次 + 2 次重试,最坏情况是 3 倍请求量;
  • 退避由框架计算:initialBackoff → ×backoffMultiplier → … → maxBackoff 封顶,并自带抖动(约 ±20%),不要自己再套一层随机;
  • 只对一元调用与服务端流式生效;客户端流式、双向流式无法保证重放安全,框架不会重试;
  • 重试受 deadline 约束:第 07 章的超时一到,正在退避等待的重试直接以 DeadlineExceeded 结束,"重试次数"必须和"超时预算"一起算;
  • waitForReady: false(默认)是 fail-fast,连接未就绪立即返回 Unavailable。

幂等键:让写操作也能安全重放

键由客户端生成,一次业务意图一个键,重试时复用同一个键:

// 键由业务语义决定,进重试循环前生成一次
key := fmt.Sprintf("send:%d:%d:%s", uid, convID, clientMsgID)
ctx = metadata.AppendToOutgoingContext(ctx, "x-idem-key", key)

服务端用占位 + 结果缓存实现去重与结果回放:

const (
	placeholder = "p"  // 处理中,防并发重复执行
	donePrefix  = "d:" // 已完成,后面跟序列化后的响应
)

// Begin 返回 (是否首次执行, 已缓存的结果, 错误)
func (s *IdemStore) Begin(ctx context.Context, key string) (bool, []byte, error) {
	k := "idem:" + key
	ok, err := s.rdb.SetNX(ctx, k, placeholder, 10*time.Second).Result()
	if err != nil {
		return false, nil, err
	}
	if ok {
		return true, nil, nil // 首次:继续执行业务逻辑
	}
	val, err := s.rdb.Get(ctx, k).Result()
	if err != nil {
		return false, nil, status.Error(codes.Aborted, "concurrent duplicate")
	}
	if val == placeholder {
		// 上一次还在跑:让客户端稍后再来,而不是自己再跑一遍
		return false, nil, status.Error(codes.Aborted, "in progress")
	}
	return false, []byte(strings.TrimPrefix(val, donePrefix)), nil
}

// Finish 业务成功后写回结果,并把 TTL 延长到业务去重窗口
func (s *IdemStore) Finish(ctx context.Context, key string, resp []byte) error {
	return s.rdb.Set(ctx, "idem:"+key, donePrefix+string(resp), 24*time.Hour).Err()
}

四条设计规则:

  • 占位 TTL 要短(10s 级):进程崩溃后键自动过期,不会永久卡死;成功后结果 TTL 长(按去重窗口,如 24h);
  • 键必须有作用域:业务:uid:业务内唯一 ID,避免不同用户、不同场景撞键;
  • 失败要分类:确定性业务失败(参数非法)删键或给短 TTL;系统失败(超时)保留占位,让重试拿到"处理中"而不是重复执行;
  • 重复请求返回与首次一致的响应,而不是报 AlreadyExists,否则调用方看到的是一次失败,幂等语义和用户体验都对不上。

参数建议值

参数建议值说明
maxAttempts3(上限 4)含首次;每多一层就成倍放大流量
initialBackoff100~200ms小于 RPC 正常耗时会导致无意义重试
maxBackoff1s(跨机房 2s)别长到超过调用方耐心
backoffMultiplier2指数退避标准值
retryableStatusCodesUNAVAILABLE慎加 RESOURCE_EXHAUSTED,需服务端带 RetryInfo
重试总预算≤ deadline 的 1/3留够首次调用与网络抖动,且全局只保留一层重试
幂等键 TTL24h(按业务定)至少覆盖客户端可能重试的最长窗口
占位 TTL10s崩溃自愈,防止键被永久占用

常见坑

  • maxAttempts 当成"额外重试次数":写成 3 以为是"重试 3 次",实际总调用 3 次,流量预算算错 1 倍。
  • 拿 DeadlineExceeded 重试:第一次超时已把预算花光,重试往往立刻再超时,只是拉长失败时间、把服务端队列堵得更死。
  • 对非幂等写重试:发消息、扣费、下单没有幂等键就重试,用户看到两条消息或两次扣款。
  • 内外层重试叠加:接入层重试 3 次、逻辑层再重试 3 次,最坏 9 倍请求且同时发生。规则是"只在最外层重试一次"。
  • 重试时重新生成幂等键或 traceId:客户端每次 attempt 新生成 key,服务端去重形同虚设;traceId 变了则链路追踪里看起来是多个独立请求。两者都必须在进重试前生成一次并复用。

小结:先判定再重试——只有幂等方法加 Unavailable(谨慎含 ResourceExhausted)才允许重试;策略用服务配置声明(maxAttempts 3、initialBackoff 100ms、maxBackoff 1s、backoffMultiplier 2),退避与抖动交给框架,重试总预算不超过 deadline 的三分之一,并且只在一层重试;写操作靠客户端生成、重试复用的幂等键,服务端用 SETNX 占位(10s)+ 结果缓存(24h)完成去重与结果回放。

笔记加载中…