实现重复接收的防护
目标
亲手制造一次重复扣款,然后使用幂等键阻止它,并处理并发请求及正文不一致的情况,完成达到真实支付 API 水平的防护。
为什么重要
在跨越网络的传递中,纯粹意义上的恰好一次几乎不可能实现。因为发送方在没有收到确认响应时,无法区分“未送达”和“已经送达、只是响应丢失”。所以,实际工作的标准答案很明确——以至少一次的方式发送,并由接收方去重。本实验最微妙的部分是第 5 步。先查询键、不存在时再保存的朴素实现,在两个请求同时到达时会让两者都通过。必须使用一次 Redis 的 SET ... NX,以原子方式合并查询和抢占。仅这一行的差异,就决定了促销期间是否会收到重复扣款投诉。
步骤
- 在 127.0.0.1:8130 上启动
/root/idem/pay.py。启动时将 Redis 键balance初始化为 10000;POST /pay接收{"amount":100},扣减余额,并返回{"charged":100,"balance":<잔액>}。 - 在没有幂等性的情况下发送两次相同请求,将结果写入
/root/idem/double.out,格式为balance_before=10000 balance_after=9800。 - 修改
pay.py,要求提供Idempotency-Key请求头。没有请求头时返回 400。 - 使用同一个键发送两次时,应原样重复第一次响应,并且余额只扣减一次。在
/root/idem/dedup.out中写入balance_after=9900 calls=2。 - 即使使用同一个键同时发送两个请求,也只能处理一次。在
/root/idem/concurrent.out中写入processed=1 balance_delta=100。 - 使用同一个键发送不同金额时返回 422。在
/root/idem/mismatch.out中写入status=422。 - 幂等键的剩余生存时间必须大于 0 且不超过 86400。在
/root/idem/ttl.txt中写入ttl=<초>。
参考
- 原子抢占:
SET idem:<key> IN_PROGRESS NX EX 86400——如果该命令返回 0,说明已经有人完成了抢占。 - 制造并发请求:
curl ... & curl ... & wait - 对确定性失败(余额不足)缓存响应;对临时失败(超时)释放键,使重试能够真正再次尝试。
- 常见错误:查询后再保存的两步实现——两步之间就是竞态区间。
启动支付服务
在 127.0.0.1:8130 上启动 /root/idem/pay.py。启动时将 Redis 键 balance 初始化为 10000;POST /pay 接收 {"amount":100},扣减余额,并返回 {"charged":100,"balance":<잔액>}。
将余额存入 Redis,即使重新启动也能保持状态。请将初始余额初始化为指定值。
重现重复扣款
在没有幂等性的情况下发送两次相同请求,将结果以 /root/idem/double.out 中的 balance_before=10000 balance_after=9800 格式写入。
同一个支付请求发送两次,余额就会扣减两次。这就是必须阻止的状态。
接收幂等键请求头
修改 pay.py,要求提供 Idempotency-Key 请求头。没有请求头时返回 400。
没有请求头时以 400 拒绝会更安全。强制客户端创建键就是接口契约。
同一个键只处理一次
使用同一个键发送两次时,应原样重复第一次响应,并且余额只扣减一次。在 /root/idem/dedup.out 中写入 balance_after=9900 calls=2。
原样返回针对该键保存的第一次响应。第二次请求不得改变余额。
并发请求也只处理一次
即使使用同一个键同时发送两个请求,也只能处理一次。在 /root/idem/concurrent.out 中写入 processed=1 balance_delta=100。
如果先查询再保存,其他请求会在两者之间插入。必须用一条原子命令完成抢占。
键相同但正文不同时拒绝
使用同一个键发送不同金额时返回 422。在 /root/idem/mismatch.out 中写入 status=422。
同时保存第一次请求的正文哈希即可进行比较。状态码为 422。
设置键过期时间
幂等键的剩余生存时间必须大于 0 且不超过 86400。在 /root/idem/ttl.txt 中写入 ttl=<초>。
永久保存键会让内存无限增长。惯例是 24 小时。请查询剩余生存时间进行确认。