先理解
刚接触这个知识点
补齐先备概念,读原理与反例,再用自己的话解释为什么。
从核心原理开始 →理解 → 实现 → 排错 → 取舍
考察双写问题、事务 Outbox、重复投递和恢复扫描。
知识内容核对 2026-10-03 · 原题来源核对 2026-10-02
建议先理解:
幂等、未知结果与任务恢复 →按当前基础选择起点,也可以依次深入。遇到不熟悉的概念,先回到核心原理;完成后用知识练习检查理解。
LEARN · PRACTICE · REFLECT
先沿着原理、问答和迁移案例阅读。需要检查理解时,再切换巩固练习或展开个人记录。
核心知识 · Outbox 的原子意图与重复投递
先备概念:本地数据库事务、队列交付语义、幂等消费
同一事务提交任务与待投递意图,消除“任务存在但通知永久丢失”的双写窗口;实际投递仍可能重复,消费者必须单独保证业务处理可重入。
先写任务再发消息,数据库成功后进程崩溃,队列永远不知道;先发消息再写任务,消费者可能找不到记录。try/catch 只能处理活着的进程,不能处理突然断电。Outbox 把业务行和待发事件放在同一数据库事务里,二者同时提交或回滚。
投递器扫描未确认事件,发送队列后标记已投递。发送成功而标记失败会重发,所以稳定 event_id、业务 revision 和消费幂等都是必要设计。Outbox 不把数据库与队列变成同一事务,也不提供外部动作端到端一次效果。事件顺序若影响状态,应按业务对象序号处理,而不是只相信网络到达顺序。
消息 ACK 的含义依队列而异,可能只证明消费者已确认交付;它不能直接证明 Agent 的工具成功。消费方先可靠领取或建立幂等处理记录,再在约定边界 ACK。长期 Agent 任务通常让消息触发持久化运行,而不是持有队列消息直到所有模型步骤完成;终态由运行状态和业务回执确认。
监测未投递事件年龄、重试次数、任务未调度时间和死信;扫描对账能发现任务与状态偏离。清理以已确认交付、保留期和补投需要为依据,不能按创建时间直接删。验收分别在业务事务回滚、提交后未发、发后未标记以及消费完成未 ACK 杀进程,检查没有丢任务且重复有定义。
不能把数据库提交和队列发送当作一个天然原子操作。我会在同一数据库事务中写任务与 Outbox 事件,由独立投递器重试发往队列,消费方用稳定事件或操作 ID 去重。消息可能重复,但已提交任务不会因为发送瞬时失败而永久丢失。还要监测未投递事件年龄和任务卡住情况,用对账扫描发现异常。
先提交任务再发消息,进程可能在中间宕机,任务永远没人执行;先发消息再提交任务,消费者可能找不到任务,或者事务最后回滚。把“异常时再试一次”写在请求线程里不能覆盖进程消失的情况。需要一份与任务提交同时持久化的待投递事实。
在同一事务写 runs 和 outbox,事件包含 event_id、run_id、类型、载荷版本与创建时间。投递器领取未发送事件,发送后更新状态。如果发送已成功但状态更新失败,下次会重复发送,因此消费者必须有去重或幂等业务转移。不要在持有长数据库事务时等待模型调用或远端消息确认。
消费方按合法状态转移领取任务,重复 event_id 不重复创建逻辑运行。处理业务结果与消费记录可以在同一存储事务中提交;涉及外部副作用仍需业务幂等与回执核对。队列的 ACK 只是投递处理确认,不能替代业务成功状态。顺序敏感任务使用版本或序列检查,迟到事件不能把已完成任务退回排队。
注入四个宕机点:事务提交前后、消息发送后、标记已发送前。断言所有已提交任务最终可被发现,重复消息不会重复副作用。监控 Outbox 最老事件年龄、重试次数、死信和运行状态停留时间。补偿扫描需要使用同样幂等键,不能修复丢任务时又制造双执行。
SQLite 内存事务演示;不包含真实队列投递器、幂等消费或分布式压测。
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE runs(id TEXT PRIMARY KEY);
CREATE TABLE outbox(event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL);
""")
def submit(run_id, fail=False):
with db:
db.execute("INSERT INTO runs VALUES (?)", (run_id,))
if fail:
raise RuntimeError("crash before outbox")
db.execute("INSERT INTO outbox VALUES (?, ?)", (run_id + ":created", run_id))
try:
submit("r1", fail=True)
except RuntimeError:
pass
print("after rollback:", db.execute("SELECT count(*) FROM runs").fetchone()[0],
db.execute("SELECT count(*) FROM outbox").fetchone()[0])
submit("r2")
print("after commit:", db.execute("SELECT count(*) FROM runs").fetchone()[0],
db.execute("SELECT count(*) FROM outbox").fetchone()[0])
db.close()
预期输出
after rollback: 0 0
after commit: 1 1沿着问题的前提和约束继续向下读。先理解参考解答,再尝试收起答案,用自己的话解释因果和取舍。
第 1 层发送成功但标记失败会怎样?
从原子提交意图进入外部投递的第二个失败窗口。
投递器无法确认本地标记时将再次发送同一 event_id。消费端查已处理 ID 或原子领取同一运行,返回现有状态,不重复创建任务。即便队列自带去重窗口也保留业务幂等,因为窗口和回放期限可能不同。记录重投次数以便排查。
沿着这个回答继续深入
第 2 层两个投递器同时扫描到同一未发事件,会不会都发送?
父问承认重投后,并发投递器增加另一种重复来源。
可能。用短事务条件领取、租约或行锁减少并发重复,发消息在事务外完成,提交标记时校验领取代次。即使这样,超时接管与响应丢失仍可能重复,消费者不能省略幂等。领取控制提高效率,业务正确性不依赖“从不重发”。
沿着这个回答继续深入
第 3 层消费端先查 event_id 不存在,再执行,为什么仍可能重复?
投递重复到达消费端,读后执行的竞态要求原子处理。
两个消费者可同时查到不存在。用唯一约束和原子状态领取,业务变更与幂等确认在同一本地事务中完成;若业务动作在远端,建立唯一意图并按稳定操作键执行。不能用一次 SELECT 充当幂等锁,事务边界必须涵盖真正受保护的状态。
第 1 层消息 ACK 是否代表业务成功?
区分运输层成功与任务层成功。
不代表。ACK 表示队列层的确认,业务可能尚未完成或只可靠保存了待执行状态。应定义 ACK 边界:持久化调度意图成功后可以 ACK,任务完成由独立状态和回执查询。若 ACK 在业务结果持久化前且没有恢复线索,仍可能丢处理。
第 1 层清理 Outbox 时如何不删未投递事件?
可靠链路最后还有保留与清理的生命周期。
只清理达到已确认投递与保留条件的事件,未投递和 unknown 单独处理;归档保留 event_id、业务版本与必要回执以支持补投。按分区删除时先核对未完成事件并报警,不把老事件等同已完成。清理与投递用条件状态避免删掉正在处理的行。
先找出改变的条件,再判断原方案中哪些前提仍成立。下面的案例是教学推演,便于将原理迁移到新问题。
改变的条件:同对象多个 revision 进入队列
延伸问题:重复去重就能防止旧版本覆盖吗?
不能,去重防同一事件重复,但不同事件可能乱序。记录对象 revision,拒绝旧 revision 覆盖新状态;需要逐步处理时按对象序号检测缺口并补取。消费者处理业务状态与记录已处理 ID 尽量在同一本地事务中完成。
保持不变的原理:事件身份与状态顺序是独立维度,幂等不能代替版本校验。
改变的条件:消费事务外发生业务副作用
延伸问题:在 processed_events 插入 ID 后就可以保证一次吗?
插 ID 后宕机会漏动作,动作成功后再插会重复。先保存意图,使用稳定 operation_id 进行远端幂等或对账,再确认结果;processed ID 可表示已可靠交给运行状态机,不能虚称远端已完成。没有目标支持就保留未知与人工核实。
保持不变的原理:本地原子意图不能跨越远端事务,副作用仍需独立恢复契约。
依据公开技术资料设计;参考资料支持技术机制,场景与评分标准为本站设计,不代表某公司面试原题。 新增问答与迁移案例用于原理讲解,来源核查与案例运行验证分别记录。
读完后可以对照这些标准解释原理、边界和取舍。掌握程度由你自评;需要进一步验证时,再完成下方小任务。
画出任务提交、Outbox 投递和消费的事务边界,标记重复发生点。