领域事件的发送与数据库变更的一致性问题。形如下图所示:
需要确保数据实体的变化,与消息的发送,最终保持一致。
引入一个用于存储待发消息的表,即Outbox表,将消息的持久化与业务数据的持久化放在同一个事务中。通过数据库的事务,保证业务数据变更与消息一致性。然后通过一个后台任务或异步线程,将消息表中的数据发送到消息系统上。如下图所示:
Outbox表的设计,需要能解决以下几个问题:
于是不难得出如下图所示的表设计(以PostgreSQL为例):
Column | Type | Description |
---|---|---|
ID | UUID | 主键 |
Type | Enum | 消息类型 |
Data | JSON | 消息数据体 |
Status | Enum | 消息的发送流程中的状态 |
OccurringTime | Timestamp | 事件的发生时间 |
SentTime | Timestamp | 事件的发送时间 |
Error | String | 事件的发送时的错误类型 |
ErrorDetail | String | 事件的发送时的错误详情 |
其中,错误类型与详情为可选列。
Status列的状态机定义如下:
每个状态的含义及作用如下:
Statue | Description |
---|---|
NEW | 新建事件,不可发送。可用于手工触发或事先审查 |
READY | 等待发送状态 |
SENDING | 已经被某个任务或线程获取用于发送 |
SENT | 成功送达* |
FAILED | 消息发送失败,需要根据错误类型分别处置 |
* 这里的“成功”,依赖于消息中间件的正确配置。如:如果是RabbitMQ,则需要开启Publisher Confirms[1],消息持久化及队列持久化。如果基于性能考量不能开启持久化,则需要利用Publisher Confirmation的来作为成功的标准,而不能简单地将发送完成,当作消息送达。
Outbox的主要问题是,它有额外的数据库负担,而且非常容易成为瓶颈[2]。主要来源于三个方面
尤其是当Outbox被设计成了一个通用事件存储器,用来存储所有事件的时候。如下图所示:
在做好数据Partition的情况下,至少可以确保Outbox本身不会成为性能瓶颈。最极端的情况如下图:
这样,Outbox本身只会有一定固定比例的额外成本,并不会独立成为资源使用上的瓶颈。
文中[2:1]给出的建议是,以事件为中心的方案。这已经超出Outbox模式本身的范畴。更多相关内容可以前往dual-writes。
一般高可用的服务不会只有一个节点,而且随着业务量的增长,会水平扩展出更多的服务节点。如果Outbox是共享的,就会出现下图所示的情况:
不难发现,Outbox很容易变成水平扩展的瓶颈。
依赖于后台Job来发送消息,总会有一定的延迟,取决于Job本身执行的间隔。间隔越短,消息发送越及时,但是相应地,数据库负载也会更高。
Outbox模式本质上承载了两部分的功能。一个是领域事件的收集与存储,另一个是领域事件的发送。而事件的发送,其实是应该由服务中的其它组件或其它的机制统一负责。于是人们常常把Outbox和CDC[3]或Transaction Log Tailing[4]结合起来使用。让Outbox只负责事件的收集与存储。