LabHub
学习 学习路径 课程

微服务架构

双写问题及其解法

在 LabHub 中继续学习

一句话总结

写入数据库与向消息代理发布,是两个无法保证原子性的动作。Outbox 是把两者折叠进同一个数据库事务的技术。

概念图: 事件本身必须设计良好 · 事件是事实,不是命令。 · 决定是携带必要信息,还是只传引用。 · 源服务一旦宕机,事件就无法处理,异步拆分的优势也会消失。

为什么需要了解这一点

先看最常见的代码。

def create_order(req):
    order = db.save(Order(req))        # 1. DB 저장
    broker.publish("OrderCreated", order)  # 2. 이벤트 발행
    return order

这五行存在严重问题。如果第 1 步成功、第 2 步失败,订单已经存在,却没有任何服务知道。反过来,如果第 2 步成功后数据库事务回滚,世界上就会流传一个并不存在的订单事件。这就是双写问题。

“把两者放进一个分布式事务不就行了吗?”这种想法很自然,但大多数消息代理无法参与 XA 事务,而 2PC 本身还会在协调器故障时长期锁住参与者,引发可用性问题。因此,实务中会选择另一条路。

工作原理

Outbox 的思路很简单:不把事件直接发送到消息代理,而是在与业务数据相同的事务中,向 outbox 表插入一行。数据库 ACID 保证两次写入的原子性。之后由独立进程(relay)读取该表并转发到消息代理。

表设计中有四个重要字段:aggregate_id(用作分区键)、event_typepayload,以及状态或发布时间。Relay 有两种实现方式:定期查询 PENDING 的轮询,或读取数据库事务日志(WAL、binlog)的 CDC。CDC 延迟低、数据库负载小,但会增加一个运维组件。

这里必须理解一点:Outbox 消除了丢失,却无法消除重复。如果 relay 向代理发布后、把状态改为 PUBLISHED 前崩溃,重启后会再次发布同一事件。这就是 at-least-once;从这一刻起,消费者幂等就成为必需。Exactly-once 不是传输层创造的性质,而是接收方创造的性质。

Saga 解决的是另一类问题。它把一个跨越多个服务的业务事务,组织成一串本地事务,并在失败时执行补偿事务。付款成功、配送失败时,就执行取消付款的补偿步骤。关键在于 Saga 不是回滚,而是向前执行取消动作——已发送的邮件无法撤回,只能再发送一封“取消通知”。

Saga 有两种形式:编舞(各服务监听事件并执行下一步)与编排(中央协调者指示顺序)。步骤不超过三个时,编舞比较轻量;超过四个后,流程去向会变得无人能说清,此时编排更合适。

实际工作中的表现

Outbox 表若放任不管,会不断膨胀。必须定期删除已发布行,或直接裁掉旧分区。若不为 status='PENDING' 条件建立部分索引,relay 查询会扫描整张表。

补偿事务中的常见错误,是忽视补偿本身也可能失败。补偿也必须能够重试,因此补偿操作同样必须幂等。

如何设计事件

要让 Outbox 与 Saga 正常工作,流经它们的事件本身必须设计良好。这里若基础薄弱,即使技术实现完全正确,几个月后仍会崩塌。

事件是事实,不是命令。 OrderCreated 表示已经发生且无法撤销的事实,监听方自行决定如何响应。若把 SendEmail 这样的命令作为事件传播,发送方就必须了解接收方的情况,拆分服务也就失去了意义。

决定是携带必要信息,还是只传引用。 事件中包含完整订单,监听方无需回头查询,但事件会变大,其中的值也可能过时。反之,若只传 ID,监听方每次都要查询,负载会集中到源服务;而且源服务一旦宕机,事件就无法处理,异步拆分的优势也会消失。 实务中的折中是:必须固化为当时事实的值直接携带,需要查看当前状态的值保留引用。 这与前面规范化部分使用的是同一标准。

假定格式一定会变化。 事件一旦发布,多个服务会按各自速度消费,无法同时部署发送方与接收方。因此,只允许增加字段;删除字段或改变含义时,应发布使用新名称的事件。 如果把 schema 注册在某处并自动检查兼容性,这条规则就能自动执行。

携带时间与顺序。 在事件中加入发生时间与序号后,接收方能识别迟到的旧事件并忽略。前面提到重试会破坏顺序,这就是实用解决方案。若缺少这些值,接收方会把到达顺序误认为发生顺序。

下个练习将做什么

使用 SQLite 创建订单表与 Outbox 表;先复现双写不一致,再把两次写入折叠进同一事务。然后让 relay 发布到 Redis,确认 relay 中途崩溃时会产生重复,最后在消费者端添加去重。