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

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

删除幂等键也意味着保证结束:设计原理

在 TT Lab 中继续学习

一句话总结

区分已完成和进行中的记录,并实现保留期限与分批清理。

为什么需要它

表变得很大之后,把旧的幂等键全删掉了。结果仍在支付中的请求的记录也消失了,重试就被当作新请求进来。已完成响应的保留期限,与进行中任务的所有权,并不适用同一种过期策略。清理不是简单的 DELETE,而是一次改变保证范围的状态转换。

工作原理

键先以 pending 状态预留,只有指纹相同才能改为 done。已完成的响应在 expires 时间之前可以重放。清理只删除 done 且 expires 已到期的行,并按 id 顺序和 limit 加以限制。pending 无论放了多久,这个清理函数都不会删除。最后一个实验会在清理前后分别预留同一个键,亲自确认一个局限:记录被删除之后,它会被当作新请求。

pending → done → expires 도달 → 제한된 purge → 같은 키가 새 요청이 됨
pending ─────────────────→ purge 대상 아님

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

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

1. 把是否完成与过期分开

init_db(path) 以幂等方式创建 keys(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,status TEXT NOT NULL,response TEXT,expires REAL NOT NULL)。

判断依据:不能只看过期时间就断定业务已经结束。

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

CREATE TABLE keys

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

2. 预留进行中的键

reserve(path,key,digest,expires) 把不存在的键以 pending、response=NULL 插入并返回 True。对已有的键,无论是否过期都不改动,返回 False。

判断依据:删除记录与复用键要分开,并明确地加以控制。

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

INSERT OR REPLACE INTO keys

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

3. 只完成当前请求

finish(path,key,digest,response) 只对 key 和 fingerprint 都一致且为 pending 的行,把 response 以 JSON 保存并改为 done,返回 True,其余返回 False。

判断依据:连指纹也一起比较,防止正文不同的 worker 覆盖已完成的响应。

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

AND status IN ('pending','done')

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

4. 只重放有效的已完成响应

fetch(path,key,now) 对 done 且 expires>now 的行,把 response 按 JSON 解析后返回,其他情况返回 None。

判断依据:固定下这份契约:在 now==expires 的边界上不再重放。

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

AND expires>=?

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

5. 限制清理候选

expired(path,now,limit=10) 检查 limit 是 1–100 的 int(不含 bool)。它按 id 升序,最多返回 limit 个 done 且 expires<=now 的 id。

判断依据:一次性清空整张表会拉长锁的持有时间,也容易把进行中的行混进来。

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

return [r[0] for r in db.execute("SELECT id FROM keys WHERE status='done' AND expires<=? ORDER BY id DESC

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

6. 把选择和删除放在同一个事务里

purge(path,now,limit=10) 用与 expired 相同的 limit 校验、条件和排序挑出候选,在同一个事务中删除,并返回被删除的 id 列表。不删除 pending。

判断依据:不要在查询候选和删除之间,留下状态可能发生变化的缝隙。

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

ids=[r[0] for r in db.execute("SELECT id FROM keys WHERE expires<=?

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

7. 实际统计各状态的数量

counts(path) 返回 {pending:数量, done:数量}。即使没有某个状态,键和 0 也必须存在。

判断依据:必须能够观测到,清理之后未完成的行有没有消失。

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

"pending":0

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

8. 确认删除之后保证范围就结束了

retention_cycle(path,key) 以 expires=10、digest='v1' 预留,并用 {receipt:1} 完成。在 now=10 时执行 purge,然后用 digest='v2'、expires=20 对同一个键重新预留,并返回该结果的 bool。该函数以空 DB 为前提。

判断依据:不掩盖“清理之后键会变成新请求”这个局限,而是亲自重现它。

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

purge(path,9)

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

在现场相遇的样子

它并不保证无限期的 exactly-once 效果。外部系统可能重新发送很久以前的请求,因此保留期限要与提供方的重试策略相匹配。如何恢复被遗弃的 pending 的租约与补偿策略,是另外的实验要负责的内容,这个清理函数不会凭猜测去删除它们。

下一项实验要做什么

八个步骤会连成一个可运行的成果。把是否完成与过期分开 → 预留进行中的键 → 只完成当前请求 → 只重放有效的已完成响应 → 限制清理候选 → 把选择和删除放在同一个事务里 → 实际统计各状态的数量 → 确认删除之后保证范围就结束了。

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