执行器给一项任务发了三张调用券。第一次用完,进程崩溃。第二个进程来接班,从配置里读出“最多三次”,又领到三张。面板上每个进程都很守规矩,外部工具却一直收到请求。
这是一个假设场景,不是作者的线上事故。第四篇把嵌套调用放进同一份时间与次数预算,但它的计数器在进程内。重启后若重新构造对象,那张“最多三次”的限额表就失去了记忆。
本篇只补这个缺口:同一个业务意图跨越进程退出、重新启动和并发竞争后,怎样仍然守住派发上限? 答案需要一份持久预留账本,也需要承认一个不太讨喜的代价:额度可以花掉,事情却还没发出去。
资料核对日期:2026 年 10 月 13 日。业务内容是合成工单;子进程、SQLite 事务、突然退出和恢复检查是真实执行。没有调用大模型、真实业务服务或网络 API,不把本机实验包装成分布式配额服务。
1. 先别给计数器起一个会撒谎的名字#
把一个字段叫作 sent_count,读者很容易以为它准确记录“已经发出的次数”。可写数据库和发送请求不是同一个原子动作。
先发送再加一:接收端已经处理,发送端在加一之前退出,恢复后少算了一次。先加一再发送:数据库已经提交,进程在发送前退出,恢复后多算了一次。再加一条 sent=true 日志,也只是把这个窗口搬到下一行。
所以本例明确记三种不同数字:
spent:已提交的预留数,包含首次请求;它是保守消耗,不是实际发送证明receiver_calls:测试仪器观察到的接收端入口次数,包含同键重放;它是本地函数边界计数,不是网络抓包effects:接收端最终业务表行数,受业务幂等规则影响
在本文受控发送入口、每张新预留至多调用一次接收端、没有隐藏重试的前提下,要守住的是:接收端入口次数不超过预留数,预留数不超过上限。三个数字不必相等。
例如预留两次后都在发送前退出,结果可以是 2、0、0。对同一意图派发两次,接收端去重,结果可以是 2、2、1。业务只发生一次,不代表调用只发生一次;调用没有发生,也不代表额度没有消耗。
2. 原子操作的单位,是上限检查加整张预留#
发送端使用两张表。task 保存稳定意图 ID、固定参数、工具安全类型、上限 cap、已消耗 spent 与确认标志。reservation 为每次获准尝试保存独立序号和确认状态。
一次申请必须在同一个写事务里完成:读取任务、检查能否继续、增加 spent、插入 reservation,再提交。不能先查出“还有一张”,释放事务后再各自减一;两个工作进程可能同时拿着那句“还有一张”去发送。
核心实现如下,完整代码还会先检查已确认、非幂等 unknown 和预算耗尽:
with db(Path(root) / 'sender.db') as c, tx(c):
row = c.execute('SELECT * FROM task WHERE id=?', ('op-1',)).fetchone()
# 准入条件检查也在本事务内
seq = row['spent'] + 1
c.execute('UPDATE task SET spent=? WHERE id=?', (seq, 'op-1'))
c.execute('INSERT INTO reservation(task_id,seq) VALUES(?,?)', ('op-1', seq))
return {'seq': seq, 'payload': row['payload'], 'safety': row['safety']}, 'reserved'
这里的 return 不会绕过上下文管理器:tx 在函数真正返回前执行 COMMIT。若提交失败,调用方拿不到可用于发送的返回值。数据库约束同时要求 0 <= spent <= cap,预留主键为意图 ID 与序号。约束是最后一道检查,不能取代事务里的准入逻辑。
SQLite 事务文档说明 BEGIN IMMEDIATE 会立即开始写事务;遇到其他写事务时仍可能忙等待或返回 SQLITE_BUSY。本文连接设有十秒等待上限。等待结束仍不能写入就报错,不跳过预算发请求,也不因为拿不到锁就改用内存计数。十秒只是样例的数据库竞争配置,不是 Agent 的业务截止时间。
本例每次恢复仍用同一个 op-1。重复初始化只接受同一参数、工具安全类型与 cap,不重置 spent;换参数、改上限都作为冲突拒绝。生产不一定永远禁止预算变更,但变更必须是明确决策,有范围和记录,不能作为重启函数的隐含副作用。
3. 旧预留不能当作一张可无限复印的通行证#
持久化以后,另一种错误很容易出现:恢复扫描看到第 1 张预留未确认,于是拿第 1 张再次发送。又退出,再用第 1 张发送。账面永远只扣一张,外部请求却可以无限多。
因此,本例的 reservation 是消耗记录,不是可反复领取的待发队列。reserve() 只返回刚刚成功提交的新预留;attempt() 每次至多进入接收端一次,恢复不会从旧行重建发送许可。需要重新派发时,先重新通过业务安全判断,再领取新的序号。
稳定意图 ID 与尝试序号必须分开。第一次和第二次业务重放仍是 op-1,接收端才能返回原工单;预算预留则从 1 变成 2,才能承认调用资源又消耗了一次。把意图 ID 换掉,会破坏去重;把尝试序号重复使用,会掩盖调用数。
这条上界依赖受控入口。公开一个 receive 函数供任意代码绕过预算,或把同一张预留复制给两个发送线程,都不在保证范围内。示例函数只是可信测试组件,没有认证、票据签名或不受信任客户端协议。生产需要收紧真正能调用工具的路径,并确认 SDK、代理与重定向没有藏着额外请求。
此外,预算不是租约,也不是 fencing。已经取得预留的旧进程可以暂停,稍后恢复发送;另一个进程确认任务后,本例只会阻止新的预留,不会撤销旧进程手里的许可。因此,“已经确认”不等于“所有在途请求终结”。接收端去重仍有必要;若需要严格撤销旧写入,应回到第二篇的接收端边界,而不是删掉一行预算记录。
4. 真正退出四次,观察不同的账#
本次环境为 Python 3.12.14、SQLite 3.53.1。驱动创建临时目录,启动全新的 Python 子进程,在明确边界调用 os._exit,再启动另一个进程恢复。退出码 71、72、73、74 分别标记下面四个故障点。
Python 的 os._exit 文档说明它直接终止进程,不经过普通清理处理器。因此,提交前退出的恢复不是 finally 替我们撤销,而是依赖 SQLite 对未提交事务的处理。实验没有模拟断电、磁盘损坏或文件系统违约。
以下四组上限均为 2,接收端具有事务幂等契约。表中的数字来自真实运行的 results.json;“恢复后”包括新进程的一次尝试或停止判断。
| 退出位置 | 恢复前预留 / 入口 / 效果 | 恢复后预留 / 入口 / 效果 | 为什么 |
|---|---|---|---|
| 增加计数并插入行,但尚未 COMMIT | 0 / 0 / 0 | 1 / 1 / 1 | 未提交预留回滚,新进程可重新申请 |
| 预留已 COMMIT,尚未调用接收端 | 1 / 0 / 0 | 2 / 1 / 1 | 第一次预留不退款,新派发要新预留 |
| 接收端效果已 COMMIT,尚未记录确认 | 1 / 1 / 1 | 2 / 2 / 1 | 原意图重放花新额度,接收端返回旧效果 |
| 发送端确认已 COMMIT,随后退出 | 1 / 1 / 1 | 1 / 1 / 1 | 新进程看到 confirmed,不申请也不发送 |
接收端入口日志先独立提交,然后在另一个事务里做业务检查、去重与写入。这样,参数冲突等拒绝也能计入入口次数。入口日志属于测试仪器,不是发送端已经收到的回执;代码不读取它来决定是否退款。它与业务表分开提交,也不意味着入口一被记录就必然产生业务效果。
发送端和接收端使用两个数据库文件,业务去重记录与业务效果在接收端同一事务提交,没有把两端账本放进一个总事务。这让两道提交之间的缺口实际存在。但它们仍在同一台机器,不是两个独立网络服务,也不能据此宣称覆盖网络分区或独立主机故障。
5. 八个进程抢三张券,检查的不只是最后有没有重单#
并发场景启动八个工作进程。每个先输出 READY 并等驱动放行;驱动统一放行后,各自申请预留。拿到预留的进程输出 RESERVED,停在另一个发送门前。没有拿到的进程返回预算耗尽。驱动收齐八个准入结果后,才放行成功者进入接收端。
这样安排是为了确保竞争发生在确认之前,避免某个进程过早完成,让其他人因 confirmed 停止,掩盖预算到底能否挡住竞争。没有靠 sleep 猜测先后。十五秒超时只用于防止测试挂死,不参与任何业务耗时统计。
实际结果是:3 个预留成功、5 个预算耗尽;预留序号为 1、2、3;接收端入口 3 次,业务效果 1 条。三个成功者都取得相同的工单 ID。这同时检查了两个互不替代的事实:发送端把可见尝试限制在三次,接收端把同一业务意图的效果去重到一条。
这里多个工作进程共享的是本地 SQLite WAL。按照 SQLite WAL 文档,该模式要求参与进程在同一主机,不能拿这个文件直接当网络文件系统上的跨机配额服务。实验启用 WAL 与 synchronous=FULL,但没有测量吞吐、公平性或生产争用尾延迟,更没有建立共识服务。读者可以从中复用事务不变量,不能原封不动复制部署结论。
6. 没回执就退款,限额会怎样失效#
反例把 cap 设为 1。每次接收端提交之后,子进程立刻退出,发送端没有确认。错误恢复逻辑把未确认预留删除,再把 spent 清零,然后重复这个流程三次。
最终账面是 spent=0、cap=1,接收端却被进入了 3 次。业务效果依旧只有 1 条,因为接收端正确去重。这个对照特意说明:幂等可以保护业务结果,不能替错误预算账本控制请求压力。
危险退款只写在实验驱动的独立反例里,安全模块没有退款 API。不是所有预留系统都永远不能释放未用额度;但释放需要足以排除旧尝试稍后发送的协议,并且原子处理释放与发送权失效。仅仅超时、租约到期、没查到效果、没看到确认,都不能自动提供这个证明。
即便确定一个进程已经结束,生产还可能存在它此前交给代理、消息队列或后台线程的工作。把“本地 PID 不在了”扩大解释成“世界上没有请求在途”,仍然是证据跳跃。本例直接函数调用,故障点由测试仪器全知控制;不能把这份测试特权冒充真实恢复器普遍拥有的判断能力。
7. 最难接受的通过项:额度耗尽,事情一次都没做#
另一组实验上限为 2,连续两次都在预留提交后、接收端调用前退出。第三个新进程返回 budget_exhausted。最终预留 2 次、入口 0 次、效果 0 条。
这不是测试失败,而是本实现明确选择的安全与活性取舍。它能限制最多派发多少次,不能保证至少派发一次,更不能保证最终业务成功。预算越紧,保守占用越容易导致“什么也没做成就停了”;预算越宽,允许消耗的资源也越多。不能用一个未经测量的统一次数替业务做决定。
任务停止后应保留原因和证据。在这个样例里,spent 已满与 confirmed=false 可以同时出现;旧预留 ack=0 只能说明缺确认,不能单独判断业务发生与否。要继续,应先核验结果并由业务决策明确批准新的资源或动作,不能换个任务 ID 悄悄拿一整份新额度。
非幂等工具还有更严的门。本例上限为 3,第一次效果提交后回执丢失,恢复直接返回 unknown_requires_reconciliation,剩下两张券也不能用。甚至预留后尚未发送就退出,恢复器看见相同的未确认状态,也保守停止。这再次区分测试仪器知道的故障点,与业务恢复器真的持有的证据。
如何从 unknown 获得可负责的结论,仍沿用第三篇的终态查询、封口与人工对账边界。不能把“预算够用”写成“允许再执行”。
8. 下载后,重跑相同的失败边界#
五个文件放在同一空目录,使用 Python 3.10 或更新版本,无第三方依赖:durable_budget.py、test_durable_budget.py、run_experiment.py、results.json、README.md。
python3 test_durable_budget.py
python3 run_experiment.py --check
本次 24 项 unittest 全部通过,8 组具名场景重新运行后与完整存档一致。测试包括零上限、非法上限、配置冲突、插入失败回滚、真实退出恢复、并发额度竞争、参数冲突计数、错误确认对象、非幂等 unknown 和危险退款反例。存档不包含 PID、临时目录或墙钟耗时,比较的是有确定含义的状态与计数。
这套测试实测于 Linux。管道握手使用 selectors,未验证 Windows 管道兼容性;所有数据库写在临时目录,不读取凭证或用户数据。计数断言只覆盖列出的交错与受控入口,不代表已穷尽所有调度、故障或真实 SDK 行为。
本篇刻意没有添加持久 deadline。第四篇的逻辑 tick 不能直接写入数据库后当作跨进程、跨机器永久有效的期限。如何保存原期限、处理时钟域与重启后的剩余窗口,仍需独立协议与测试。预算持久化完成,不会顺便解决时间恢复。
9. 账本必须比进程活得久,也必须记得自己不知道什么#
迁移到真实系统时,至少要追问:所有发送入口是否真的先预留?同一预留能否被两个执行者复用?配置更新、任务克隆、旧备份恢复或清理历史行,会不会让 spent 倒退?确认依据是否来自可信业务回执?旧进程和代理是否仍可能发出已经获准的请求?
这些问题里,额度倒退会破坏资源上界,换意图会破坏业务去重,伪造确认会制造假成功,擅自退款会把未知藏进漂亮的数字。不能只审查 SQL 是否原子,就把整个执行器盖章为可靠。
本系列第五篇新增的是跨进程的保守派发预算。第一篇的接收端幂等、第二篇的 fencing、第三篇的 unknown、第四篇的期限与取消仍然各管一段边界。可靠恢复的目标,不是让重启之后一切看起来像新的一样,而是让新进程忠实接过旧进程留下的责任。
本文及原创图解、样例代码由 AI 辅助制作,并经过代码执行与内容核验。合成业务场景与本地事务实验不构成生产系统可靠性承诺。

