题干与适用场景
订单服务收到一个命令后,需要完成两件事:
- 把订单写入 PostgreSQL;
- 向消息队列发布
OrderCreated事件。
业务约束不能只是“两个调用都试一下”。数据库事务回滚时,不能发出一条描述不存在订单的 事件;事务提交后,即使进程马上崩溃,发布意图也必须保留下来,并最终到达消息队列。同一订单 的事件需要有序,不同订单之间不要求全局顺序。中继和消费者可能在任意位置崩溃,消息队列也 可能重新投递,而且系统不能使用分布式两阶段提交。
这就是事务型发件箱问题。只要一次请求既修改事务数据,又要可靠触发另一个系统,就会遇到 同类场景,例如预留库存、计费、更新搜索索引、发送邮件、调用 Webhook 或记录分析事件。目标 不是凭空获得“恰好一次投递”,而是找出现有的原子边界,把跨边界意图可靠保存,并让重试安全。
面试官考察点
第一项是能否准确分析失败窗口。“先写数据库,再发消息”会在数据库提交后、消息发送前的崩溃 中丢事件;“先发消息,再提交数据库”会让消费者看到一个后来回滚的订单;内存中的提交后回调 也会随进程一起消失。高质量回答会先证明这些方案为什么失败,再引出设计。
第二项是能否说清保证边界。业务行和发件箱行可以在同一个本地数据库事务中原子提交,消息 发布则在之后异步进行。这样能保证每个已提交变更都有持久的事件意图,但没有把数据库与消息 队列变成一个事务,也没有承诺恰好一次投递。
第三项是端到端的重试安全。如果消息队列已接收事件,中继却在记录成功前崩溃,恢复后会再次 发布。因此,中继提供的是至少一次发布,消费者必须让业务效果幂等。还要区分消费者的本地 数据库效果与扣款等外部副作用,两者的原子边界不同。
最后是顺序与可运维性:按聚合分配序号、选择分区键、中继并发抢占、毒消息、重试、清理、回放 保留期、延迟指标和故障注入测试。只说出模式名称还不够。
回答前需要澄清的问题
- 到底需要什么保证? 至少一次发布加业务效果只执行一次是否足够,还是必须在响应调用方前
同步确认下游已收到?
- 哪个变更与哪些事件绑定? 一次订单变更可能产生一个事件,也可能在一个事务里产生多个
事件,后者需要连续的订单内序号。
- 顺序范围是什么? 本题假设只要求同一订单内有序,不要求所有订单形成一个全局总序。
- 消息队列能保证什么? 需要确认确认机制、重新投递、分区顺序、保留期和生产者幂等;这些
能力本身都无法消除数据库到消息队列的交接间隙。
- 事件最晚多久可见? 延迟目标会影响轮询间隔、数据库负载,以及是否值得引入变更数据捕获。
- 消费者具体做什么? 本地数据库更新可以和收件记录共用一个事务;外部扣款或发邮件需要
下游幂等键,或者再增加一次可靠交接。
- 需要支持多久的回放? 发件箱与消费去重记录的清理策略必须覆盖要求的重试和回放窗口。
- 两种资源是否能参加两阶段提交? 本题明确不能。真实系统若必须同步跨资源原子提交,且
两边确实支持,仍应评估它的可用性和耦合成本,不能笼统断言永远不可用。
30 秒回答框架
“我会在同一个 PostgreSQL 事务里写订单和一条不可变的发件箱事件。独立中继读取已提交记录, 先抢占、再发布,只有收到消息队列确认后才标记为已发布。中继在发布前崩溃,记录仍待处理; 消息队列接收后、中继标记前崩溃,则会重复发布,所以保证是至少一次。每个事件使用稳定 ID, 消费者把该 ID 的去重记录和业务更新放进同一个事务。顺序方面,我会分配订单内单调序号,以 订单 ID 作为分区键,并阻止后续事件越过尚未发布的前序事件。最后监控最老待发布事件的年龄, 并在提交、发布、确认和消费的每个边界注入崩溃来验证不变量。”
分步骤深入解答
先证明直接调用为什么不成立。在数据库优先的流程中,数据库可以在 T1 提交,进程却在消息 队列于 T2 接收前退出,于是订单存在而事件不存在。重试 HTTP 请求也不是完整修复:客户端 可能不重试,而且命令本身若不幂等,重试还会创建重复订单。消息队列优先则会让消费者先看到 事件,随后订单事务却失败。交换调用顺序只是交换不一致的方向。
接着把事件意图放进服务真正拥有的原子边界。在一个 PostgreSQL 事务中校验命令、修改订单、 分配该订单的下一个序号,并插入不可变发件箱记录。两行一起提交或一起回滚。一个代表性表结构 如下:
CREATE TABLE outbox_events (
event_id uuid PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
aggregate_sequence bigint NOT NULL,
event_type text NOT NULL,
schema_version integer NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
claimed_by text,
claim_until timestamptz,
published_at timestamptz,
attempt_count integer NOT NULL DEFAULT 0,
last_error text,
UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);
CREATE INDEX outbox_dispatch_idx
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;eventid 在每次重试中保持不变,schemaversion 明确载荷演进规则,聚合序号唯一约束防止两条 事件占用同一个逻辑位置。序号必须在聚合记录相同的事务与锁规则下分配,时间戳或中继处理顺序 不能代替它。若一个事务产生多条事件,就按预期顺序分配连续序号。
轮询中继应该在一个短事务里领取小批量记录:用 FOR UPDATE SKIP LOCKED 选中记录,更新 claimedby 与 claimuntil,然后提交。行锁释放后,已持久化的租约可以阻止其他工作进程主动 处理同一行。中继在长数据库锁之外发布消息,收到消息队列确认后再把记录标记为已发布。工作 进程崩溃后,租约过期即可恢复。退避与 available_at 可以避免下游故障时形成紧密重试循环。 让数据库事务跨越网络发布既会增加争用,也不会让消息队列参与同一个原子提交。
确认间隙无法消除。消息队列可能已持久接收事件 E,中继却在写入 published_at 前崩溃; 恢复后 E 会再次发布。如果提前标记,又会产生永久丢失事件的间隙。因此中继应选择安全的一侧: 允许重复,不能丢失,并要求消费者去重。
如果消费者的业务效果写入数据库,可把已处理事件 ID 与业务效果放进同一个事务:
CREATE TABLE processed_events (
consumer_name text NOT NULL,
event_id uuid NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);消费者开始事务,对 (consumername, eventid) 执行 INSERT ... ON CONFLICT DO NOTHING RETURNING,只有插入返回记录时才执行业务更新,然后提交。 没有返回记录表示该事件已经生效,重复消息可以直接确认而不再执行更新。若去重记录和业务效果 分属两个事务,就重新制造了双写问题。去重记录的保留时间至少要覆盖旧事件仍可能回放的时间。
本地收件表不能原子覆盖非事务型外部副作用。调用支付接口时,应把 event_id 作为服务商的 幂等键。下游若不支持幂等,就需要另一个持久命令或发件箱加对账,或者明确接受重复风险。 发邮件、Webhook 等不可逆调用也有相同边界。
顺序要贴合业务范围。为每个订单分配单调序号,不允许序号 k + 1 越过仍未发布的 k,并以 aggregate_id 作为消息队列分区键。领取查询可以只选择每个聚合尚未发布的最小序号,也可以按 aggregate_id 的稳定哈希划分中继所有权;两种做法都必须让每个聚合只有一条有序发布通道。 消费者发现序号缺口时,可根据业务选择拒绝、缓冲或对账。强行建立全局序号会把无关订单串行化, 降低可用性,却无助于订单内不变量。
轮询中继最简单、可移植,所有权在应用数据库中也清晰,但轮询间隔需要在延迟和查询负载之间 权衡。数据量增长后,需要待处理索引、小批次、租约和有界清理。变更数据捕获可以追踪数据库 日志并转发新增的发件箱记录,通常能降低轮询压力与延迟,但会把连接器偏移、数据库日志保留、 部署和恢复纳入运维边界。直接捕获任意业务表还会暴露存储层变更,而不是明确的领域事件; 显式发件箱能保持契约稳定。
运维是设计的一部分。至少监控待发布数量、最老待发布记录年龄、发布吞吐与失败、尝试次数、 过期抢占、消息确认延迟、消费者去重数量、隔离事件和表增长。只有跨过回放与审计窗口后,才 分批归档或删除已发布记录。毒消息需要明确选择重试、隔离还是修复;跳过它可能破坏订单内 顺序,所以同一聚合的后续事件不能无声继续。
验证必须针对边界,而不只是成功路径。分别在数据库提交前、提交后响应前、中继领取时、发布 前、消息队列接收后但写 published_at 前、消费者业务提交后但确认前,以及清理过程中注入 故障。测试要建立四个不变量:
- 每个已提交业务变更恰好有一个持久发件箱意图;
- 每个已回滚变更没有发件箱意图;
- 每个持久意图在系统恢复后最终至少发布一次;
- 无论重复投递多少次,消费者可见的业务效果只应用一次。
还应暂停中继制造积压,重启后验证延迟恢复、订单内顺序、数据库负载边界和告警;同时覆盖毒 消息、载荷版本升级、旧事件回放及保留期边缘的清理。
高质量示范回答
“数据库与消息队列不能原子提交,所以我会先把事件意图纳入数据库事务。订单行和不可变发件箱 行一起提交。事务回滚时两者都不存在;事务提交后进程立刻崩溃,其他进程仍能看到发件箱记录。
中继使用短数据库事务和会过期的租约领取待处理记录,在网络发布期间不持有数据库锁,收到 消息队列确认后才写入 published_at。消息队列接收后、状态更新前仍有崩溃窗口,所以中继可能 重复发布。这个取舍是有意的:重复可以用幂等恢复,事件丢失却无法自动恢复。
每条事件使用稳定 UUID。数据库消费者在同一个事务中,把 UUID 写入按消费者命名的去重表, 并完成业务更新;重复事件触发唯一键冲突,成为无操作。如果消费者调用支付或邮件服务,还要 把事件 UUID 作为下游幂等键或增加另一次可靠交接,因为本地去重事务无法包含远程副作用。
顺序方面,我在订单事务中分配序号,以订单 ID 作为分区键,并阻止后续序号越过尚未发布的前序 事件,不强求全局顺序。若延迟和负载目标没有证明需要 CDC,我会先用轮询,然后监控最老待发布 年龄、重试、过期租约、重复率、毒消息和表增长。
最后,我会在每个边界杀死进程。要求的结果是:回滚不产生意图,提交一定留下意图,系统恢复 后每个意图至少发布一次,重复投递只改变一次消费者状态。发件箱解决可靠交接;请求幂等、消费 幂等、模式演进和对账仍是系统的显式组成部分。”
常见错误
- 依次调用数据库与消息队列 → 任一调用都可能单独成功 → **把业务变更和事件意图放入一个
本地数据库事务。**
- 提交后调用内存发布器 → 崩溃会连同回调状态一起丢失 → 返回前持久保存发布意图。
- 声称发件箱保证恰好一次投递 → 消息已接收但状态未记录的间隙会产生重复 → **明确至少一次
发布,并设计业务效果只执行一次。**
- 在消息队列确认前标记已发布 → 崩溃可能永久丢事件 → 确认后才记录成功,并容忍再次发布。
- 把消费去重与业务效果分开写 → 消费者重现同一个双写间隙 → 两者放在同一本地事务。
- 认为本地收件表能保护远程扣款 → 远程效果不能加入本地事务 → **使用下游幂等键、可靠交接
与对账。**
- 用时间戳表示顺序 → 时钟和并发无法分配唯一因果位置 → **事务内分配聚合序号,并以聚合 ID
作为分区键。**
- 多个轮询器没有抢占或租约 → 工作进程会主动争抢同一行 → **使用短抢占、过期机制、小批量
与待处理索引。**
- 立即删除已发布与去重记录 → 延迟重试和回放可能重复旧效果 → 按明确的回放与审计窗口清理。
- 只测发布成功 → 保证都体现在崩溃窗口 → 在每个持久边界前后注入故障并断言不变量。
追问及应对
追问 1:目标是外部 API,而不是消息队列怎么办?
源事务仍可写入一条发件箱命令。工作进程调用 API 时,以 event_id 作为幂等键,并保存响应。 超时是歧义状态,远程服务可能已经完成操作,所以重试必须沿用同一个键。若 API 既不支持幂等, 也不能查询操作状态,就无法保证业务效果恰好一次;需要增加对账,或把重复风险写进业务契约。
追问 2:工作流跨越多个服务和数据库怎么办?
发件箱能可靠发布每个服务的本地状态变化,但不能原子提交整个多服务工作流。应把流程建模为 Saga,明确正向步骤、幂等、持久状态与补偿操作。每个 Saga 步骤都可使用自己的本地事务加发件箱。 还要定义补偿本身失败时如何处理,不能把它描述成所有数据库一起回滚。
追问 3:面试官要求严格全局事件顺序怎么办?
先澄清无关聚合为何需要同一顺序,以及愿意牺牲多少吞吐或可用性。单一序号生成器或消息队列的 单分区能建立总序,但也会成为串行与故障瓶颈。多数订单流程只需要订单内因果顺序,用事务型 聚合序号与聚合分区键成本更低。
追问 4:CDC 连接器宕机后如何恢复?
已提交发件箱行仍是事实来源。需要监控连接器延迟和数据库日志保留余量,可靠保存连接器偏移, 并测试从最后确认偏移重启。数据库日志必须覆盖故障恢复目标;超出后需要快照或受控回填。消费 去重让重放一段重叠范围仍然安全。
追问 5:哪些情况不应使用事务型发件箱?
事件明确允许尽力而为,例如可丢弃遥测;或者下游能安全轮询事实来源,且延迟目标允许时,可以 选择更简单的设计。若两种资源确实都支持两阶段提交,且同步原子性是硬要求,可以结合耦合与 可用性成本评估它。事件溯源也是替代方案,但它会改变事实来源模型,不应只为绕过一次交接而引入。