READ · UNDERSTAND · TRANSFER
阅读理解,按需巩固
先沿着原理、问答和迁移案例阅读。需要检查理解时,再切换巩固练习或展开个人记录。
核心知识 · 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工程推演
- 场景
- 面试假设:提交 Agent 任务返回成功,偶发任务一直停在 queued。
- 设计决策
- 任务和待投递事件同事务提交,后台重试并扫描滞留记录。
- 验证目标
- 已提交任务可恢复,重复投递不创建第二个运行。
- 适用边界
- Outbox 不会自动解决外部工具副作用的 exactly-once。
连续追问与解答
沿着问题的前提和约束继续向下读。先理解参考解答,再尝试收起答案,用自己的话解释因果和取舍。
第 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 可表示已可靠交给运行状态机,不能虚称远端已完成。没有目标支持就保留未知与人工核实。
保持不变的原理:本地原子意图不能跨越远端事务,副作用仍需独立恢复契约。
易错点
- 把双写包在 try/catch 就算可靠
- 依赖队列自动去重解决全部问题
- 长期事务里等待模型
参考资料
依据公开技术资料设计;参考资料支持技术机制,场景与评分标准为本站设计,不代表某公司面试原题。 新增问答与迁移案例用于原理讲解,来源核查与案例运行验证分别记录。
检查自己理解到哪一步
读完后可以对照这些标准解释原理、边界和取舍。掌握程度由你自评;需要进一步验证时,再完成下方小任务。
- 基础达标
- 能指出先写库和先发消息各自窗口。
- 中高级信号
- 给出事务 Outbox、稳定 ID 和幂等消费。
- 资深信号
- 能设计宕机点验证、滞留告警与安全重投。
巩固练习 按需完成 · 建议 15 分钟
画出任务提交、Outbox 投递和消费的事务边界,标记重复发生点。
展开验收要求与检查点
- 任务和事件同生共死
- 重复消息有定义处理
- 丢失投递可被扫描发现
重点检查
- 识别数据库和队列双写窗口
- 同事务写 Outbox
- 处理至少一次投递与恢复扫描