重试与幂等:可重试判定、退避与幂等键
重试是最便宜的高可用手段:一次网络抖动、一次 Pod 滚动重启导致的 Unavailable,重试一次就过去了。它也是把故障放大成雪崩的最快方式——固定间隔重试、对非幂等写重试、内外层叠加重试,都会让一个后端故障升级成全站故障。本章讲清三件事:什么错才能重试、退避怎么配、幂等键怎么设计。
判定一:只有幂等方法才可重试
读操作(Get/List)天然幂等。写操作必须在业务上定义"同样的请求执行多次 == 执行一次",常见三种做法:
- 天然幂等:条件更新
UPDATE ... SET status=2 WHERE id=? AND status=1、SETNX、删除不存在的东西; - 幂等键去重:客户端带键,服务端首次执行并把结果缓存,重复请求直接返回缓存结果(见下文);
- 状态机 + 版本号:请求带
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,否则调用方看到的是一次失败,幂等语义和用户体验都对不上。
参数建议值
| 参数 | 建议值 | 说明 |
|---|---|---|
maxAttempts | 3(上限 4) | 含首次;每多一层就成倍放大流量 |
initialBackoff | 100~200ms | 小于 RPC 正常耗时会导致无意义重试 |
maxBackoff | 1s(跨机房 2s) | 别长到超过调用方耐心 |
backoffMultiplier | 2 | 指数退避标准值 |
retryableStatusCodes | UNAVAILABLE | 慎加 RESOURCE_EXHAUSTED,需服务端带 RetryInfo |
| 重试总预算 | ≤ deadline 的 1/3 | 留够首次调用与网络抖动,且全局只保留一层重试 |
| 幂等键 TTL | 24h(按业务定) | 至少覆盖客户端可能重试的最长窗口 |
| 占位 TTL | 10s | 崩溃自愈,防止键被永久占用 |
常见坑
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)完成去重与结果回放。