Skip to content

idempotency-retry-simulator ​

1. 实验目标 ​

用纯 Node(零三方依赖)模拟「移动弱网支付」闭环,验证理论文档里的核心论断——网络超时 ≠ 失败,重试必须与服务端幂等配套设计:

  • 场景① 无幂等键盲目重试:SocketTimeoutException 后 catch 原样重发 → 服务端重复扣款(复现线上资金事故路径)。
  • 场景② 带 X-Request-Id 重试:同样的超时、同样的自动重试,但整个业务动作复用同一个幂等键 → 服务端去重表拦截,只生效一次。
  • 场景③ 重试前先查单:无幂等键的存量接口,用「订单状态查询」把超时后的「状态未知」收敛为「状态已知」,避免重复扣款。
  • 场景④ 重试风暴对比:200 个客户端同时失败后,固定间隔重试 vs 指数退避 + 随机抖动,对比重试到达的服务端瞬时峰值。

对应 Android / Flutter 真实工程:OkHttp Interceptor / Dio 重试拦截器里「能不能无脑 chain.proceed(request)」,取决于请求是否携带幂等键、服务端是否有去重表。

2. 工程要点 ​

  • 幂等键的生命周期在客户端:进入提交页时生成一次 RequestId,之后所有重试复用同一个值;每次点击重新生成等于没有防重。
  • 服务端两道防线分层:
    • 去重表(第一道) :Map.has(key) 即 Redis SETNX 的内存替身——不存在才执行业务并缓存结果;已存在直接回放缓存响应(idempotentReplay=true),不再触碰账务。
    • 账务流水表(第二道) :每笔真实扣款追加一条流水,重复扣款事故在这里现形;真实工程中由数据库唯一索引兜底并发竞态。
  • 故障建模:「响应在回程丢失」= 服务端正常处理成功但客户端只能看到超时。这正是「超时后状态未知」的本质:客户端无法区分「没送到」和「没处理」。
  • 退避策略即服务端保护:固定间隔让所有客户端在同一窗口集体重试(重试雷暴);指数退避 + Jitter 把到达摊开,瞬时峰值显著下降。

3. 关键实现速览 ​

3.1 服务端幂等闸门:命中去重表直接回放 ​

为什么看这段:防重的关键是「先查重再执行业务」,命中即回放缓存结果,重复请求根本到不了扣款逻辑。

js
function pay(req) {
  const key = req.requestId || null;

  // ① 幂等闸门:命中去重表 → 不再执行业务,直接回放缓存结果
  if (key && idempotencyTable.has(key)) {
    const cached = idempotencyTable.get(key);
    return { status: 200, body: { ...cached.body, idempotentReplay: true } };
  }

  // ② 业务执行:无幂等键、或首次出现的幂等键都会走到这里
  const body = chargeOnce(req.orderId, req.amountCents, key);

  // ③ SETNX 语义:只有首次 set 生效;真实工程由 DB 唯一索引兜底并发竞态
  if (key) idempotencyTable.set(key, { status: 200, body });

  return { status: 200, body };
}

完整源码:server.js

3.2 弱网传输层:制造「已扣款但只见超时」 ​

为什么看这段:事故的根源是这次调用里服务端已经产生了副作用,而客户端拿到的是异常——重试决策必须基于这个事实。

js
function sendViaLossyNetwork(server, req, attempt, loseResponseAtAttempts) {
  const response = server.pay(req); // 服务端总是能收到请求并完成处理
  if (loseResponseAtAttempts.includes(attempt)) {
    return { outcome: 'timeout', error: 'SocketTimeoutException: 响应在回程丢失' };
  }
  return { outcome: 'ok', response };
}

完整源码:client.js

3.3 重试风暴离散事件仿真 ​

为什么看这段:把「重试策略差异」量化成服务端视角的到达直方图——首击流量两种策略相同,拉开差距的是失败后的重试分布。

