重试不是免费的
一句话总结
重试一旦在多个层级叠加,就会相乘并再次压垮正在恢复的对方,因此只能在一个层级执行,并在指数退避中加入抖动。
为什么重试会放大故障
对方系统短暂波动,我们进行重试,看起来是良好设计。但请考虑这种情况。
게이트웨이: 재시도 4회
→ 서비스A: 재시도 4회
→ 서비스B: 재시도 4회
用户只按一次按钮,最终系统却会收到 4 × 4 × 4 = 64 次请求。正在恢复的对方又会被这轮轰炸压垮,而且每到重试间隔,这股流量都会再次出现。
这就是“只在一个层级重试”原则的由来。多个层级同时重试,会产生指数级放大。
指数退避与抖动
固定间隔重试有两个问题:重试得太快,而且所有客户端会同时重试。
1차 실패 → 1초 대기 → 2차 → 2초 → 3차 → 4초 → 4차 → 8초 ... (상한 30초)
指数退避解决了第一个问题,但第二个问题仍然存在。
如果一万个客户端采用同样规则,就会在完全相同的时刻重试。 正在恢复的服务器会再次倒下,而且流量波峰会在 2 秒、4 秒、8 秒时重复出现。
这就是 thundering herd。解决方法是加入抖动(jitter),把随机性混入计算出的等待时间。
sleep = random(계산값 × 0.5, 계산값) # full jitter 계열
没有抖动的退避只完成了一半。平时即使没有抖动也能正常运行,因此通常要到大规模故障时才会首次暴露。
哪些错误不能重试
如果不做区分,重试反而有害。
| 错误类型 | 重试? | 原因 |
|---|---|---|
| 连接被拒绝、超时 | O | 很可能是临时故障 |
| HTTP 500、502、503、504 | O | 服务端临时错误 |
| HTTP 400(格式错误) | X | 重发 100 次也会失败 100 次 |
| HTTP 401/403(认证/权限) | X | 凭据不变就不会成功 |
| HTTP 404 | X | 目标不存在 |
| HTTP 409(重复) | X | 可能表示已经处理 |
| HTTP 429(超过限额) | 有条件 | 应遵守 Retry-After |
尤其糟糕的是忽略 429 并立即重试。对方明明说“请慢一点”,我们却反而加速,许多 API 会直接封禁这种客户端。
DLQ——要懂得放弃
超过最大尝试次数的消息应送入 DLQ(Dead Letter Queue)。无限重试会堵塞队列,而队列堵塞就意味着整体停摆。
写入 DLQ 时不能只保存原始消息,因为之后需要人工判断。
{
"msg_id": "M-20260819-000123",
"original": { ...원본 전문... },
"reason": "upstream 503 after 5 attempts",
"attempts": 5,
"first_failed_at": "2026-08-19T02:11:03+09:00",
"last_failed_at": "2026-08-19T02:12:47+09:00",
"trace_id": "a1b2c3d4"
}
- 必须有原始消息才能重处理
- 必须有原因与尝试次数才能分类根因
- 必须有首次失败时间才能知道问题从何时开始
而且,DLQ 本身必须被监控。 只要数量不为零,就应有人查看。很多系统建了 DLQ 却无人关注,那与静默丢弃消息没有区别。
重处理设计
如何把 DLQ 中的消息放回去?
- 分类——按原因区分系统错误与数据错误
- 判断——修正后重新投入,还是要求源系统重发
- 安全确认——重处理是否幂等。 否则会造成重复处理
- 执行——先少量处理;一次全部放回,可能因相同原因再次堵塞
- 记录——谁在何时重处理了多少条
第 3 步最关键。对非幂等接口进行重处理,可能产生比不处理更坏的结果,例如重复入账、重复下单。
幂等性——如何实现
幂等是指同一请求处理多次,副作用仍只发生一次,并不是“响应总是相同”。
方法归根结底只有一个:为每个请求附上唯一键,由接收方记住已经处理过的键。
CREATE TABLE inbox_log (
msg_id TEXT PRIMARY KEY, -- ★ 멱등키. 제약이 마지막 방어선
biz_key TEXT NOT NULL,
status TEXT NOT NULL,
response TEXT, -- 최초 응답을 그대로 보관
created_at TEXT NOT NULL
);
设计的核心,是选择什么作为幂等键。
- 只用
order_no?——很危险,同一订单还可能存在修改报文 송신시스템 + 전문번호——如果报文编号在发送方唯一,就是好方案송신시스템 + 전문번호 + 일자——适用于报文编号每天循环的情况
而且必须通过数据库约束(PRIMARY KEY / UNIQUE)阻止重复。 应用层的 if 조회 then 없으면 insert 在两个请求同时到达时会被绕过,因为查询与插入之间存在空隙。约束会消除这道空隙,即使应用有缺陷,也无法绕过。
保存后返回(store and return)
再进一步,对重复请求可以原样返回首次响应。
1. msg_id 로 조회
2. 있으면 → 저장된 response 를 그대로 반환 (처리 안 함)
3. 없으면 → 처리하고, 결과를 response 에 저장
这样一来,发送方重发就完全安全。因超时没有收到响应时,直接再发送一次即可。这正是 Idempotency-Key 请求头约定所做的事,也是支付 API 采用这种方式的原因。
幂等记录的保留期限
inbox_log 会无限增长,必须清理。但清理某段记录的瞬间,那一时间段的重复防护也随之消失。
因此,保留期限应长于现实中可能发生重发的最长时间。如果对方的重处理政策允许重发最多 7 天前的数据,保留 30 天较为安全。清理批处理只能按时间条件删除;“从最旧的开始删除 N 条”在流量突然增加时,可能误删较新的记录。
产生重复的四条路径
最后,总结实际工作中重复从何而来。
- 重试——超时后重发,尤其是仅响应丢失的情况
- 队列的 at-least-once——代理未收到确认而重新投递
- 运维人员手动重发——故障后要求“先再发一次”
- 发送方重新执行批处理——从头重跑失败批次
第 4 种规模最大。批处理完成一半后失败,整体重跑会让前一半全部重复。因此,批处理要么记录重启位置,要么依赖接收方幂等性;后者通常更现实。
在实际项目中
最常见的情况是:没有人专门设计重试,却在三层都开启了重试。 网关默认值、HTTP 客户端库默认值,再加上我们的代码。每层合理的 3~4 次相乘后会变成几十次,于是事故处理中会出现“我们明明只发了一次,为什么对方日志有 40 条”的对话。
其次,是重试了不该重试的错误。格式错误与认证失败即使发送一百次也不会改变,但重试逻辑若不区分状态码,无意义请求就会填满对方日志并堵塞我方队列。
最后必须懂得放弃。送入 DLQ 时若不同时记录原因、尝试次数与首次失败时间,几周后打开文件的人就无法判断:这条消息能否安全地重新投入。