背景
3 年前我负责一个支付回调服务,经历过一次典型的「重试风暴」:上游网关超时后自动重试,叠加用户手动刷新,下游业务层 10 分钟内收到同一笔订单的 47 次「新请求」。
复盘时发现根因不是「幂等没做」,而是幂等做在了两层,但两层的「一次调用」语义对不齐:
- 网关层:同一个
X-Request-ID视为同一次调用,幂等窗口 30s - 业务层:同一个
order_id视为同一次调用,幂等窗口 24h
问题出在一次上游网关自身重试:超时后它复用了旧的 X-Request-ID 重发,网关认为「这是同一次调用」,直接返回了上次缓存的响应——但那次响应是「处理中」,不是「成功」。业务层根本没收到这次请求,于是又完整处理了一次。
结果:同一笔订单,DB 里多了两条流水。事后排查花了 2 天,因为两层日志里「看起来都只处理了一次」。
常见分歧 / 几种做法
| 做法 | 描述 | 优点 | 风险 |
|------|------|------|------|
| A. 只在业务层做幂等 | 网关不做幂等,所有幂等逻辑下沉到业务层 | 语义统一,实现简单 | 重复请求穿透到业务层,放大下游 DB / MQ 压力 |
| B. 只在网关层做幂等 | 业务层不做幂等,完全信任网关 | 下游最轻 | 网关宕机、旁路调用、内部服务间调用都会绕过,单点风险极高 |
| C. 两层都做,各自为政 | 网关用 request-id,业务用业务键 | 看似双保险 | 「对齐窗口」是隐式的,两层窗口不一致时(如 30s vs 24h)会出现「网关放行 + 业务层也放行」的窗口 |
| D. 两层都做,显式对齐 | 网关记录 request-id → 业务键 映射,业务层查询时优先查网关 | 语义最清晰 | 复杂度高,网关成为幂等状态的「事实源」,可用性要求极高 |
我的倾向与理由
我倾向 做法 C + 一条硬规则:两层都做,但「最终裁判」必须是业务层。
核心理由:
- 语义不同,强行对齐是脆弱的。 网关层看到的是「网络语义」(同一个 TCP 连接、同一个 request-id),业务层看到的是「业务语义」(同一笔订单、同一个支付单号)。这两者本质不同,用网关的 request-id 去「代表」业务操作,在异步 / 回调 / MQ 场景下尤其不可靠。
- 网关层只能「加速去重」,不能「替代判断」。 它的职责是在 30s 窗口内快速拦截「明显的网络层重复」,减轻下游压力;但它不应该、也没有能力判断「这笔业务操作是否已经成功完成」。
- 业务层的幂等键必须是「业务不可变键」(如
order_id + payment_no),不能是「请求级键」(如request-id、trace_id)。前者有业务语义,后者只有网络语义。
做法 C 的「对齐窗口」问题,我的落地方案:
- 网关层记录
request-id → {业务键, 响应状态}的映射,TTL 设为业务层幂等窗口的 1/10(如业务层 24h,网关层 1.44h) - 关键规则:网关层返回响应时,如果响应状态是「处理中 / 异步 / 202 Accepted」,不缓存、不拦截,让后续重试穿透到业务层——因为只有业务层才能判断「处理中 → 成功」的转换是否完成
- 业务层每次处理前,先查「这个业务键最近 N 分钟是否已被处理过」,是则直接返回上次结果,不再写 DB
- 加一条可观测性指标:
gateway_passthrough_but_business_rejected(网关放行但业务层拒绝的次数),这个指标持续增长说明两层对齐窗口有问题
幂等键的安全属性(修订补充)
原文发布时漏掉了这一节,这里补上。幂等键不只是「功能正确性」问题,它同时是安全边界:
- 幂等键必须不可预测。 若 idem key 由客户端生成且可预测(自增序、时间戳、
UUIDv1),攻击者可预知下一条 key 抢先SETNX占位,造成竞态或拒绝合法请求。建议:- 幂等键用服务端 CSPRNG 生成(Java
SecureRandom、Nodecrypto.randomBytes),不要信任客户端传值; - 如果必须客户端生成(如 Webhook 场景),至少用
UUIDv4(122 位随机),且服务端对 key 做归属校验(tenant_id + user_id必须匹配当前会话)。
- 幂等键用服务端 CSPRNG 生成(Java
- Redis key 必须带租户隔离。
SETNX idem:{order_id}在多租户 SaaS 下是越权漏洞——A 租户的order_id如果和 B 租户的碰撞,B 的幂等检查会命中 A 的记录。正确写法:idem:{tenant_id}:{order_id},且tenant_id从服务端 session 取,不从请求头取。 - 幂等缓存本身是 DoS 放大器。 高 QPS 下,如果幂等键的 Redis 查询走网络 RTT(0.5–2ms),每次请求多 1 次 Redis 调用。建议:
- 网关层用本地 Caffeine(TTL 30s,容量 10w),热 key 走本地、冷 key 走 Redis;
- 业务层用 DB 唯一索引兜底(
INSERT ... ON DUPLICATE KEY UPDATE或INSERT IGNORE),不依赖 Redis 的「先查后写」——if (exists) return; else { insert; }在并发下两个请求同时查都返回 false,两个都写。
202 穿透规则的具体实现(修订补充)
原文只说了「202 Accepted 不缓存、不拦截」,这里补一个可直接落地的伪代码:
// 网关层幂等中间件(简化版)
public Object handle(Request req) {
String rid = req.header("X-Request-ID");
CachedEntry entry = idemCache.get(rid); // 本地 Caffeine + Redis fallback
if (entry != null) {
// 关键:只有「终态」响应才拦截
if (entry.status == 200 || entry.status == 400 || entry.status == 422) {
return entry.response; // 直接返回缓存,不穿透
}
// 202 / 503 / 504 → 不拦截,穿透到业务层
// 业务层负责判断「处理中 → 成功」的转换
}
Object resp = proxyToBusiness(req);
// 只缓存「终态」响应到幂等映射
if (isTerminal(resp.status)) {
idemCache.put(rid, new CachedEntry(resp, resp.status));
}
return resp;
}
private boolean isTerminal(int status) {
return status == 200 || (status >= 400 && status < 500);
// 202 / 503 / 504 不算终态 → 不缓存 → 下次重试穿透
}
为什么 503/504 也不算终态? 因为「服务暂时不可用」不代表「这笔业务操作失败」——重试后可能成功。如果网关把 503 缓存了,后续重试全部命中 503,业务层永远收不到这笔请求。
MQ 消费场景的幂等(修订补充)
原文第 3 问提到了 MQ 消费场景,这里补一个具体做法:
MQ 的「消费端处理了但 ack 丢了」窗口,HTTP 幂等键不直接适用(MQ 没有 request-id),正确做法:
- 幂等键 =
topic-partition-offset(或msgId),这是 MQ 层面的「业务不可变键」; - 消费端处理前:
SETNX mq_idem:{topic}:{partition}:{offset} EX 86400,已存在则跳过; - 关键:ack 必须在「业务落库成功」之后发,不能之前。顺序:
处理 → 落库 → SETNX → ack。如果 SETNX 和 ack 之间崩溃,重投后 SETNX 命中 → 跳过 → 安全;如果处理完但 SETNX 没发,重投后重新处理 → 靠 DB 唯一索引兜底。 - 反例:先 ack 再处理。ack 丢了 → 消息不重投 → 丢失;ack 发了但处理失败 → 消息不重投 → 丢失。这是最危险的顺序。
想听大家在什么场景下怎么选
- 你们的幂等键在哪一层定义? 只做一层还是两层?如果是两层,对齐窗口是怎么定的?
- 有没有遇到过「两层幂等不对齐」导致的事故? 根因是什么?事后怎么修的?
- 异步场景(MQ 消费、Webhook 回调)下,幂等键怎么设计才不丢不重? 特别是「消费端处理了但 ack 丢了」这种窗口,你们怎么防?
- 网关层的幂等缓存,你们用什么存? Redis?本地 Caffeine?一致性怎么保证(多副本网关)?
- 你们有没有「网关放行但业务层拒绝」的可观测性指标? 这个指标在你们团队里是常态还是告警?
- 幂等键的生成方是谁? 客户端传的还是服务端生成的?多租户下怎么隔离?(修订补充的问题)
我特别想听做支付 / 电商 / 金融的朋友聊聊:你们在「重试风暴 + 幂等」这个交叉点上,到底是怎么兜底的?