js
const fixedStrategy = { name: '固定间隔 500ms', nextDelay: () => 500 };
const expBackoffStrategy = {
  name: '指数退避 500*2^(n-1) + Jitter[0,250)',
  nextDelay: (attempt, rand) => 500 * Math.pow(2, attempt - 1) + Math.floor(rand() * 250),
};

Jitter 来自种子化伪随机(mulberry32(42)),保证每次运行输出完全一致,README 摘录可复现。

完整源码:client.js

4. 运行方式 ​

bash
cd labs/node/backend-data/idempotency-retry-simulator
node client.js

无需安装任何依赖,Node >= 18 即可(本机 Node v26.7.0 实测通过)。

5. 可能输出 ​

以下是 node client.js 的真实运行摘录(种子化随机保证逐字节可复现):

text
=== ① 无幂等键:SocketTimeoutException 后盲目重试(复现重复扣款事故)
[client] 第 1 次 POST /pay(无 X-Request-Id)→ SocketTimeoutException(服务端实际已扣款,客户端无法区分“没送到”还是“没处理”)
[client] 未查询订单状态,catch 后直接原样重发…
[client] 第 2 次 POST /pay → 200 {"code":"OK","orderId":"order_1001","amountCents":9900,"requestId":null,"orderChargeCount":2}
[server] 账务流水:order_1001 共 2 笔扣款 ← 一次点击被扣 2 次

=== ② 带幂等键:同样的超时自动重试,服务端去重只生效一次
[client] 第 1 次 POST /pay(X-Request-Id: req_20260823_9d3f)→ SocketTimeoutException
[client] 自动重试,仍携带同一个幂等键…
[client] 第 2 次 POST /pay → 200 {"code":"OK","orderId":"order_2002","amountCents":9900,"requestId":"req_20260823_9d3f","orderChargeCount":1,"idempotentReplay":true}
[server] 账务流水:order_2002 共 1 笔扣款;去重表大小=1 ← 第 2 次请求命中去重表被拦截

=== ③ 无幂等键但「重试前先查单」:把状态未知收敛为状态已知
[client] 第 1 次 POST /pay → SocketTimeoutException(状态未知)
[client] 重试守卫生效:先查单再决定是否重发…
[client] GET /orders/order_3003/charge-count → 1 笔扣款流水
[client] 判定首次请求已被服务端成功处理 → 放弃重发 POST,向用户展示“支付结果确认中”
[server] 账务流水:order_3003 共 1 笔扣款 ← 查单守卫避免了重复扣款

=== ④ 重试风暴对比:基站闪断 t=0 全员首击失败,t=1200ms 恢复(200 个客户端)
[storm] 首击流量(两策略相同):200 个请求在 t=0 同时发出并全部失败
[storm] 固定间隔 500ms                    :重试总量 600 个,重试峰值窗口 [500, 600)ms 内同时到达 200 个
[storm] 指数退避 500*2^(n-1) + Jitter[0,250):重试总量 400 个,重试峰值窗口 [500, 600)ms 内同时到达 85 个
[storm] 结论:重试瞬时峰值 200 → 85(下降 57%),总重试量 600 → 400

6. 预期现象 ​

  • 场景①:两次 POST 都真实执行了扣款,第二次响应里 orderChargeCount=2 且 requestId=null——一次点击、两笔流水,资金事故成立。
  • 场景②:第一次尝试同样超时(钱已扣、去重表已写入),重试命中去重表,响应与首次完全相同并多出 "idempotentReplay":true;账务流水始终只有 1 笔。
  • 场景③:客户端不重发 POST,而是先查到 1 笔已有流水,判定首次已成功——「状态未知」被一次查询收敛,UI 层应展示「支付结果确认中」而不是再次收款。
  • 场景④:两种策略的首击流量相同(200 个);差别全在重试——固定间隔在 `API 幂等性、超时重试与移动端数据最终一致性

10. 完整源码 ​

  • server.js —— 内存版支付服务端:去重表(SETNX 语义)+ 账务流水 + 订单状态查询
  • client.js —— 弱网客户端驱动:三个支付重试场景 + 重试风暴离散事件仿真

站点构建时间:2026/8/24 23:43:17