LabHub
学习 学习路径 课程

队列与异步 API

实现重复接收的防护

在 LabHub 中继续学习

目标

亲手制造一次重复扣款,然后使用幂等键阻止它,并处理并发请求及正文不一致的情况,完成达到真实支付 API 水平的防护。

为什么重要

在跨越网络的传递中,纯粹意义上的恰好一次几乎不可能实现。因为发送方在没有收到确认响应时,无法区分“未送达”和“已经送达、只是响应丢失”。所以,实际工作的标准答案很明确——以至少一次的方式发送,并由接收方去重。本实验最微妙的部分是第 5 步。先查询键、不存在时再保存的朴素实现,在两个请求同时到达时会让两者都通过。必须使用一次 Redis 的 SET ... NX,以原子方式合并查询和抢占。仅这一行的差异,就决定了促销期间是否会收到重复扣款投诉。

步骤

  1. 在 127.0.0.1:8130 上启动 /root/idem/pay.py。启动时将 Redis 键 balance 初始化为 10000;POST /pay 接收 {"amount":100},扣减余额,并返回 {"charged":100,"balance":<잔액>}
  2. 在没有幂等性的情况下发送两次相同请求,将结果写入 /root/idem/double.out,格式为 balance_before=10000 balance_after=9800
  3. 修改 pay.py,要求提供 Idempotency-Key 请求头。没有请求头时返回 400。
  4. 使用同一个键发送两次时,应原样重复第一次响应,并且余额只扣减一次。在 /root/idem/dedup.out 中写入 balance_after=9900 calls=2
  5. 即使使用同一个键同时发送两个请求,也只能处理一次。在 /root/idem/concurrent.out 中写入 processed=1 balance_delta=100
  6. 使用同一个键发送不同金额时返回 422。在 /root/idem/mismatch.out 中写入 status=422
  7. 幂等键的剩余生存时间必须大于 0 且不超过 86400。在 /root/idem/ttl.txt 中写入 ttl=<초>

参考

启动支付服务

在 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 小时。请查询剩余生存时间进行确认。