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

银行现场的语言

没收到响应的客户端会再发一次

在 TT Lab 中继续学习

一句话总结

幂等键是客户端附在请求上的名牌,服务端靠这个名牌记住“这个请求已经处理过了”,从而阻止第二次执行,并把第一次的响应原样返回。

为什么需要它

向转账 API 发出请求的客户端收不到响应,并不少见。可能是网关的超时比服务端处理的时间短,可能是负载均衡器断开了连接,也可能是手机进了地下室。这时客户端知道的事实只有一个——没有收到响应。钱到底有没有划出去,它并不知道。

这时客户端剩下的选择有两个。放弃,或者重发。如果放弃,用户会认为“转账没成功”,于是再点一次。结果请求无论如何都会再发一次。所以,消除重试本身不是答案,让重试变得安全才是答案。

RFC 9110 的 9.2.2 节 以方法为单位定义了这种性质。同一个请求发送多次,对服务端产生的预期效果与只发送一次相同,那么这个方法就是幂等的(idempotent),PUT、DELETE 和安全方法都属于这一类。同一节还写道,幂等方法之所以被区分出来,是因为在读取响应之前通信中断时,可以自动重试,并明确指出,非幂等的方法,客户端不得随意自动重试。转账是 POST。也就是说,协议不提供任何保证。保证必须由我们自己来做。

工作原理

做法很早以前就有了结论。客户端为每个请求附上一个唯一的键,服务端把这个键存下来。把这种惯例整理成文档的,是 IETF 草案 draft-ietf-httpapi-idempotency-key-header。草案不是标准——它没有 RFC 编号,内容也可能改变。尽管如此,它是目前处理这个问题时整理得最好的文章,所以在实务中起着共同语言的作用。

草案确定的骨架有四点。

错误码也是草案提议的。同一个键带来不同的正文,是 422(Unprocessable Content);前一个请求仍在处理,是 409(Conflict)。两者的区别在于,客户端要做的事不同。422 需要修改请求,409 则没什么可改的,稍后再问一次就行。

在存储这一侧,这个设计的核心只有一句。在键表上加 UNIQUE 约束,把第二次 INSERT 失败这件事本身当作判定。“先查询,没有再插入”,在查询与插入之间有空隙,同一时刻到来的两个重试,都会看到“没有”,然后都去执行。SQLite 的 ON CONFLICT 子句 把违反约束时怎么办分成 ROLLBACK、ABORT、FAIL、IGNORE、REPLACE 五种,默认值是 ABORT。这里要注意的是 INSERT OR IGNORE。它会悄悄跳过冲突的行,代码就无法知道这是重试还是新请求。我们需要的是异常——在 Python 里,它以 sqlite3 的 IntegrityError 的形式抛上来。

요청 + Idempotency-Key
      │
      ├─ 키 선점 INSERT 성공  →  이체 실행 → 응답 저장(completed) → 201
      └─ UNIQUE 위반          →  지문 다름  → 422
                                 처리 중    → 409
                                 완료됨    → 저장한 응답을 그대로 재생

在现场相遇的样子

第一,不看指纹的实现最多。如果只看键,就回答“已经有了,所以成功”,那么柜台职员改了金额、在同一个画面上重新发出的请求,就会被悄悄忽略。客户以为自己转了 30 万韩元,实际划出去的是 20 万韩元。这种事故在日志里也不会留下错误。

第二,用字符串来比较正文。如果客户端库改变了 JSON 字段的顺序或空白,同一个请求就会得到不同的指纹,422 就会铺天盖地地出现。所以指纹要在规范化之后再计算。RFC 8785(JSON Canonicalization Scheme) 对这种规范化作了规定——把对象的键按码点顺序排序,去掉空白,把数字和字符串的写法固定成一种,然后以 UTF-8 序列化。在 Python 里,json.dumps(obj, sort_keys=True, separators=(",", ":")) 是实务中可用的近似做法(它并没有完全遵循 RFC 8785 的数字写法规则)。

第三,没有处理中的状态。如果先把键记成“完成”,再执行转账,那么执行过程中进程一旦崩溃,就只剩下键。此后的重试会得到“已经处理过”的回答,而钱永远不会划出去。反过来,如果先转账、后记录键,就会产生重复。所以要把先占(in_progress)和完成(completed)分开,分两个阶段来记录。

第四,没有确定键的范围。如果键是全局的,别的客户碰巧发来同样的字符串时,就会拿到别人的响应。范围通常按(客户、端点、键)三项来确定。保存期限也必须一并确定——上面这份草案也写道,要公开说明过期策略。期限一过,把键删除,之后的重试就成了新请求,所以保存期限必须比客户端的重试上限更宽裕。

实际工作中真正重要的事

下一项实验要做什么

亲手做出那一天的请求日志,先统计没有幂等保护的系统造成的重复转账。然后依次加上幂等键表与 UNIQUE 约束、规范化指纹、处理中状态、响应重放,以及键的范围和保存期限。评分器每次都会用不同的账号、金额和键,真的运行你的端点,对照响应和余额,并且还会发送在同一时刻到来的两个重试。最后,把一整天的请求一个不漏地重新放一遍,证明重复转账变成了 0。