清理任务删掉了一批老回执。数据库瘦了,值班面板也很安静。过了一会儿,一个在队列里躺了很久的请求终于醒来。接收端查了查:“没有见过。”于是,同一张工单又建了一次。
这是本篇的假设场景,不是作者经历过的线上事故。问题却可以真实复现:过去没做过,与做过但忘了,在一张空表面前长得一模一样。
第一篇把去重记录与业务效果放进同一个事务,第五篇把派发额度保存在跨进程账本里。它们都需要历史状态。今天不再增加一层重试,而是追问这本历史账什么时候可以变薄。
资料核对日期:2026 年 10 月 14 日。以下业务内容是合成工单;SQLite 提交、子进程退出、管道控制的迟到请求及重新查询是真实执行。没有调用大模型、支付接口或真实工单系统,也没有运行生产 TTL、网络延迟和容量测试。
1. 幂等有保留边界,不能从“支持”两个字推导永久有效#
先看一个具体契约。Stripe 的幂等请求文档明确区分 API v1 与 API v2;其中 v1 说明,键至少保留 24 小时后可以自动清理,原键被清理后再次使用会生成新请求。同键参数校验也依赖其保存的信息。
这里引用的是文档约定,没有实际调用 Stripe,更不是在评测支付安全。不同 API、版本与服务商的规则不能互相套用。值得提炼的工程问题是:调用方承诺多久之内重试,接收方承诺多久之内记得,排队与恢复会不会跨过两者之间的缝。
“任务最长运行十分钟”也不等于“十分钟后所有旧请求都消失”。暂停的进程、离线重放、消息积压与恢复旧快照都可能延长到达时间。即使删除操作准确遵守 TTL,TTL 仍然只说明记录的年龄,并没有证明旧请求已经不存在。
本例不模拟现实时间。实验中的清理是一个显式函数调用,表示保留策略已经决定删记录;我们只检验删除前后会发生什么,不声称测到了某个服务的过期时间。
2. 两种东西可以分别过期:答案与“我见过你”#
回执里通常不只有一个键,还带着返回值。返回值很大时,可以先让答案过期,继续保留一条较小的记录:命名空间、代次、操作 ID,以及参数摘要。这类保留记录在本文称为墓碑。
我们的 receipt 表把 result 设为 NULL 后,同键同参数返回 result_expired;它不会创建第二张工单,也不会凭空恢复已清掉的结果。同键不同参数仍返回 conflict。状态名称刻意没有叫 success:这个入口已经不能提供原始回执,调用方需要独立的业务查询或对账。
代码中的 result 实际只保存一个效果 ID,不是大响应正文;因此实验验证的是状态语义,不是节省了多少存储。业务 effect 表仍保留合成工单,我们也没有用它做隐藏的去重兜底。
if old:
if old[0] != digest:
return {'status': 'conflict'}
return {
'status': 'replayed' if old[1] is not None else 'result_expired',
'effect': old[1],
}
摘要基于精确 UTF-8 字符串的 SHA-256。它不是通用 JSON 规范化方案,也不是认证签名。真实接口必须明确哪些参数参与比较、规范化如何版本化,以及碰撞与权限的处理;本实验按摘要未发生碰撞来讨论。
墓碑的限制很直接:每个旧操作仍占一行。清掉大答案,可以降低每条记录的负担,但并没有把无限操作压成有限状态。若接下来把墓碑也删了,最初那个“没做过还是忘了”的问题就回来了。
3. 想删掉整批历史,先改变旧请求的准入规则#
如果业务允许把旧批次彻底关闭,可以用一个范围边界替代这批逐条回执。每个命名空间保存 closed_through,意思是这个代次及以前的请求都不再接收。
假设边界从 0 推到 3,代次 1、2、3 的请求一律返回 closed。无论它有没有做过、参数是否变化、去重行是否还在,都不能重新创建效果。代次 4 及以后才继续查 receipt,并按原来的事务幂等规则执行。
这项规则不是“已经完成的旧任务不要重试”,而是更强、也更粗的承诺:这个范围内,连从未到达的合法新工作也不要了。 因此封口必须是业务协议中的终止决定,不能由一个只懂磁盘占用的清理脚本擅自作出。
本例中的 epoch 是批次范围,不是第二篇的工作进程接管代次;closed_through 也不是墙钟 deadline。相同字段名称不能替代协议含义。这里没有实现生产代次分配器、租户身份认证或封口审批。
样例只接受受信任测试调用者传入的命名空间与正整数代次。真实系统若允许任意调用者把 1 改成 999,就等于让请求自己宣布“我不属于旧批次”。必须由可信任务状态决定批次,并校验该业务意图与批次的绑定。下面的代码没有提供这层防伪能力。
4. 封口、清理与业务写入必须在同一道门里排队#
先删除 receipt,稍后再写 closed_through,会留下一个窗口:旧请求已经查不到回执,却还没有被边界拒绝。只检查边界然后释放事务,再做业务写入,也会留下窗口:请求检查时合法,清理完成后却继续产生效果。
实验使用同一个 SQLite 数据库。execute 在 BEGIN IMMEDIATE 内读取边界、检查回执、插入业务效果与回执;close_and_collect 在同类写事务内提高边界、删除旧回执。业务写入不能绕过这个入口。
with connection(path) as c, transaction(c):
row = c.execute(
'SELECT closed_through FROM boundary WHERE namespace=?',
(namespace,),
).fetchone()
if epoch <= row[0]:
return {'status': 'closed'}
# 在同一事务内继续查回执、写效果、写回执
封口代码也不把“更新成功”与“清理成功”分成两次提交:
c.execute(
'UPDATE boundary SET closed_through=? WHERE namespace=?',
(through, namespace),
)
c.execute(
'DELETE FROM receipt WHERE namespace=? AND epoch<=?',
(namespace, through),
)
SQLite 的事务文档说明,同一时刻只有一个写事务,BEGIN IMMEDIATE 从开始就申请写事务。因此这里有两个可解释的顺序:旧操作先提交,随后封口删除其回执但保留业务效果;或者封口先提交,旧操作看到边界后被拒绝。已经提交的效果不会被封口撤销。
本例使用 WAL 与 synchronous=FULL。WAL 文档限定共享内存协作等使用条件,不能把本机数据库实验直接搬到网络文件系统,或推导跨服务原子性。若业务动作是外部邮件或远端 API,这个 SQLite 事务管不到那里;需要接收端提供相应协议,不能靠本地一条边界记录代替。
5. 让旧进程真的等一等,再决定放它进来#
实验驱动启动独立 Python 进程。子进程输出 READY,停在管道输入;父进程收到确认后执行清理,再写入 GO,让子进程进入 execute。没有用 sleep 猜测“它应该已经停住了”。十五秒超时只是测试卡死保护,不是业务期限或测量值。
第一组是故意错误的对照:先创建工单,再只删 receipt,最后放行旧请求。实测晚到请求返回 created,effect 表从一行变成两行。这证实“请求 ID 没变”本身不构成去重,接收端还得保有对应状态或拒绝规则。
第二组先封口并清理,再放行同一请求。实测返回 closed,原始效果保留一条。随后发送一个从未出现过、但属于旧代次的操作,同样返回 closed。最后发送代次 2 的新操作,才新增一条效果。这组最终效果数是二,来自旧批次的原工单和新批次的新工单,不是旧请求重放成功。
墓碑组则得到另一种结果:清掉 result 后,同键同参数返回 result_expired,改变参数返回 conflict,效果仍只有一条。把“不能返回结果”和“允许再执行”分开,才不会让缓存回收顺手改了业务语义。
6. 清理进程自己崩溃,不能留下一半规则#
我们用 os._exit 真正退出子进程,分别停在边界更新后、回执删除后但提交前、封口事务提交后但回复前。重新打开数据库,不看进程最后说了什么,只看持久状态。
| 故障点 | 恢复后边界 | 旧回执行数 | 旧请求恢复行为 |
|---|---|---|---|
| 更新边界后、提交前退出 | 0 | 1 | replayed,不新增效果 |
| 删除回执后、提交前退出 | 0 | 1 | replayed,不新增效果 |
| 封口事务提交后、回复前退出 | 1 | 0 | closed,不新增效果 |
前两行回滚整个未提交事务;最后一行保留整个已提交事务。这里没有出现“回执没了,边界还是旧的”的半成品。补充测试用 SQLite 触发器故意拒绝删除,验证边界更新也一起回滚;触发器是测试故障注入,不是生产组件。
同样的检查也施加到业务事务:效果插入后、回执插入前退出,恢复后效果数为零;两者提交后、回复前退出,恢复重试返回 replayed,效果数仍为一。这样才能确认用于清理实验的基础去重入口本身没有先漏掉原子性。
这些是进程级故障与列出的事务边界,不是断电、磁盘丢写、损坏恢复或跨节点一致性测试。八线程、十六次同键调用的额外测试使用各自数据库连接,结果为一次 created、十五次 replayed;它检查一个并发用例,不代表穷尽所有调度,也不是吞吐基准。
7. 换批次能通过,不代表应该替旧任务换批次#
测试还故意做了一次危险的“恢复”:代次 1 的 work-1 已有一个效果,封口后,把同名操作换到代次 2 再发。实测新增第二条效果。
实现的键是 namespace、epoch、op 三元组。换 epoch 就换了键,代码没有跨代次的全局业务去重。若一个动作必须跨批次保持唯一,需要稳定业务键、业务表唯一约束或其他可审计的终态协议。不能为了躲开 closed 自动生成新批次,也不能把这种改键行为包装成安全重试。
命名空间也一样。它用于隔离不同范围;测试中 demo 的封口不影响 other。生产里它应来自经过验证的身份与资源绑定,不能让调用者通过改命名空间逃避限制。即使使用哈希摘要,也不会自动获得身份认证。
另一个危险来源是恢复旧备份。若 closed_through 从 3 回到 0,已经关闭的请求会重新获准。代码拒绝普通接口把边界调小,也验证再次 init 不会重置已有边界;但它无法阻止运维恢复整份旧数据库。恢复协议必须把边界单调性当作业务状态的一部分,不能只把它看成可重建缓存。
封口还改变了查询能力:旧回执被删除后,仅凭 closed 无法知道某个操作以前是否执行。本文保留的业务表可用于后续对账,但没有实现完整查询服务;调用方不能把 closed 解释成“肯定没发生”。第三篇的 unknown 对账边界仍然适用。
8. 重跑十组场景,再检查你愿意失去什么#
下载五个文件到同一空目录:retention.py、test_retention.py、run_experiment.py、results.json、README.md。使用 Python 3.10 或更新版本,无第三方依赖:
python3 test_retention.py
python3 run_experiment.py --check
本次运行环境为 Linux、Python 3.12.14、SQLite 3.53.1。30 项 unittest 全部通过;10 组具名场景重新创建临时数据库、执行真实进程,再与完整 JSON 比较通过。结果文件不包含随机目录、PID 或耗时,保留可比较的状态、退出码与效果数量。管道等待采用 selectors,本次未验证 Windows 管道兼容性。
这里减少的是逐操作 receipt 行,不是宣称整个系统占用恒定:业务 effect 行仍增长,命名空间边界也必须持久保留。没有测量磁盘回收量,没有实现 VACUUM、分布式垃圾回收、真实时钟保留窗口或备份恢复协议。封口范围的业务批准、身份认证、并发隔离与数据保留要求,都需要真实项目单独落实。
对需要长期接受旧任务的系统,永久或长期保留墓碑可能更合适。对允许明确结束一个批次的系统,范围封口可以替代那一批去重行。对只有第三方接口、无法控制接收协议的系统,只能遵守对方保留契约,在窗口之外查询或对账,不能照搬本例 SQL 取得并不存在的保障。
清理历史不是单纯的存储优化。删掉答案,意味着无法再从这个入口重现答案;删掉识别记录,可能意味着重新执行;以范围拒绝代替逐条记忆,则意味着连合法的迟到工作也一并拒绝。真正需要批准的,是这个行为变化,而不只是“数据库终于小了一点”。
继续讨论可查看知识星球,或通过公众号入口关注更新。本文延续 Agent 执行系列;RAG 第六篇仍等待真实生成输出与可审查细粒度证据,不用本实验代替。
本文及原创图解、样例代码由 AI 辅助制作,并经过代码执行与内容核验。合成业务与本地事务实验不构成生产系统可靠性承诺。

