没收到响应的客户端会再发一次
一句话总结
幂等键是客户端附在请求上的名牌,服务端靠这个名牌记住“这个请求已经处理过了”,从而阻止第二次执行,并把第一次的响应原样返回。
为什么需要它
向转账 API 发出请求的客户端收不到响应,并不少见。可能是网关的超时比服务端处理的时间短,可能是负载均衡器断开了连接,也可能是手机进了地下室。这时客户端知道的事实只有一个——没有收到响应。钱到底有没有划出去,它并不知道。
这时客户端剩下的选择有两个。放弃,或者重发。如果放弃,用户会认为“转账没成功”,于是再点一次。结果请求无论如何都会再发一次。所以,消除重试本身不是答案,让重试变得安全才是答案。
RFC 9110 的 9.2.2 节 以方法为单位定义了这种性质。同一个请求发送多次,对服务端产生的预期效果与只发送一次相同,那么这个方法就是幂等的(idempotent),PUT、DELETE 和安全方法都属于这一类。同一节还写道,幂等方法之所以被区分出来,是因为在读取响应之前通信中断时,可以自动重试,并明确指出,非幂等的方法,客户端不得随意自动重试。转账是 POST。也就是说,协议不提供任何保证。保证必须由我们自己来做。
工作原理
做法很早以前就有了结论。客户端为每个请求附上一个唯一的键,服务端把这个键存下来。把这种惯例整理成文档的,是 IETF 草案 draft-ietf-httpapi-idempotency-key-header。草案不是标准——它没有 RFC 编号,内容也可能改变。尽管如此,它是目前处理这个问题时整理得最好的文章,所以在实务中起着共同语言的作用。
草案确定的骨架有四点。
- 键由客户端生成。必须唯一,对于正文不同的请求,不能重复使用同一个键。建议使用 UUID 这样的随机值。
- 指纹(fingerprint)由服务端生成。是根据请求正文计算出的校验值,草案举出了整个正文的校验和、部分字段的校验和、逐字段比较值等方式作为例子。只看键,防不住“用同一个键发送不同金额”的事故。
- 第一个请求照常处理。把它的结果和状态码附在键上存起来。
- 重复的请求分两种情况。对于在前一个请求结束之后到来的重试,原样返回存好的结果;对于在前一个请求仍在处理时到来的重试,用冲突错误来回答。
错误码也是草案提议的。同一个键带来不同的正文,是 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)分开,分两个阶段来记录。
第四,没有确定键的范围。如果键是全局的,别的客户碰巧发来同样的字符串时,就会拿到别人的响应。范围通常按(客户、端点、键)三项来确定。保存期限也必须一并确定——上面这份草案也写道,要公开说明过期策略。期限一过,把键删除,之后的重试就成了新请求,所以保存期限必须比客户端的重试上限更宽裕。
实际工作中真正重要的事
- 重试无法阻止,只能让它变得安全。把设计的出发点放在这里。
- 键的重复使用不要悄悄地让它成功,而要用 422 拒绝。哪个才是真正的请求,服务端并不知道。
- 响应不要重新计算,而要原样重放存好的那份。如果重新计算,余额和时间就会变,客户的画面会出现两次不同的样子。
- 键不是转账编号。把键删除之后,如果同样的请求再来,那就是新的转账。保存期限和重试上限要一起写进文档。
下一项实验要做什么
亲手做出那一天的请求日志,先统计没有幂等保护的系统造成的重复转账。然后依次加上幂等键表与 UNIQUE 约束、规范化指纹、处理中状态、响应重放,以及键的范围和保存期限。评分器每次都会用不同的账号、金额和键,真的运行你的端点,对照响应和余额,并且还会发送在同一时刻到来的两个重试。最后,把一整天的请求一个不漏地重新放一遍,证明重复转账变成了 0。