同一笔转账出去了两次 — 做拦住它的那一侧
目标
亲手做出一个层,使客户端重试导致同一个转账请求到来两次时,钱也只划出一次。加上幂等键表与 UNIQUE 约束、请求正文的规范化指纹、处理中状态、响应重放,以及键的范围和保存期限,并把一整天的请求日志重新放一遍,证明重复转账为 0。
为什么重要
收不到响应的客户端会重新发送。没有办法阻止这一点,也不应该阻止。RFC 9110 不把 POST 看作幂等,所以协议不提供任何保证——保证由应用程序来做。 做法的骨架,IETF 草案(draft-ietf-httpapi-idempotency-key-header)已经整理好了。键由客户端生成,指纹由服务端生成,对已完成的键的重试重放存好的响应,对处理中的键的重试用冲突来回答。 本实验难的不是代码,而是边界。先查询再插入,会漏掉并发的重试;先把键记成完成,崩溃时钱就永远划不出去;不看指纹,金额变了的请求就会被悄悄忽略。 评分器不会相信你写的文字。它会在临时目录里搭起评分器自己做出的账户数据库,每次都用不同的账号、金额和键,真的运行你的脚本,把响应 JSON、转账表和余额,与它直接量出的值对照。
步骤
- 创建并运行 /root/idem/gen_requests.py,生成 /root/idem/idem.db。其中包含 8 个账户、120 个请求(100 个互不相同的键)和当天的 120 笔转账。
- 统计重试造成的重复转账,用 requests、unique_keys、duplicate_keys、extra_transfers、double_paid 和 keys 写入 /root/idem/dup_report.json。
- 创建 /root/idem/idem_api.py,借助幂等键表的 UNIQUE 约束,让同一个键的第二个请求无法产生转账。键不同则不拦截。
- 让 idem_api.py 保存请求正文的规范化指纹,并在同一个键带着不同正文时,用 422 拒绝。只是字段顺序不同的同一个正文,就是同一个请求。
- 把 idem_api.py 的键记录分成先占(in_progress)和完成(completed)两个阶段,并让它对处理中的键的重试,以 409 in_progress 作答。必须能用
--crash-after-claim造出处理之前崩溃的情形。 - 让它对已完成的键的重试原样重放存好的响应。status 与第一次的响应相同,replay 为 true,正文一个字都不能不同。
- 把键的范围扩大为 (client_id, endpoint, idem_key),并用
--purge-before删除保存期限已过的键。策略写入 /root/idem/policy.json。 - 用 /root/idem/replay_day.py 把 120 个请求一个不漏地重新放一遍,生成 /root/idem/day.db 和 /root/idem/result.json,并用 /root/idem/idem_report.md 分四节写出报告。
参考
- 运行约定:
python3 /root/idem/idem_api.py --db <DB> --request <요청 JSON>(占位符为请求 JSON)会在标准输出中输出一块响应 JSON,并以退出码 0 结束。如果无法读取请求文件,则为 3。 - 请求 JSON:
{"client_id": …, "endpoint": …, "idem_key": …, "body": {"src": …, "dst": …, "amount": 정수, "currency": "KRW"}}(占位符为整数) - 响应 JSON:
{"status": 정수, "replay": true|false, "reason": 문자열|null, "body": 객체|null}(占位符依次为整数、字符串、对象)。如果是重新执行,则 status 为 201、replay 为 false,body 中有 transfer_id。 - 表名和列:
account(acct_id, holder, balance)、transfer(transfer_id, src, dst, amount, currency, created_at),幂等键表由你来定。评分器只预先填好 account 表,其余的期望由你的脚本来创建。 - 锁:Python 的 sqlite3 默认是延迟事务。在先占这种确定要写入的地方,用
BEGIN IMMEDIATE,等待则用PRAGMA busy_timeout来处理。 - 常见错误:先查询再插入(会漏掉并发重试),先把键记成完成,用原始字符串计算指纹,重放时重新计算响应。
- 保存期限 72 小时和三列的范围,是本实验的假设。IETF 草案只写了要公开说明过期策略,并没有确定数字。
- 不要做压力测试。每次评分的预算是 60 秒,Pod 是 2 核。
生成当天的请求日志
创建并运行 /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 次进程。报告里要用数字写出这次拦下的金额。