LabHub
学习 学习路径 课程

系统间对接 (EAI)

重试不是免费的

在 LabHub 中继续学习

一句话总结

重试一旦在多个层级叠加,就会相乘并再次压垮正在恢复的对方,因此只能在一个层级执行,并在指数退避中加入抖动。

概念图: 一个层级 · 4 × 4 × 4 = 64 次 · 只在一个层级重试 · 完全相同的时刻

为什么重试会放大故障

对方系统短暂波动,我们进行重试,看起来是良好设计。但请考虑这种情况。

게이트웨이:   재시도 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 中的消息放回去?

  1. 分类——按原因区分系统错误与数据错误
  2. 判断——修正后重新投入,还是要求源系统重发
  3. 安全确认——重处理是否幂等。 否则会造成重复处理
  4. 执行——先少量处理;一次全部放回,可能因相同原因再次堵塞
  5. 记录——谁在何时重处理了多少条

第 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
);

设计的核心,是选择什么作为幂等键

而且必须通过数据库约束(PRIMARY KEY / UNIQUE)阻止重复。 应用层的 if 조회 then 없으면 insert 在两个请求同时到达时会被绕过,因为查询与插入之间存在空隙。约束会消除这道空隙,即使应用有缺陷,也无法绕过。

保存后返回(store and return)

再进一步,对重复请求可以原样返回首次响应

1. msg_id 로 조회
2. 있으면 → 저장된 response 를 그대로 반환 (처리 안 함)
3. 없으면 → 처리하고, 결과를 response 에 저장

这样一来,发送方重发就完全安全。因超时没有收到响应时,直接再发送一次即可。这正是 Idempotency-Key 请求头约定所做的事,也是支付 API 采用这种方式的原因。

幂等记录的保留期限

inbox_log 会无限增长,必须清理。但清理某段记录的瞬间,那一时间段的重复防护也随之消失。

因此,保留期限应长于现实中可能发生重发的最长时间。如果对方的重处理政策允许重发最多 7 天前的数据,保留 30 天较为安全。清理批处理只能按时间条件删除;“从最旧的开始删除 N 条”在流量突然增加时,可能误删较新的记录。

产生重复的四条路径

最后,总结实际工作中重复从何而来。

  1. 重试——超时后重发,尤其是仅响应丢失的情况
  2. 队列的 at-least-once——代理未收到确认而重新投递
  3. 运维人员手动重发——故障后要求“先再发一次”
  4. 发送方重新执行批处理——从头重跑失败批次

第 4 种规模最大。批处理完成一半后失败,整体重跑会让前一半全部重复。因此,批处理要么记录重启位置,要么依赖接收方幂等性;后者通常更现实。

在实际项目中

最常见的情况是:没有人专门设计重试,却在三层都开启了重试。 网关默认值、HTTP 客户端库默认值,再加上我们的代码。每层合理的 3~4 次相乘后会变成几十次,于是事故处理中会出现“我们明明只发了一次,为什么对方日志有 40 条”的对话。

其次,是重试了不该重试的错误。格式错误与认证失败即使发送一百次也不会改变,但重试逻辑若不区分状态码,无意义请求就会填满对方日志并堵塞我方队列。

最后必须懂得放弃。送入 DLQ 时若不同时记录原因、尝试次数与首次失败时间,几周后打开文件的人就无法判断:这条消息能否安全地重新投入。