TT Lab
开始
学习 学习路径 课程

幂等性 — 点两次也只扣一次款

按两次也只生效一次

在 TT Lab 中继续学习

目标

在网络中,重试不可避免。 当客户端没有收到响应时, 它无法区分请求根本没有到达,还是请求已到达、只有响应没能返回。

因此,重复请求必须由服务器阻止。 本实验会逐步修复一个特意设计为 非幂等的支付 API。

开始

cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1        # 키 k1 로 결제
./ledger.sh          # 지금까지 적립된 것

请加上 setsid nohup。如果只用 & 启动,切换 Shell 时服务器也会一起终止。

需要修改的位置

只修改 server.py 中的 handle_pay 一个函数。idem 表已经创建好, 其中包含键、请求哈希、状态、响应和时间。

重新启动服务器时

ps -eo pid,args | awk '$2 ~ /python3$/ && $3 == "server.py" {print $1}' | xargs -r kill

不要使用 pkill -f server.py。该模式还会匹配执行这条命令的 Shell 命令行,从而杀死它自己。

步骤

  1. 复现重复支付 → 01-duplicate.txt
  2. 相同键返回相同答案 → 由评分器直接确认
  3. 没有键时拒绝 → 03-nokey.txt
  4. 相同键、不同正文 → 04-conflict.txt
  5. 并发请求 → 05-race.txt
  6. 重启后仍能记住 → 06-persist.txt
  7. 重试策略 → 07-retry.md
  8. 总结 → 08-notes.md

参考

第 2、5、6 步会由评分器直接启动服务器并发送请求。 复制日志无法通过。

重试会造成重复支付

启动服务器,发送两次相同请求,然后检查账本并记录到 01-duplicate.txt。

cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1
./probe.sh k1
./ledger.sh

payment_id 会增加为 1、2,账本中会出现 2 条记录。客户端认为自己只支付了一次,因为它只是由于超时而重试。

网络中的重试不可避免。没有收到响应时,客户端无法区分请求根本没有到达,还是请求已到达但只有响应没能返回。

相同键返回相同答案

修改 server.py 的 handle_pay,使请求以相同 Idempotency-Key 到达两次时,第二次不再记账,而是原样返回第一次的响应。

idem 表已经创建好,其中包含键、请求哈希、状态、响应和时间。

如果是首次请求,就记账并保存响应。如果键已存在,则原样返回保存的响应,不要新建记录。

确认方式:发送两次后,./ledger.sh 中有1 条记录即可。而且两次响应的 payment_id 必须相同。

没有键时拒绝请求

让服务器以 400 拒绝没有 Idempotency-Key 的请求,并记录到 03-nokey.txt。

不带参数调用 ./probe.sh,就会发送没有键的请求。

如果允许涉及资金的请求选择是否提供键,就一定会出现不发送键的客户端。 随后,该客户端会造成重复支付。最好从一开始就将键设为必填。

相同键对应不同正文时

使用相同键发送不同金额时,让服务器以 422 拒绝,并记录到 04-conflict.txt。

把请求正文的哈希保存到 idem.request_hash 后进行比较。

./probe.sh k1 '{"user":"u1","amount":1000}'
./probe.sh k1 '{"user":"u1","amount":99999}'

如果悄悄放行第二次请求,就会把金额 1000 韩元的响应返回给金额 99999 韩元的请求。复用键的缺陷会被悄无声息地掩盖。

相同键同时到达时

确认即使使用相同键同时发送 10 个请求,账本中也只有1 条记录,并写入 05-race.txt。

pids=""
for i in $(seq 10); do ./probe.sh race >/dev/null 2>&1 & pids="$pids $!"; done
wait $pids
./ledger.sh

wait 后面务必写上 PID。如果只写 wait,它连第 1 步在后台启动的服务器也会等待;服务器不会结束,因此终端会一直卡住。

“先查询,不存在就插入”这种简单方式会在这里失效,因为十个请求会同时读到“不存在”。

正确做法是为键设置唯一约束,让插入失败的一方等待,或者用锁包住操作。idem.key 已经是主键。

进程重启后还能否记住

完成一次支付后关闭并重新启动服务器,再用相同键发送请求,将仍然不会产生重复记录的结果写入 06-persist.txt。

如果使用内存字典实现,这一步就会失败,因为重启后会忘记记录。

实际工作中还有一个更常见的失败原因:服务器有多台。1 号 Pod 记住的内容,2 号 Pod 并不知道。因此,记录必须保存到所有实例共同可见的位置(本实验使用 sqlite,实际环境使用 DB 或 Redis)。

哪些错误应该重试

在 07-retry.md 中区分可以重试和不应重试的响应,并分别写明理由。至少写 4 种。

需要思考:500、503、429、400、422,以及完全没有收到响应的情况。

提示:重试 400 会永远得到 400。重试时应当逐渐增加间隔(exponential backoff),并加入随机抖动(jitter),避免多个客户端同时重试。否则,重试会再次压垮正在恢复的服务器。

总结三个要点

在 08-notes.md 中至少写三行:为什么重试不可避免、幂等键应保存在哪里,以及相同键对应不同正文时为什么必须拒绝。

正文中必须包含 재시도、저장、본문。