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

ACK前停机的零食售货机

订单已保存,发送意图却丢失了

在 TT Lab 中继续学习

一句话总结

保存了业务这一事实与已通知其他服务这一事实是不同的,所以要把仍需投递的意图与业务一起留下。

为什么需要它

前面模块中的零食自动售货机,在一个 SQLite 文件内一起保存了库存和游标。这次订单受理端和仓库是分开的。受理端接收订单,仓库收到该订单后增加出库准备数量。受理 DB 中记录了 7 个订单,但仓库什么都没收到。运维人员因为订单表正常而回答说成功,但从用户的角度来看,零食并没有备好。一次数据库事务并不会自动把两个系统绑在一起。

你可能会想:“在 DB 提交的下一行调用 HTTP 不就行了吗?”可是在执行紧接着的那一行之前,进程就可能结束。如果调换顺序、先发送,又会出现相反的问题:仓库已经备好货,订单 DB 的提交却可能失败。靠调换两行的顺序,无法消除中间的故障窗口。这个模块先设计成功的证据留在哪里、需要重试的事项留在哪里。

工作原理

把“发送意图”而不是“发送”做成原子

受理端在一个事务中同时记录 orders 和 outbox。orders 是已受理的业务,outbox 是之后要投递的条目。两者要么都保存,要么都消失。COMMIT 之后,由单独的投递循环读取 outbox 中未完成的条目并发送给仓库。保存订单的函数本身不会等待 HTTP 响应。这是为了即使外部系统很慢,也不会在整个网络等待期间一直占着 DB 写锁。

접수 DB: BEGIN → orders 삽입 → outbox 삽입 → COMMIT
전달 루프: 미완료 조회 → HTTP 요청 → 업무 ACK 확인 → sent=1
창고 DB: BEGIN → inbox 삽입 → stock 갱신 → COMMIT → ACK

在这张图中,原子的只是每个 DB 的 BEGIN 与 COMMIT 之间。整个箭头流程并不是单个事务。多亏了 outbox,即使受理进程在提交之后立刻死掉,重新启动的投递循环也能找到未完成的意图。但是,仓库反映之后响应可能丢失,所以重复发送依然可能发生。必须区分“不会丢失要发送的事项”和“发送恰好只发生一次”。

在时间线上标出成功与不确定性

例如,提交一个 ID 为 snack-7、数量为 7 的订单。如果在 orders 插入之后终止,那么在独立连接中 orders 和 outbox 都应是 0 行。在 outbox 插入之后、尚未提交时也是一样。在提交之后终止,两者都应是 1 行。最后这种情况下,如果因为调用方没有收到响应就删除订单,那就成了取消已受理业务的另一个行为。应当用相同的 ID 和内容重新提交,以确认已有的受理。

测试不会只确认“在同一个连接内能看到改动”。尚未提交的连接可以读到自己的改动。还要对照单独的 SQLite 连接读到了什么,才能知道对外已经确定的状态。此外,还要区分 try/finally 清理错误的情形,与通过 os._exit 使清理代码本身都不会执行的情形。前者体现异常处理的正确性,后者体现进程消失后能从数据库文件中读出的状态。

ID 由业务设计决定

本实验的 ID 是由 ASCII 字母、数字、下划线、连字符组成的 1–64 个字符的字符串,数量是 1–1000 的整数。True 在 Python 中看起来可能像整数,但不接受作为数量。这些限制并不是真实订单服务的标准,而是教学用的契约。相同 ID 与相同数量视为重试,相同 ID 与不同数量是 Conflict,不同 ID 与相同数量则作为独立订单处理。如果增加了价格、客户、商品种类,就必须重新确定哪些字段属于同一业务的内容。

如果每次都用生成时刻或当前时间重新生成 ID,同一订单的重试就会变成不同的订单。反过来,如果直接把数量本身当作 ID,碰巧都订了 7 个的两个人就会被合并成一个。网络重连次数、请求时刻和业务标识符各有不同的含义。这次的实现以调用方已经确定了稳定的业务 ID 为前提。并不声称构建了 ID 发放服务或多用户认证体系。

在现场相遇的样子

FDE 在把客户现有的 DB 与新的通知、任务系统连接起来时,会遇到“一边成功了、另一边没成功”的情况。需求中往往只写了成功场景,所以应当把提交前后、发送前后、ACK 前后的中断都放进验收测试。本实验中的两个 SQLite 文件,是用来以小规模重现这类边界的装置。它既不是与真实支付公司做过原子提交的证据,也不是构建过消息代理的证据。

使用 outbox 也会带来运维责任。要查看尚未完成的行已存在多久、投递循环是否在运行、失败条目是否阻塞了后面的条目。删除未完成的行、把数字变成 0,并不是恢复。在不知道哪些已被接收的状态下,应当先设计重发与确认。没有保留期限和容量限制、无限堆积的表,也不是完整的运维设计。本实验验证小量数据的失败含义,把长期保存和清理留作下一个设计问题。

下一项检查要做什么

紧接着的测验要判断提交前后的表状态,以及两种发送顺序各自的失败。下一个模块中,为了让投递循环即使重发同一个条目,也不会重复仓库的业务效果,要把 inbox 和库存一起保存。在最后的实验中,用真实的进程终止来确认各个中断点。

通过官方文档进一步阅读