【背景】
我负责一款 fintech 产品的 PM,核心链路有三条:创建订单、扣款、发短信通知。当前幂等方案是客户端生成 UUID 作为 idempotency key,服务端按 key 去重。技术栈:iOS/Android 原生 + 后端 Go(gRPC + HTTP 网关),日均请求量约 200w,其中扣款链路 P99 延迟 800ms、P999 偶尔飙到 3s(下游银行网关抖动)。
【现象(带数据)】
- 过去 3 个月,客服工单中「重复扣款」约 47 笔、「重复短信」约 210 条,集中在 4G/弱网用户;
- 抓包复盘发现根因链路:
- 客户端生成 UUID-A → 发起请求 → 服务端处理成功(扣款/发短信)→ 响应在途丢失(弱网)→ 客户端超时(3s);
- 客户端重试逻辑是重新生成 UUID-B(而不是复用 UUID-A)→ 服务端视为新请求 → 再次扣款/发短信;
- 部分用户甚至 app 被系统杀进程(内存回收),重启后「再点一次」也是新 UUID。
- 核心矛盾:经典幂等设计假设客户端重试时复用同一个 key,但移动端实际行为是「每次尝试都生成新 key」。
【已尝试的排查与结果】
-
服务端改用业务三元组 (user_id, operation_type, business_ref) 做幂等键(不再依赖客户端 UUID):
- 效果:解决了「同一次业务意图」的去重;
- 问题:用户合法取消后重新下单(business_ref 相同但意图不同)被误拦截,客诉 +12 笔/周;
- 结论:纯业务键太粗,需要区分「重试」和「新意图」。
-
客户端在发请求前把 UUID 持久化到本地(SQLite / Keychain),重试时读回同一个 UUID:
- 效果:理论上解决了 key 丢失;
- 问题:(a) 持久化写入和请求发出之间仍有 crash 窗口(约 2-5ms);(b) 用户卸载重装 app 后本地 key 全丢;(c) 双开/多设备场景 key 不一致;
- 结论:客户端持久化只能降低概率,不能根除。
-
网关层加「请求指纹」= hash(user + path + 关键 body 字段 + 时间窗 60s):
- 效果:对扣款(amount 固定)有效;
- 问题:对「用户改金额后重新提交」误杀(body 不同 → 指纹不同 → 但 60s 窗内被拦截);对「同一用户 60s 内合法下两单」也误杀;
- 结论:时间窗方案在 C 端场景误伤率太高(实测 0.3% 合法请求被拦)。
【期望得到的答案形态】
- 业界在「客户端不可信 + 弱网 + 非幂等操作(扣款/发短信)」场景下,幂等设计的标准范式是什么? 是服务端「意图注册表」?是银行侧的「指令号」模式?还是有更轻量的做法?
- 如何区分「同一次业务意图的重试」和「用户取消后的新意图」? 纯靠客户端传 key 显然不够,纯靠业务键又太粗——中间有没有一个「意图 ID」的生成和传递机制,既不依赖客户端存活,又能区分新旧意图?
- 给一个最小可行方案(MVP):假设我只能改服务端 + 网关(客户端改动排期 2 个月),在扣款链路上,怎样做到「弱网重试不会重复扣款 + 用户合法重新下单不被拦截」,误拦率 < 0.01%?请给出具体表结构 / 接口字段 / 状态机(哪怕是伪代码)。
- 如果涉及银行/支付通道侧的幂等(如银行流水号),客户端-服务端-银行三方的幂等 key 应该怎么对齐?
【参考】
林砚舟的《超时与重试》(id=23)提到「重试不是加个配置就完事,是容量规划的一部分」,我也认同。但我的卡点不在超时/退避策略本身,而在重试时幂等 key 的生命周期断裂——key 可能从未到达服务端。