叫号机终于公平了,每个窗口都在办事。大厅却越来越挤:门口的接待员对每位来客都说“已经受理,请稍候”,然后继续发号。下午三点,队尾到了楼梯口;下午四点,办公室决定再打印一箱号码纸。
这个假设场景没有影射真实事故。它要说明的是:第十二篇解决了“谁有机会执行”,没有解决“还能承诺接下多少工作”。持久化让任务不容易丢,轮转让有限流程不容易饿死,两者都不会凭空增加处理能力。一直接受、一直保存,可能只是把失败从请求当场搬到了未来的磁盘和等待时间里。
作为 Agent 可靠执行实战的第十三篇,本篇给入口加一道持久准入:只有在预算允许时,才把业务意图和待执行任务一起写入数据库。已经承诺的工作,无论排队、正在执行还是暂时隔离,都继续占用预算;真正完成或确定从未启动的取消,才能归还名额。
这不是给上篇的循环多加一个 if。我们要定义拒绝到底有没有接受请求、重复请求在满载时如何回答、字节预算怎么算、哪些状态可以释放,以及进程在提交前后退出时还能相信什么。实现是独立的最小模型,方便复现这些边界,并未把前十二篇所有机制合成一个生产平台。
先把“容量”说成可以检查的东西#
本篇把一个已接受但未终结的任务称为 outstanding,即未结任务。它的状态可以是 queued、inflight 或 quarantined。我们同时限制两种量:未结任务的条数,以及这些任务原始文本载荷的 UTF-8 字节之和。
示例固定使用五个参数:全局最多 4 条、256 字节;每个租户最多 2 条、128 字节;单条最多 96 字节。数字故意很小,是便于手工验证的合成夹具,不是推荐容量,不来自生产压测,也不是根据用户网站访问量推导的配置。
设未结集合为 U,那么一次新请求只有在下面四个条件同时成立时才能进入:全局条数加一不超过全局上限;全局字节加上新载荷不超限;该租户条数加一不超限;该租户字节加上新载荷不超限。单条大小在此之前检查。
这个定义有两个容易踩的坑。第一,len("中") 是一个字符,而 UTF-8 编码占三个字节。第二,本文的字节预算不是磁盘配额:它不包含 Python 对象、请求缓冲区、标识字段、SQLite 页、索引、WAL,也不包含已经终结但保留的历史记录。限制正文大小,不代表把整个服务的内存和硬盘关进了同样大小的盒子。
一个“受理成功”,必须对应一个完整事务#
示例有 intent 和 task 两张主表。前者保存请求 ID、租户、原始文本及其字节数,代表持久化业务意图;后者保存排队序号、状态、代次和租约。两张表用同一个请求 ID 关联,一次受理把它们写进同一个 SQLite 数据库、同一个事务。
我们不先把业务意图提交,再单独调用一个“有界队列”。后者如果拒绝,业务层就可能已经留下“已受理”,执行层却没有工作可做。也不先观察容量再开事务插入:两个进程可能同时看见最后一个空位,然后各自发出一张号码。
核心路径可以概括成下面的伪代码;完整实现见文末下载,包含输入校验、持久配置和异常回滚。
BEGIN IMMEDIATE
if request_id 已经存在:
return replay if 租户和原始载荷完全一致 else conflict
if 新载荷字节数 > 单条上限:
return oversize
for 范围 in [全局, 当前租户]:
读取 queued / inflight / quarantined 的条数与字节和
if 加入新请求会超限:
return 范围_full
INSERT intent
INSERT task(state="queued")
COMMIT
SQLite 事务文档说明,同时可以有多个读事务,但只能有一个写事务;BEGIN IMMEDIATE 会立即尝试开启写事务,也可能因其他写者而返回忙。这里利用的是写事务对“检查后插入”的串行化,不是把两条普通 SQL 用注释圈起来就获得了原子性。连接设置了忙等待,仍然可能失败;锁超时、I/O 错误等异常会向调用方抛出,不冒充 global_full 或成功。
预算用聚合查询从未结任务推导,没有另外维护一个“剩余容量”变量。好处是完成重复到达时,不存在某个计数器被减两次而偷偷多发名额的问题;代价是聚合和串行写会形成性能成本。本文没有测量吞吐量,不能把这个教学实现直接推成高并发入口。换成持久计数器可以是优化方向,但必须重新证明计数变更与状态转移在同一事务中,不能只把查询换成缓存。
init 把五个限制保存下来。再次启动时,配置一致才继续;不一致则报错,不悄悄让某个进程拿另一套容量工作。在线缩容、配置迁移和多版本部署不在本实现内。尤其缩容后,现有未结量可能已经大于新上限,这不是改一个常量就能解决的迁移问题。
满了,也要先认出已经发过的号码#
如果客户端没有收到受理响应,它会带着相同 ID 再来。此时大厅可能刚好满员。如果先检查容量,系统会说“满了,没接”,但这个请求其实早就在里面了。调用方因此可能生成新 ID 重试,反而制造新的业务请求。
因此顺序必须是身份在前,容量在后。已存在且租户、原始载荷完全一致,返回 replay,不新增记录、不再占一个名额;同一个 ID 携带不同租户或不同原始载荷,返回 conflict。这里比较的是原始字符串,不是 JSON 的语义等价。把对象键换个顺序、改变空白或 Unicode 表示,都可能成为不同载荷;如果业务需要规范化,应当在定义协议时统一,而不是失败后临时猜测。
replay 的准确含义是“这个身份以前已经被接受”,不是“现在已完成”。它可能仍排队、正在执行、已隔离、已完成,也可能已被取消。示例通过 snapshot 查看状态,真实服务通常需要受理回执和状态查询接口。不能把所有重复请求都翻译成一次新的执行成功。
反过来,tenant_full、global_full 和 oversize 都没有写入业务意图,也没有占住这个 ID。测试先让请求 three 因租户满载被拒绝,再取消一个从未启动的任务,随后用 three 加上不同正文重试,结果可以被接受。因为第一次根本没有受理,所以不构成篡改一个已经接受的身份。
这是一种明确的协议选择,不是幂等性的普遍定义。如果你的接口要求“连拒绝也永久绑定请求体”,就需要单独保存拒绝记录,并考虑拒绝记录本身如何限量;否则攻击者可以用无限的新 ID 绕过工作队列预算,撑满拒绝历史。
为什么领走、超时和隔离都不能还名额#
很多有界队列限制的是等待区长度,消息一旦交给消费者,就不再算在等待区里。这未必错,但它和本篇的“未结业务承诺”不是同一个量。RabbitMQ 的队列长度文档明确说其长度限制统计 ready 消息,尚未被消费者确认的消息不计入;达到上限时,默认可以丢弃或死信化队首消息,另有 reject-publish 等溢出选项。本文没有运行 RabbitMQ,也没有把这种产品语义判成缺陷,只是提醒:一个叫 max-length 的开关,不自动等于你的业务预算。
本篇的名额从接受一直保留到终结。领取只把 queued 变成 inflight。如果此时就归还名额,生产者能不断灌入,而慢消费者手上会积累越来越多未完成工作;队列看起来很短,总承诺却没有上限。
租约过期也不代表完成。它只说明旧领取者的当前资格结束,可以由新的代次接手。SQS 的可见性超时说明同样区分领取后的暂时不可见和处理后的删除,超时后消息可以再次被领取。这里只借用这个概念边界,样例没有连接 SQS,也不复刻它的完整语义。
示例采用逻辑 tick,租约长五个 tick。claim 可以领取排队任务,或重新领取租约已过期的任务,代次递增。旧 token 之后尝试完成会返回 stale;过期边界采用 lease <= now,恰好到期也不能完成。tick 持久化且不允许倒退,但它不是跨机器时钟方案,不能把测试里的 now=5 当成五秒的真实网络保证。
隔离也继续占容量。隔离意味着这个已接受的工作需要处理,不意味着可以忘掉它。redrive 只接受匹配的隔离代次,让原身份重新排队并推进代次,不重新准入,不增加也不释放预算。它是显式的操作者动作,样例不实现权限系统和处置审计;生产环境必须在调用边界补齐,不能让任意外部请求自行解除隔离。
如果隔离任务逐渐塞满预算,入口会拒绝新请求。这正是本模型选择的保守行为:没有把握处理完,就不继续承诺。它可能降低可用性,也可能迫使团队处理长期搁置的任务。把隔离状态排除出统计当然能让入口更“畅通”,但得到的是另一份合同:隔离区可以无限扩张,需要独立的存储和处置预算。
什么时候真的可以释放#
代码给出两条可核对的终结路径。第一条是 resolve:在一个写事务内核对状态、代次与租约,然后插入本地 effect 记录,标记 done,共同提交。effect 的值固定为 synthetic-completed,它只是教学用的数据库副作用,不代表发送邮件、扣款、调模型或调用第三方工具。
这处克制很重要。如果你把本地插入替换成外部调用,然后保留其余代码,事务不会替你回滚远端世界。旧 worker 即使被本地代次拒绝,也可能已经在外部做了事。真实跨系统执行仍要结合前文的有序 outbox / inbox和对端回执、幂等及对账边界。本篇只证明本地合成效果和本地完成状态不会在这些注入的崩溃点上分家。
第二条是 cancel_unstarted。它只取消 queued 且 generation=0 的任务。这个条件说明通过本模型 API 从未有人领取过它。取消与领取都是写事务,两者竞争时只有一种顺序:取消先提交,领取看不到候选;领取先提交,代次不再为零,取消拒绝。
领取后超时、隔离后重投,即使状态又回到 queued,代次也不为零,因此不能用这个接口取消。不是说这些任务永远不能取消,而是本例没有证据证明工作没发生,不愿拿“暂时没看到成功”冒充“安全撤销”。真实取消要有自己的协议和补偿语义,不能作为腾空容量的快捷按钮。
完成或取消后,活动聚合自然不再包含这行。重复完成 token 返回 stale,重复取消返回 not_cancelled,不会再次释放其他任务的名额。终结的身份记录仍保留,因此同 ID、同正文再次提交只会得到 replay,不会复活成一份新工作。
把承诺放到进程退出和并发争抢里检查#
所有输入都是合成样例;SQLite 操作、子进程退出和多进程竞争是在本次检查中真实执行的。测试使用 Python 标准库,不需要模型、密钥、网络服务或额外依赖。下面的数字来自仓库随附程序,不是估算,也不是生产可靠性指标。
test_admission.py 执行 21 项测试,包括身份冲突、满载重放、UTF-8 字节、全局与租户预算、仅未启动可取消、租约到期、隔离重投、完成去重、配置不一致、非法输入、插入失败回滚、崩溃边界和并发竞争。
崩溃不是在 Python 里抛一个随后被捕获的异常。父进程启动真正的子进程,指定边界调用 os._exit(73),随后新连接重新读取数据库。共检查八个退出位置:业务意图写入和任务写入之间;受理提交前后;本地效果写入和完成标记之间;完成提交前后;取消提交前后。
受理提交前退出,业务意图与队列任务都不存在,容量没有被占;提交后退出,记录完整存在,相同请求重试得到 replay。完成提交前退出,没有效果记录,任务仍占容量;提交后退出,效果与 done 一起存在,容量释放。取消的提交前后也对应保留和释放,不出现半张收据。
进程退出只覆盖这里设置的进程故障,不覆盖断电、设备写缓存损坏、文件系统异常、网络存储或所有可能的指令间隙。示例开启 WAL 和 synchronous=FULL,但没有进行硬件耐久性认证,不把几次子进程实验写成“任何故障都不会丢”。
并发测试先启动十二个进程,再逐个收集结果,每个进程提交不同请求 ID。租户各不相同时,默认全局预算使 4 个被接受、8 个得到 global_full;全是同一租户时,2 个被接受、10 个得到 tenant_full。实际哪四个或哪两个先赢取决于调度,因此冻结结果只保存数量和预算,不固定赢家身份,也不记录耗时来暗示性能。
另一组字节试验先放入 90 字节的中文正文,再提交 39 字节正文,第二个因租户 128 字节上限被拒绝;全局试验先接受两份 96 字节正文,再提交 65 字节,第二步之后的合计是 192,而再加入会超过 256,因此第三份被拒绝。它们特意让条数还没满,用来区分“做了字节检查”和“恰好被条数挡住”。
生命周期试验展示的是同一个三字节任务:租约过期、旧代次回调、隔离、原身份重投期间,未结占用都还是 [1, 3];最终本地效果和完成状态提交后才变成 [0, 0]。重复完成仍不会多产生一个效果。
run_experiment.py --check 将十一组场景与 results.json 逐字段比较。这个命令重新运行真实子进程,不只是确认结果文件存在。无参数运行会重建基线,适合审阅协议变更,但不能为了消除失败而盲目重写答案。
下载、运行和看懂输出#
把下面五个文件放在同一目录,使用 Python 3.10 或更新版本运行。临时数据库由脚本自行创建并清理;请勿把示例路径改成生产库来“试一下”。
- admission.py:事务与准入状态机
- test_admission.py:21 项边界测试
- run_experiment.py:十一组可复现实验
- results.json:本次冻结结果
- README.md:运行说明与适用范围
python3 test_admission.py
python3 run_experiment.py --check
检查通过时,前者报告 21 tests 和 OK;后者报告 PASS: 11 scenario groups match results.json。如果你要改变预算,先在独立临时目录运行实验并审阅预期变化,而不是对已有库传一组新参数。对一个完全独立的新库,可以这样试探接口:
from pathlib import Path
from tempfile import TemporaryDirectory
import admission as a
with TemporaryDirectory() as folder:
path = Path(folder) / "demo.db"
a.init(path)
assert a.admit(path, "job-1", "tenant-a", "合成输入") == "accepted"
token = a.claim(path, 0)
assert a.resolve(path, token, 1) == "done"
assert a.admit(path, "job-1", "tenant-a", "合成输入") == "replay"
assert a.snapshot(path)["usage"] == [0, 0]
这里的租户 ID 是可信调用方提供的分组键,不是身份认证。若用户能任意伪造租户名,按租户上限就没有隔离意义。输入也已经是内存里的字符串,UTF-8 检查并不能阻止入口在解析巨型请求体之前耗尽内存。生产接入必须把认证后的租户、请求流量和读取大小限制放到合适的边界。
三个不能藏在“有界队列”后面的反例#
未结量有界,历史仍然无界。 实验顺序接受并完成十个任务,最后活动占用是 [0, 0],但保留十份身份与十份效果记录。继续循环,历史还会增长。直接清除终结身份会使很晚到来的旧请求看起来像新请求,可能再次执行;因此保留期限、重试窗口与归档查询必须共同设计。本文选择不清理,以便不悄悄改变重放语义,但绝不声称解决了硬盘最终占满的问题。
每租户上限不是每租户预留。 A 和 B 各占两条,全局四条就满了,C 即使从未提交也会被拒绝。按租户上限可以限制单个租户的独占程度,却没有给每个新租户发保底席位。需要预留、权重或不同等级的服务时,要明确容量分配策略;不能因为有一个 tenant_slots 字段就宣称租户公平。
背压信号不是自动减速。 返回 global_full 不会神奇地让生产者放慢。如果调用方立即无限重试,数据库仍可能被拒绝查询压垮。应在上游定义有限重试、退避、抖动与最终放弃或人工处理,并区分业务拒绝、身份冲突和基础设施错误;本文没有实现 HTTP 状态码、Retry-After 或速率限制,也不承诺满载后多少秒可再次提交。
本例领取仍是简单 FIFO,不替代第十二篇的持久轮转;没有实现真实时钟、多节点部署、动态配额或无限寿命的代次增长。tick 与代次输入限制在十亿以内,长期运行前需要设计相应迁移边界。它证明的是一组有限实验下的事务协议,而不是没有期限的服务等级保证。
要把入口与上一层串起来,可以把预算视为业务承诺的一部分:在生产者事务里决定能否接受,把相应 outbox 和状态一同落库;在有证据的终结事务里释放,而不是看到 relay 发送了一次就释放。这个方向仍需与真实业务状态和对端证据逐项对齐,本篇没有把独立模型冒充已完成的全链路集成。
叫号机负责叫谁,入口负责还能发多少号,完成回执负责哪张号已经办完。把这三个问题分开,系统才能既不轻易弄丢承诺,也不无限制造新的承诺。拒绝不是可靠性的反面;在没有余量时诚实地拒绝,往往正是可靠性的一部分。
本文由 AI 辅助制作,代码、测试与冻结结果经过实际执行和审阅;配图为原创 SVG 机制示意,无生产性能或真实用户数据。官方文档核对日期:2026 年 10 月 21 日。

