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

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

用事务性发件箱保障订单与事件一致性

在 TT Lab 中继续学习

目标

基于真实的文件状态,验证 SQLite 事务、故障注入、重新发送和消费者去重。

为什么重要

一次正常请求成功,并不能保证边界值和故障恢复。本实验先以小规模方式实现或测试每个函数的契约,再把它们接入真实运行。评分不只看代码是否存在或报告里的措辞,而是检查结果、异常和存储状态。保留前面步骤的代码,再继续下一步。

步骤

  1. 在 /root/work/idem-outbox-lab/service.py 中,让 init_db(path) 创建 orders(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)、outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0)、consumed(id TEXT PRIMARY KEY)、totals(name TEXT PRIMARY KEY, amount INTEGER NOT NULL),并在 totals 中不重复地插入 sales=0。先用下面的命令做准备。
mkdir -p /root/work/idem-outbox-lab
cp /opt/fixtures/practice_depth/idem-outbox-lab/* /root/work/idem-outbox-lab/
cd /root/work/idem-outbox-lab
  1. 在 /root/work/idem-outbox-lab/service.py 中,让 canonical(order_id, amount) 只接受非空字符串 id 和正整数 int 金额,并把 {id, amount} 以 sort_keys=True、separators=(',', ':')、ensure_ascii=False 的 JSON 形式返回。bool 不作为金额接受,输入有误时抛出 ValueError。
  2. 在 /root/work/idem-outbox-lab/service.py 中,让 enqueue(path, order_id, amount, fault=lambda:None) 在 BEGIN IMMEDIATE 中依次执行插入订单 → fault() → 插入 outbox,然后提交并返回 True。用相同的 id 和金额重复请求时返回 False,金额冲突时抛出 ValueError。如果 fault 抛出异常,两边都要回滚。
  3. 在 /root/work/idem-outbox-lab/service.py 中,让 pending(path, limit=10) 按 id 升序返回最多 limit 个 sent=0 的 (id, payload) 元组。limit 是 1–100 的整数(不含 bool),违反时抛出 ValueError。
  4. 在 /root/work/idem-outbox-lab/service.py 中,让 acknowledge(path, event_id) 在把 sent=0 的行改为 sent=1 时返回 True,行不存在或已经发送过时返回 False。
  5. 在 /root/work/idem-outbox-lab/service.py 中,让 dispatch(path, publish, limit=10) 把 pending 的每一行传给 publish(id, payload),成功之后再 acknowledge。发布异常要向外传播,并保持未发送状态。返回成功处理的个数。
  6. 在 /root/work/idem-outbox-lab/service.py 中,让 consume(path, event_id, payload) 校验 JSON 中的 id 和金额,并确认与 event_id 一致。在同一个事务中把 id 记录到 consumed,并把金额加到 totals 的 sales 上。首次处理返回 True,重复返回 False,值有误时抛出 ValueError。
  7. 在 /root/work/idem-outbox-lab/service.py 中,让 total(path) 返回 sales 的合计。让 publish 在 consume 成功之后立刻抛出 ConnectionError,然后再次运行 dispatch 试试。事件即使被投递两次,合计也只能增加一次,并且 outbox 最终必须是 sent=1。

参考

把订单、事件和消费记录分开

在 /root/work/idem-outbox-lab/service.py 中,让 init_db(path) 创建 orders(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)、outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0)、consumed(id TEXT PRIMARY KEY)、totals(name TEXT PRIMARY KEY, amount INTEGER NOT NULL),并在 totals 中不重复地插入 sales=0。先用下面的命令做准备。

mkdir -p /root/work/idem-outbox-lab
cp /opt/fixtures/practice_depth/idem-outbox-lab/* /root/work/idem-outbox-lab/
cd /root/work/idem-outbox-lab

使用 CREATE IF NOT EXISTS 和 INSERT OR IGNORE,重启时就不会把合计重置。

可以用 bash /opt/lab/checks/idem-outbox-lab/01-contract.sh 手动复现评分。保存文件后重新运行。

生成经过校验的事件正文

在 /root/work/idem-outbox-lab/service.py 中,让 canonical(order_id, amount) 只接受非空字符串 id 和正整数 int 金额,并把 {id, amount} 以 sort_keys=True、separators=(',', ':')、ensure_ascii=False 的 JSON 形式返回。bool 不作为金额接受,输入有误时抛出 ValueError。

要规范化 JSON 字符串中的空白和键顺序。不要悄悄地把 float 或 bool 转成整数。

可以用 bash /opt/lab/checks/idem-outbox-lab/02-contract.sh 手动复现评分。保存文件后重新运行。

回滚两次写入之间的故障

在 /root/work/idem-outbox-lab/service.py 中,让 enqueue(path, order_id, amount, fault=lambda:None) 在 BEGIN IMMEDIATE 中依次执行插入订单 → fault() → 插入 outbox,然后提交并返回 True。用相同的 id 和金额重复请求时返回 False,金额冲突时抛出 ValueError。如果 fault 抛出异常,两边都要回滚。

不要在 with 连接块里吞掉异常。如果在调用 fault 之前就提交,就会重现“只留下订单”的事故。

可以用 bash /opt/lab/checks/idem-outbox-lab/03-contract.sh 手动复现评分。保存文件后重新运行。

按限定的批次读取未发送的事件

在 /root/work/idem-outbox-lab/service.py 中,让 pending(path, limit=10) 按 id 升序返回最多 limit 个 sent=0 的 (id, payload) 元组。limit 是 1–100 的整数(不含 bool),违反时抛出 ValueError。

批次上限可以避免故障恢复时一次性耗尽内存。不要省略 ORDER BY。

可以用 bash /opt/lab/checks/idem-outbox-lab/04-contract.sh 手动复现评分。保存文件后重新运行。

以幂等方式记录发布确认

在 /root/work/idem-outbox-lab/service.py 中,让 acknowledge(path, event_id) 在把 sent=0 的行改为 sent=1 时返回 True,行不存在或已经发送过时返回 False。

用 UPDATE 的条件和 rowcount 来区分首次确认与重复确认。

可以用 bash /opt/lab/checks/idem-outbox-lab/05-contract.sh 手动复现评分。保存文件后重新运行。

只在发布成功之后才标记完成

在 /root/work/idem-outbox-lab/service.py 中,让 dispatch(path, publish, limit=10) 把 pending 的每一行传给 publish(id, payload),成功之后再 acknowledge。发布异常要向外传播,并保持未发送状态。返回成功处理的个数。

如果把 acknowledge 放在 publish 之前,一旦出故障,事件就会永远丢失。空批次返回 0。

可以用 bash /opt/lab/checks/idem-outbox-lab/06-contract.sh 手动复现评分。保存文件后重新运行。

把消费记录和效果以原子方式绑在一起

在 /root/work/idem-outbox-lab/service.py 中,让 consume(path, event_id, payload) 校验 JSON 中的 id 和金额,并确认与 event_id 一致。在同一个事务中把 id 记录到 consumed,并把金额加到 totals 的 sales 上。首次处理返回 True,重复返回 False,值有误时抛出 ValueError。

用 BEGIN IMMEDIATE 把重复检查和效果的应用绑在一起。如果先单独提交 consumed,效果就可能丢失。

可以用 bash /opt/lab/checks/idem-outbox-lab/07-contract.sh 手动复现评分。保存文件后重新运行。

重现“投递两次、效果一次”

在 /root/work/idem-outbox-lab/service.py 中,让 total(path) 返回 sales 的合计。让 publish 在 consume 成功之后立刻抛出 ConnectionError,然后再次运行 dispatch 试试。事件即使被投递两次,合计也只能增加一次,并且 outbox 最终必须是 sent=1。

发送成功与确认记录之间的缝隙依然存在。不要掩盖这道缝隙,而要确认它能靠消费者去重来承受。

可以用 bash /opt/lab/checks/idem-outbox-lab/08-contract.sh 手动复现评分。保存文件后重新运行。