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

银行现场的语言

同一笔转账出去了两次 — 做拦住它的那一侧

在 TT Lab 中继续学习

目标

亲手做出一个层,使客户端重试导致同一个转账请求到来两次时,钱也只划出一次。加上幂等键表与 UNIQUE 约束、请求正文的规范化指纹、处理中状态、响应重放,以及键的范围和保存期限,并把一整天的请求日志重新放一遍,证明重复转账为 0。

为什么重要

收不到响应的客户端会重新发送。没有办法阻止这一点,也不应该阻止。RFC 9110 不把 POST 看作幂等,所以协议不提供任何保证——保证由应用程序来做。 做法的骨架,IETF 草案(draft-ietf-httpapi-idempotency-key-header)已经整理好了。键由客户端生成,指纹由服务端生成,对已完成的键的重试重放存好的响应,对处理中的键的重试用冲突来回答。 本实验难的不是代码,而是边界。先查询再插入,会漏掉并发的重试;先把键记成完成,崩溃时钱就永远划不出去;不看指纹,金额变了的请求就会被悄悄忽略。 评分器不会相信你写的文字。它会在临时目录里搭起评分器自己做出的账户数据库,每次都用不同的账号、金额和键,真的运行你的脚本,把响应 JSON、转账表和余额,与它直接量出的值对照。

步骤

  1. 创建并运行 /root/idem/gen_requests.py,生成 /root/idem/idem.db。其中包含 8 个账户、120 个请求(100 个互不相同的键)和当天的 120 笔转账。
  2. 统计重试造成的重复转账,用 requests、unique_keys、duplicate_keys、extra_transfers、double_paid 和 keys 写入 /root/idem/dup_report.json。
  3. 创建 /root/idem/idem_api.py,借助幂等键表的 UNIQUE 约束,让同一个键的第二个请求无法产生转账。键不同则不拦截。
  4. 让 idem_api.py 保存请求正文的规范化指纹,并在同一个键带着不同正文时,用 422 拒绝。只是字段顺序不同的同一个正文,就是同一个请求。
  5. 把 idem_api.py 的键记录分成先占(in_progress)和完成(completed)两个阶段,并让它对处理中的键的重试,以 409 in_progress 作答。必须能用 --crash-after-claim 造出处理之前崩溃的情形。
  6. 让它对已完成的键的重试原样重放存好的响应。status 与第一次的响应相同,replay 为 true,正文一个字都不能不同。
  7. 把键的范围扩大为 (client_id, endpoint, idem_key),并用 --purge-before 删除保存期限已过的键。策略写入 /root/idem/policy.json。
  8. 用 /root/idem/replay_day.py 把 120 个请求一个不漏地重新放一遍,生成 /root/idem/day.db 和 /root/idem/result.json,并用 /root/idem/idem_report.md 分四节写出报告。

参考

生成当天的请求日志

创建并运行 /root/idem/gen_requests.py,生成 /root/idem/idem.db。其中有 8 个账户、120 个请求(100 个互不相同的键)和当天的 120 笔转账。

先创建 /root/idem,并在其中用 python3 生成 sqlite DB。表有 account、req_log 和 transfer_v1 三张。req_log 是网关收到的请求原样,所以重试也各占一行,transfer_v1 是这些请求实际产生的转账。

统计重试造成的重复转账

用 requests、unique_keys、duplicate_keys、extra_transfers、double_paid 和 keys 写入 /root/idem/dup_report.json。duplicate_keys 是产生了两笔以上转账的键的个数,keys 是这些键的列表。

extra_transfers 是每个键去掉第一笔转账之后剩下的笔数。double_paid 是这些剩下的转账的金额之和。用从 SQLite 3.25 起可以使用的 ROW_NUMBER() OVER (PARTITION BY … ORDER BY …),在键内给转账排出第几笔,一次就能得到。

拦住同一个键的第二个请求

创建 /root/idem/idem_api.py,借助幂等键表的 UNIQUE 约束,让同一个键的第二个请求无法产生转账。键不同的话,即使正文相同也不拦截。

先查询、没有再插入的方式,会让同一时刻到来的两笔都通过。请直接对以键为主键的表执行 INSERT,把违反约束的异常(sqlite3.IntegrityError)当作“已经存在”的信号。第二个响应,把 replay 设为 true,或者以 409 作答都可以。

同一个键但正文不同时拒绝

让 idem_api.py 把请求正文的规范化指纹与键一起保存,并在同一个键带着不同正文时,用 422 拒绝。只是字段顺序不同的同一个正文,必须看作同一个请求。

如果用原始字符串计算指纹,只是字段顺序或空白不同,也会变成不同的请求。RFC 8785 所规定的规范化,核心是键排序和去掉空白。在 Python 里,可以用 json.dumps 的 sort_keys 和 separators 来近似,再把那段字节序列放进 sha256。

处理中的键与并发重试

把键记录分成先占(in_progress)和完成(completed)两个阶段,并让它对处理中的键的重试,以 409 和 reason in_progress 作答。--crash-after-claim 必须只做先占,不产生转账,并以非 0 的退出码结束。

如果先把键记成完成,转账过程中崩溃时,钱没有划出去,重试却得到“已经处理过”。把先占事务和执行事务分开,中间状态就会留在表里。评分器也会发送在同一时刻到来的两个重试——它们当中重新执行的,必须恰好是一个。

原样重放存好的响应

让它对已完成的键的重试,原样返回存好的响应。status 与第一次的响应相同,replay 为 true,body 不能与第一次的响应有一个字的差别。

重放不是重新计算。要在完成的时刻把响应正文整个存下来,再原样取出来用。如果重新计算,余额或时间就会变,客户的画面会出现两次不同的样子。处理中的键仍然必须是 409。

确定键的范围和保存期限

把键的范围扩大为 (client_id, endpoint, idem_key),并让 --purge-before <RFC 3339 시각>(占位符为 RFC 3339 时间)删除比它更早的键,输出 {"purged": 개수}(占位符为个数)。策略以 scope、retention_hours(72)、on_fingerprint_mismatch(422) 和 on_in_flight(409) 写入 /root/idem/policy.json。

如果键是全局的,别的客户碰巧发来同样的字符串时,就会拿到别人的响应。把主键改成三列,就有了范围。保存期限一过,把键删除,之后同样的请求就是新请求——所以保存期限必须比客户端的重试上限更长。

把一整天重新放一遍来证明

用 /root/idem/replay_day.py 把 120 个请求一个不漏地重新发送,生成 /root/idem/day.db 和 /root/idem/result.json,并在 /root/idem/idem_report.md 中用 ## 무엇이 잘못됐나 ## 어떻게 막았나 ## 남은 위험 ## 운영 규칙 四节写出来。

挑选请求就无法构成证明。把 req_log 按 seq 顺序全部发送,并把结果统计为 requests、created、replayed、rejected、transfers、total_transferred、v1_total 和 double_paid_avoided。把 idem_api.py 作为模块导入使用,就不需要启动 120 次进程。报告里要用数字写出这次拦下的金额。