代表性面试主题

如何设计 Transactional Outbox 保证数据库与消息最终一致?

后端困难
OfferCC 编辑团队发布 更新

题干

订单服务需要在一次请求中更新数据库并发布事件,但数据库提交和消息发送不能使用分布式事务。请设计 Transactional Outbox,并说明中继、重复消息、顺序、故障恢复和清理策略。

1. 题目

订单服务创建订单时,必须提交订单状态并发布 OrderCreated 事件供库存和通知服务消费。数据库和消息代理没有共同的两阶段提交。请设计一套 Transactional Outbox 方案,要求事件不因进程崩溃而静默丢失,并能处理重复投递和中继积压。

2. 约束与澄清

  • 订单数据与 Outbox 表位于同一个本地事务数据库。
  • 消息代理支持至少一次投递,但不承诺全局顺序或事务发送。
  • 业务允许最终一致;库存消费者必须幂等。
  • 需要说明单个聚合的顺序、跨聚合是否需要顺序,以及保留和删除期限。

3. 核心思路

在同一个数据库事务中写入订单变更和一条 Outbox 记录,记录包含唯一 event_id、聚合键、事件类型、序列号、载荷、创建时间和发布状态。事务提交成功意味着业务数据与待发布事件同时存在;事务回滚则两者都不可见,消除了应用层数据库写入与发送之间的双写窗口。

独立的消息中继轮询或订阅 Outbox,发送到代理后再标记已发送。发送确认与标记不是同一事务时,崩溃可能导致同一事件再次发送,因此消费者以 event_id 去重,而不是假设“只发送一次”。

4. 参考实现

text
createOrder(command):
  begin transaction
  order = insert orders(...)
  event = insert outbox(
    event_id=uuid(), aggregate_id=order.id,
    aggregate_version=order.version, type="OrderCreated",
    payload=serialize(order), status="pending"
  )
  commit
  return order.id

relayBatch():
  rows = select pending outbox rows
         order by aggregate_id, aggregate_version, created_at
         for update skip locked limit BATCH_SIZE
  for row in rows:
    try:
      broker.publish(key=row.aggregate_id, id=row.event_id, body=row.payload)
      mark_sent(row.event_id)  // conditional update
    except transient_error:
      increment_attempts_and_schedule_retry(row.event_id)

consume(message):
  begin transaction
  inserted = insert processed_messages(message.id) on conflict do nothing
  if inserted:
    apply_business_change(message)
  commit

5. 可靠性与正确性

业务事务成功但进程在中继发送前崩溃时,待处理记录仍会被下一次扫描发现。中继发送成功后、状态更新前崩溃时,下一轮会重发;因此端到端语义是至少一次,消费者去重表或业务幂等键必须与业务变更放在同一个消费者事务中。

同一聚合的顺序可由单调版本号和按聚合键分区保证;不要承诺跨聚合全局顺序。SELECT ... FOR UPDATE SKIP LOCKED 或租约字段可避免多个中继重复领取,但不能代替消费者幂等。Outbox 表需要索引(状态、重试时间、创建时间),并通过归档或安全删除控制增长。

6. 追问与陷阱

  • 把“发布成功”后立即删除记录会在确认丢失时造成不可恢复缺口;应先持久化发送状态或保留可审计记录。
  • 代理确认超时不等于代理未收到消息,重试必须接受重复事件。
  • 只在应用内先写数据库、再调用代理仍然存在双写竞态,不能靠 try/catch 获得原子性。
  • 如果 Outbox 与业务表不在同一数据库事务边界,需要改用 CDC、事务消息或重新定义一致性保证。

7. 延伸阅读

可以比较轮询发布器与 CDC 中继:轮询部署简单但会产生扫描和延迟,CDC 延迟较低却增加日志捕获与运维依赖。还应讨论毒性消息、指数退避、死信队列、监控 pending age,以及消费者 schema 兼容策略。

8. 面试评分点

能准确定位双写窗口

应指出数据库提交与代理发送无法由普通本地事务同时提交,并把订单变更和 Outbox 记录放入同一事务。

能解释至少一次与幂等

应描述中继崩溃导致重复发送的具体时序,并让消费者以事件 ID 去重且与业务更新共用事务。

能处理顺序和并发

应区分单聚合顺序与全局顺序,说明分区键、版本号、锁或租约如何限制并发领取。

能覆盖运营边界

应提出重试退避、死信、积压告警、归档清理和 schema 演进,而不是只给出一张表结构。

公开来源

同类题目

资源

产品对比

每份对比均基于官方资料,并标注复核日期。

查看完整对比