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