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

幂等性 — 点两次也只扣一次款

相同的键不一定代表相同的请求:设计原理

在 TT Lab 中继续学习

一句话总结

把键的范围、请求体规范化和键复用冲突分开,来判定一次请求是否为重新发送。

为什么需要它

两个客户碰巧使用了同一个幂等键,结果一个客户收到了另一个客户的响应。在另一起请求中,同一个键对应的金额已经变了,系统却仍返回之前的成功结果。只比较幂等键本身,就会忽略请求的含义和安全边界。键要绑定到租户和操作范围,请求体的含义则要用单独的指纹来检查。

工作原理

先校验字符串键的语法,并把 HTTP 方法和准确的路径与租户绑定在一起。JSON 的键顺序和空白要规范化,但数组顺序要保留。NaN 不是标准的 JSON 值,因此予以拒绝。请求指纹用 SHA-256 计算,如果同一范围的键对应了不同的指纹,就按冲突处理。保存的响应以深拷贝的方式返回,避免调用方之后改动它,影响到后续重新发送的结果。

tenant + method + path + key → 범위 키
본문 → 정규 JSON → 지문 → 최초 저장 / 같은 요청 재생 / 다른 요청 충돌

阅读契约并预测失败的工作表

下面并不是要求你把实现整个背下来的答案,而是逐步进行的代码评审。每个改动片段都有意破坏了契约。要注意,改动之后正常用例仍然可能通过。执行之前,先预测观测哪些输入、异常、状态能让差异显现出来;实现之后,再拿这个预测与实际结果对比。

1. 校验键的语法

valid_key(value) 只会原样返回由英文字母、数字、下划线、连字符组成的 1–64 个字符,其他输入一律抛出 ValueError。

判断依据:限制键的长度和允许的字符,不把空键当作正常的重新发送来处理。

需要评审的有问题的改动片段:

{1,128}

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

2. 对象顺序折叠,数组顺序保留

canonical(body) 只接受 dict,并返回以 sort_keys=True、separators=(',',':')、ensure_ascii=False、allow_nan=False 序列化得到的 JSON 字符串。无法序列化的值统一抛出 ValueError。

判断依据:如果对数组排序,就会改变用户所请求的操作顺序。

需要评审的有问题的改动片段:

sort_keys=False

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

3. 计算请求体指纹

fingerprint(body) 是对 canonical(body) 的 UTF-8 字节应用 SHA-256 后得到的 64 位 hex 字符串。

判断依据:Python 的 hash() 每个进程都会变化,因此不能用作存储的指纹。

需要评审的有问题的改动片段:

hashlib.sha512(

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

4. 按租户和操作区分键

scoped_key(tenant, method, path, key) 用 valid_key 校验 tenant 和 key,并把 method 转为大写。path 必须是以 / 开头的字符串。把这四个值编码成 separators=(',',':') 的 JSON 数组并返回。

判断依据:与简单地用分隔符拼接相比,对结构进行编码能让边界更清晰。路径的大小写要保留。

需要评审的有问题的改动片段:

method.upper(), path.lower(),

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

5. 区分三种判定

classify(record, digest) 在 record=None 时返回 'new',在 record['fingerprint']==digest 时返回 'replay',否则返回 'conflict'。

判断依据:不能仅仅因为键已经存在,就把所有重复请求都当作成功来重放。

需要评审的有问题的改动片段:

else "replay"

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

6. 保存响应时做拷贝

remember(records, key, digest, response) 把 {fingerprint:digest, response:response 的 deepcopy} 保存到新键下。如果键已存在,就抛出 ValueError,并保留原有记录。

判断依据:响应里的列表如果也不拷贝,嵌套的状态就会被共享。

需要评审的有问题的改动片段:

response

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

7. 读取响应时同样做拷贝

replay(record) 返回 record['response'] 的深拷贝副本。

判断依据:防止修改了第一次响应的调用方,连带改变后续重新发送的结果。

需要评审的有问题的改动片段:

record["response"]

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

8. 业务函数只调用一次

execute(records, tenant, method, path, key, body, action) 会计算 scope 和指纹。结果为 new 时,把 action() 的结果 remember 下来并返回副本;结果为 replay 时,返回已有响应的副本;结果为 conflict 时,抛出 ValueError。action 抛出的异常要向外传播,并且不留下记录。

判断依据:只有连业务函数的调用次数和失败之后残留的记录都检查了,才能看清重新发送的契约。

需要评审的有问题的改动片段:

if state in ("new", "replay"):

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

在现场相遇的样子

这个实验用单进程的内存字典,来学习把键与请求含义分开的契约。进程故障之后的数据保留,以及多个 worker 的并发,留到下一个 SQLite 实验再讲。哈希不是加密,也不声称 JSON 规范化已经统一了所有语言的数字表示,成了国际标准。

下一项实验要做什么

八个步骤会连成一个可运行的成果。校验键的语法 → 对象顺序折叠,数组顺序保留 → 计算请求体指纹 → 按租户和操作区分键 → 区分三种判定 → 保存响应时做拷贝 → 读取响应时同样做拷贝 → 业务函数只调用一次。

每个步骤检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,故意改动边界比较或清理代码,确认哪些测试会失败。说明为什么前面的测试在后面的步骤中仍然成立,并写出一个本实验不保证的运维条件。