第二季第 6 篇。拆解对象:DeepFlux 里一张叫
events_outbox的数据库表------全平台所有"已经发生的事"(领域事件)都先落到它里面,再由后台慢慢消费。第 104 篇(DDD 六条铁律)验证过这套机制的前半段:"事件和业务数据必须写进同一个事务"这条铁律,有集成测试守着。这篇接着讲后半段:写进去之后呢?谁来取?取的时候崩了怎么办?没人管的消息最后谁兜底? 两篇之间没有依赖,不要求读过 104。不要求你懂消息队列,概念当场讲。
先交代真实状态。今天我在本机做了两件事:一是跑了 outbox 相关三个包的单元测试(共享写入包、ETL 消费端、超时兜底任务,-count=1 绕过缓存,全绿);二是在本机测试库的真实 outbox 表(894 行待处理事件)上开两个数据库会话做了个并发实验,模拟两个消费者同时来抢消息、其中一个中途崩溃:
css
A 认领 3 行(最早的 3 行)
B 并发认领 3 行
交集行数 = 0(期望 0 = 互不抢行)
崩溃后 A 的 3 行重新可见(与回滚前一致)→ at-least-once 重投成立
✅ SKIP LOCKED 并发认领:无重复认领;崩溃回滚后事件不丢
三个数字各说明一件事:两个消费者抢同一批消息,一行都不会重复认领 (交集 0);消费者 A 在处理中途"崩溃"(事务回滚),它认领的 3 行原封不动回到待处理------消息不会因为消费者死了就丢掉。这两条性质是这套机制的全部根基,下面从头讲。
一、先弄懂三个词
领域事件(domain event) :一句"已经发生、不可撤销的事实",格式是"谁在什么时候做了什么"。比如"租户 A 的会话 S 结束了"(agent.session.completed)、"用户 U 被创建"(auth.user.created)。事件的价值在于通知关注方:计费系统关心会话结束,审计系统关心用户创建------各取所需。
双写问题(dual-write problem) :这是 Outbox 要解决的那个问题。假设业务操作要写数据库、还要发一条消息通知下游,最直觉的写法是"先写库,再发消息"(比如发到 Kafka/RabbitMQ)。但这是两次独立操作:写库成功后进程崩溃,消息没发出去------下游永远不知道这件事发生了;反过来先发消息再写库,写库失败就成了"幽灵消息"(下游收到一件没发生过的事)。两次写,无论顺序,都可能只成功一半。这就叫双写问题。
事务 Outbox(transactional outbox) :解法。把"要发的消息"当成一行数据,和业务数据写进同一个数据库事务------要么都成,要么都不成,不存在"只成功一半"。消息先躺在数据库的一张表里(这张表就叫 outbox,发件箱),再由一个后台进程把表里的消息慢慢取出来、处理掉、打上"已处理"标记。发件箱这个名字很形象:信写好了放进门口的箱子,邮递员(后台进程)稍后来取,你不用等他。
CronJob(定时任务) :按时间表反复运行的短命程序(比如"每 5 分钟跑一次"),跑完就退出。它在这篇里的角色是兜底:那些"等着人处理但人一直没来"的事情,由定时任务定期扫一遍、强制收尾。
二、写入侧:一张表、一条 SQL、一个注册表
这套机制的落成时间线(commit 可查):
| 时间 | 事件 |
|---|---|
2026-05-16(d4a36218) |
迁移 000008 建 events_outbox 表 |
2026-06-11(54ac71e0,T8) |
提取共享包 pkg/outbox:8 个 BC 约 300 行重复代码收归一处 |
2026-07-02(cc3e16af,T10) |
迁移 000088 加 schema_version 列 + 全平台事件注册表 |
2026-09-05(5b6fe757) |
迁移 000162 加每消费者失败记录表(第六节细讲) |
表结构 (000008,含 2026-09 时点全貌)核心就 7 列:事件 ID、事件类型(如 agent.session.completed)、聚合 ID(这件事发生在哪个对象上)、payload(JSON 详情)、来源 BC、发生时间、published_at(处理时间,NULL 就是"待处理")。配套一个部分索引------只索引待处理的行,消费者查询永远走它。
共享写入点 是一段 43 行的 Go 代码(pkg/outbox/outbox.go)。妙处在它的参数设计:接收一个"既能是数据库连接、也能是事务"的接口------传事务进来,事件就和业务写同生共死(Outbox 的本体);传连接进来,就是独立写入(补偿/兜底路径)。同一个函数两种语义,调用方一行代码切换。每个 BC 自己保留"把领域对象转成 JSON"的几行代码,SQL 这层不再重复------这是 T8 重构的成果,重构前的注释留了账:8 个 BC 各自维护一份几乎相同的 INSERT,约 300 行。
注册表 (T10)是一张人工维护的对照表:约 50 个事件类型 → 各自的 payload 结构版本。写入时自动按表盖章(当前只有 agent.content.generated 已经升到版本 2------版本机制真的被用上了,不是摆设)。为什么需要它?因为消费端拿到的是一坨 JSON,没有版本号就没法安全演进格式:生产者改了字段名,消费者按旧格式解析就静默出错。ADR-003 里把这个写成硬前提:"禁止在无版本裸 JSON 上建订阅者。"没注册的新事件类型不会在运行时报错(写入不中断),但 CI 里的测试会抓------又一个"运行时宽松、门禁严格"的分层设计。
三、消费侧:一个反直觉的决策------不接消息队列
Outbox 模式的"标准剧本"通常是:后台 relay 把表里的消息转发到 NATS/Kafka,订阅者各取所需。DeepFlux 的设计文档一开始也是这么写的。但 2026-07-02 的架构决策记录(ADR-003)把这条路否了,理由值得逐条念:
- "不为让文档成真而实现文档。" 全仓没有一行 NATS 订阅代码,当前唯一消费者是用量统计------对它来说 5 秒轮询延迟完全够用。
- 建一套消息总线意味着 NATS 集群、重投递、死信队列、监控------没有订阅者的消息总线是纯运维负担。
- 事件侧无订阅者,结构上不可能出现事件回环------这本身是架构健康的证据。
于是现状是:消费者直接轮询数据库表。你可能觉得"轮询"土,但它换来的是零新增组件 ------Postgres 本来就得高可用,表本来就在那,多一个每 5 秒来的 SELECT 而已。ADR 还留了重开的触发条件:出现"某 BC 需要近实时响应另一 BC 事件"的真实需求时再重评,前置条件(schema 版本化)T10 已经铺好。这是一个把"不做什么"写清楚、把"何时重开"也写清楚的决策记录------比"我们用了 Kafka"更有参考价值。
四、取货的规矩:认领、标记、以及 at-least-once
第一个消费者是 tools/etl(2026-05-23 落地):每 5 秒扫一次待处理行,把 3 种事件(hook.executed / agent.session.completed / llm.call.recorded)聚合成用量指标(谁的会话数、token 数、花了多少钱)。它的取货流程在一个数据库事务里完成三步:
sql
BEGIN;
SELECT ... FROM events_outbox
WHERE published_at IS NULL AND event_type = ANY(...)
ORDER BY occurred_at LIMIT 500
FOR UPDATE SKIP LOCKED; -- ① 认领:锁住这批行,别家正在处理的跳过
-- ② 聚合:把 500 行事件折算成指标,upsert 进 usage_metrics
UPDATE events_outbox SET published_at = now() WHERE id = ANY(...); -- ③ 标记已处理
COMMIT;
两个关键词,都是我这次在本机实测过的:
FOR UPDATE SKIP LOCKED------多消费者安全分账。 demo 里两个会话同时按"最早 3 行"认领:A 先到锁住 1-3 号,B 后到,跳过被锁的拿走 4-6 号,交集 0。没有这个子句的话,B 会等 A 的锁(白白排队);更糟的是没有锁的话,A、B 可能拿到同一批行处理两遍。SKIP LOCKED 是 Postgres 给"多 worker 抢任务"场景的专用武器------抢不到就走,绝不等待。
at-least-once(至少一次)------崩溃不丢,但可能重。 假如消费者在 ①③ 之间崩溃:事务没提交,标记没写上,这批行回到待处理,下一轮被重新处理------消息绝不丢 。代价是边界情况可能重复处理(① 处理完、③ 还没提交时崩溃,下轮再来一遍)。所以 ADR-003 在 2026-07-19 补了一节把语义写死:投递保证 = at-least-once,不追求 exactly-once ,消费者必须幂等(同一事件处理两次,结果和一次相同------靠 upsert 的"存在则累加不存在则插入"语义天然做到)。"至少一次 + 消费端幂等"是工程上比"恰好一次"便宜一个数量级、又足够可靠的组合------exactly-once 需要分布式事务或两阶段提交,复杂度指数级上升。
还有一条护栏:etl 的消费清单和事件注册表之间有一致性测试 锁定(consistency_test.go),防止"注册表里有、消费端忘了"或反过来。清单注释里还留了两条"幽灵事件"案底:tool.invoked 从未走过 outbox(真源是审计表),session.ended 的生产者从未存在(真实事件叫 agent.session.completed)------声明与实现的双轨漂移靠测试抓住,这正是 ADR-018 的主题。
五、第二个消费者与坏事件隔离
2026-09-05,第二个消费者上线(contenthub 生成内容投影:Agent 产出的文章/图片投影进内容库)。它带来两个新问题和新答案,都值得抄:
坏事件不能堵住好事件。 如果某一行事件格式坏了(生产者 bug),永远解析失败怎么办?旧式做法它会在每轮扫描里反复失败,占着批次。这里的做法是建一张按消费者隔离的失败表 (000162):解析失败的事件记下错误和"下次重试时间"(5 分钟后),主查询 LEFT JOIN 失败表------到点之前跳过它,到点了再试一次。坏事件被单独隔离,正常事件照常流转。
单条失败不能掀翻整批。 一批 100 条里第 37 条插入失败,整批回滚的话 99 条好事件陪着重来。这里用了 SAVEPOINT(事务里的"存档点"):每条事件的插入前打一个存档,单条失败回滚到存档点,记入失败表后继续处理第 38 条。最后批次提交时,好的进了、坏的留了案底。
同一张表、多个消费者、互不干扰 靠的是事件类型过滤:etl 只取它的 3 种类型,内容投影只取 agent.content.generated------各取各的,published_at 谁先处理谁标,类型不相交就永不相争。当前规模(两个消费者)这是最便宜的方案;消费者再翻几倍,就到了 ADR-003 留的重评点。
六、兜底的那一半:17 个 CronJob
前面都是"事件驱动"的优雅部分。但有一类问题它管不了:状态卡住等人的 。典型是"人机协作审批"(HITL):Agent 暂停下来等用户点"批准/拒绝",如果用户关掉浏览器再也不来,这个会话就永远停在"等待中"。这不是事件------没有"事"发生,只是时间到了没人管。
兜底的是定时任务族。仓库 cmd/cron/ 下现在有 17 个短命任务,按时间表由 K8s CronJob 触发,跑完即退:每天创建审计分区、每天归档老分区、每周淘汰记忆、每月重置配额、每 5 分钟发积分......这篇只细读和 outbox 同期落地的 hitl-timeout(2026-05-17,比第一个消费者 etl 还早 6 天):
- 行为 :每 5 分钟扫一遍"等待审批且已超时"的会话,逐个执行超时自动拒绝 ------代码注释明确写着"与人工点拒绝的处理器等价"。不是发明新逻辑,而是把既有代码路径换一个触发时机。
- 失败语义 :单会话失败记日志继续处理下一个;扫描阶段失败(数据库不可达)整个任务退出码 1------让 K8s 标记 Job Failed 触发告警,下个 5 分钟自动重试。"可重试的失败"和"要报警的失败"分开处理。
- 防御式设计:处理前重新加载会话状态------可能用户恰好在超时扫描的同一秒点了批准,状态已不在"等待",跳过即可(和 105 篇的"停止按钮三层对齐"同一哲学:永远假设别人可能先你一步改变了状态)。
cron 目录还有自己的 D-rules:定时任务可以直连数据库(同集群内网),但跨 BC 的数据必须走那个 BC 暴露的应用层接口,不许直接 SQL 撬别家的表------第 104 篇的 D4 铁律在"后台任务"这个场景下的具体化。
小结
- 双写问题是分布式系统里最经典的坑之一,Outbox 用"把消息当数据"一步化解。 关键动作只有一个:事件行和业务写进同一个事务。剩下的(谁来取、怎么标记)都是工程细节------但这些细节决定了它是玩具还是生产件。
- "至少一次 + 消费端幂等"优于追求"恰好一次"。 前者用 SKIP LOCKED(并发分账实测 0 交集)+ upsert(天然幂等)就够;后者要分布式事务,贵一个数量级。ADR-003 把语义白纸黑字写死,比口头约定可靠。
- 不接消息队列是一个主动决策,不是落后。 "不为让文档成真而实现文档"------零订阅者时轮询数据库最便宜;重开条件(真实订阅需求)和前置条件(schema 版本化)都写进了 ADR。技术选型的成熟度,往往体现在敢不做什么。
- 事件管"发生了的事",Cron 管"没发生的事"。 没人审批的会话、没人认领的过期数据------这类"时间的债"只能由定时任务定期清算。17 个 CronJob 与 outbox 消费者一起构成完整的后台面。
- 机器守的部分今天全绿 :三包单测
-count=1通过;SKIP LOCKED 并发 demo 在本机测试库实测(0 交集 + 崩溃重投)。生产环境的 etl/contenthub 投影运行状态本次未观测,如实分层。