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

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

两位顾客同时预订最后一件商品:设计原理

在 TT Lab 中继续学习

一句话总结

把库存扣减、预留 id 和取消以原子方式绑在一起,防止超卖。

为什么需要它

库存只剩一件,两个请求同时查询,结果都预留成功了。之后同一条取消消息到达了两次,库存比原来还多了。预留的去重与取消的去重是两种不同的状态转换,而且剩余数量的检查也必须与扣减处于同一个事务中。

工作原理

stock 保存当前剩余数量,reservations 保存预留 id、商品、数量和是否已取消。用相同的预留 id 和相同的内容再次请求,会以 False 结束;内容不同则是冲突。只有首次预留才扣减库存,取消仅在从 active 变为 cancelled 时归还一次库存。即使复用已取消的预留 id 来表示同一笔预留,也不会产生新的预留。

재고 검사 + 예약 삽입 + 차감 → COMMIT
예약 active → 취소 + 재고 반환 → cancelled → 재취소 무동작

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

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

1. 把数量固定为整数契约

quantity(value) 只返回(不含 bool 的)正 int,其余情况抛出 ValueError。

判断依据:防止负数的预留反而增加库存。

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

not isinstance(value,int)

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

2. 把库存和预留分开保存

init_db(path) 以幂等方式创建 stock(sku TEXT PRIMARY KEY,available INTEGER NOT NULL) 和 reservations(id TEXT PRIMARY KEY,sku TEXT NOT NULL,qty INTEGER NOT NULL,cancelled INTEGER NOT NULL DEFAULT 0)。

判断依据:已取消的预留也要保留,才能区分重复的取消与重新预留。

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

CREATE TABLE reservations

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

3. 区分商品初始化与库存变更

add_stock(path,sku,amount) 检查是否为(不含 bool 的)大于等于 0 的 int,并且只对新商品执行 INSERT。重复的商品抛出 IntegrityError。

判断依据:避免同一条初始化命令覆盖正在使用的库存。

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

INSERT OR REPLACE INTO stock VALUES

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

4. 查询剩余数量

available(path,sku) 返回已保存的数量,商品不存在时抛出 KeyError。

判断依据:把不存在的商品与缺货区分开,不要掩盖错误的商品 id。

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

return 0

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

5. 把预留与库存扣减绑在一起

reserve(path,rid,sku,qty,fault=lambda:None) 先校验数量。已有的预留 id 如果商品和数量都相同就返回 False,不同则抛出 ValueError。新的预留要确认商品存在和库存,依次执行扣减 → fault → 插入预留,然后返回 True。库存不足或商品不存在时抛出 ValueError。

判断依据:把检查和扣减放在同一个事务中,出故障时全部回滚。

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

db.commit()
        fault()

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

6. 取消也只生效一次

cancel(path,rid) 在预留不存在或已被取消时返回 False。如果是 active 的预留,则在同一个事务中执行 cancelled=1 和归还库存,并返回 True。

判断依据:不能因为取消消息被重新发送,库存就不断增加。

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

if row is None:

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

7. 确认预留状态

reservation(path,rid) 返回 (sku,qty,cancelled) 元组或 None。

判断依据:不只看返回值,还要读取取消状态是否真的留在了 DB 里。

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

return db.execute("SELECT sku,qty,0

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

8. 重现最后一件库存的竞争

compete(path,sku) 用两个不同的 id(first 和 second)、数量均为 1,在两个线程中执行 reserve。返回按输入顺序排列的结果列表,其中只把 ValueError 转换为 False。

判断依据:要比较库存为 1 和库存为 2 这两种情形,才能筛掉那种无条件只让一个成功的实现。

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

["first","first"]

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

在现场相遇的样子

这并不是把支付授权和库存预留绑进同一个分布式事务的实验。预留过期以及支付失败的补偿,属于另外的流程。这里通过一个小型 SQLite DB,学习的是这样的原理:不要轻信查询时看到的库存数字,而要在写入的事务内部再确认一遍。

下一项实验要做什么

八个步骤会连成一个可运行的成果。把数量固定为整数契约 → 把库存和预留分开保存 → 区分商品初始化与库存变更 → 查询剩余数量 → 把预留与库存扣减绑在一起 → 取消也只生效一次 → 确认预留状态 → 重现最后一件库存的竞争。

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