按两次也只生效一次
目标
在网络中,重试不可避免。 当客户端没有收到响应时, 它无法区分请求根本没有到达,还是请求已到达、只有响应没能返回。
因此,重复请求必须由服务器阻止。 本实验会逐步修复一个特意设计为 非幂等的支付 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
命令行,从而杀死它自己。
步骤
- 复现重复支付 →
01-duplicate.txt - 相同键返回相同答案 → 由评分器直接确认
- 没有键时拒绝 →
03-nokey.txt - 相同键、不同正文 →
04-conflict.txt - 并发请求 →
05-race.txt - 重启后仍能记住 →
06-persist.txt - 重试策略 →
07-retry.md - 总结 →
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 中至少写三行:为什么重试不可避免、幂等键应保存在哪里,以及相同键对应不同正文时为什么必须拒绝。
正文中必须包含 재시도、저장、본문。