双写问题,指在两个不同的系统,做相关联数据变更时,如何保证数据一致性[1]。
比如一个用户操作,涉及数据库变更和对外发送事件。如下图所示:
这两个操作,如何保证要么全做,要么全不做。
在数据库和消息系统都正常运行时,这两个操作都是完全可以都做的。所以仅仅配置MQ做消息持久化,并不足以保证消息发送做到至少一次。问题的重点在于,如何在网络中断、MQ宕机、系统断电的情况下,依然保证数据库与消息的一致性。
这个问题的解决难度并不高,但是也没有简单到先发消息再写数据库抑或先写数据库再发消息之间的选择这么简单[1:1]。
所以从开源社区到商业化服务提供商都在讨论这个问题的解决方案。包括:
这个问题唯一的难点在于:现有的事务实现,一般只能用于一个系统或体系,跨系统的事务性,只能够应用层自己处理。
单就数据库与消息系统间的双写问题,处理思路大体有三个
当涉及多个系统或服务时,可以使用Sagas来解决。或者说,双写问题的解决方案——Outbox模式,就是协同式Sagas,协调两两服务的基本模式。
从名字不错看出,这个模式的做法是,引入一张专门用于记录待发消息的表。然后用另一个任务或进程来负责将待发消息发送到消息系统。如下图所示:
本质上,是通过把一个有两个跨系统输出的过程,分解成了两个单入单出的过程来解决问题。其主要缺点是,有一定的业务侵入性。在微服务架构体系下,让每个服务都带一个Outbox表,也会增加每个服务的设计复杂度。更多关于Outbox模式的细节,可以参考Outbox模式
这个模式的实现非常简单,也不难找到现成的开源实现。
CDC(Change Data Capture)本质上是对Outbox模式的泛化实现,能在不侵入业务逻辑的前提下,达成和Outbox同样的效果。但是其主要问题在于:
CDC会依赖于数据库本身的能力,所以可以处理的场景会受到限制。比如,PostgreSQL的Logical Replication可以被用来实现CDC,但是会受制于PostgreSQL本身的约束[6]。如:
已经有开源的CDC实现可供直接集成使用。
Outbox模式的缺点和CDC的优点正好互补。所以不难得出一个集合二者优点的方案。即:用Outbox存放对外的领域事件,然后利用CDC将Outbox中的数据发送到消息系统中。
这样,使用方就只需要定义领域事件的结构,同时避免对外暴露内部数据对象的存储模型。同时,又不必麻烦编写额外的代码去负责把Outbox中的新增数据发送到消息系统。
其基本设计如下图所示:
关于这个实现方案的更多细节,可以参考这篇文章。
如果消息中间件,把自己模拟成数据库,并支持了数据库的XA分布式事务协议。便可以让消息与数据库变更事务化。但是并不是所有的消息中间件都支持消息事务。已知支持某种XA协议的消息中间件有:
更常见的消息中间件,如RabbitMQ, ActiveMQ及Kafka,均不支持事务。原因也很简单:影响性能[8]。
Martin Fowler在他的《Event Sourcing[9]》一文中这样讲:
The official system of record can either be the event logs or
the current application state. If the current application state is
held in a database, then the event logs may only be there for audit
and special processing. Alternatively the event logs can be the
official record and databases can be built from them whenever
needed.
如果不以数据库为系统的中心,而将事件本身做为数据的中心,那么不难得出这样的方案:
如果对于事件自产自消的行为有顾虑,也可以把消费事件持久化数据到数据库的过程放在独立的节点来执行:
事件溯源作为一种系统架构风格,是关系整个系统设计的重大决策,一般并不会为“解决双写问题”而引入事件溯源。这里只是为了解决方案的完整性将其列出。在实际项目中,需要有更好的理由来引入事件溯源。
以上方案都是应用层方案。应用层方案时常只能基于现有技术栈的能力来解决问题。但是双写问题之所以产生,有两种主要问题,一种是服务自己出错,还有一种,也是更主要的原因是网络问题等系统及硬件问题。如果能通过自定义网卡驱动对TCP协议本身进行强化,让连接本身做持久化,断掉之后能自动重连并且自动续传。然后所有应用层再基于这个加强版TCP协议,去做应用层协议。也可以避免双写问题产生。
但是这种做法的成本极高,需要从底层协议栈到所有中间件应用全部自研才能实现。没有现成的开源实现可以参考。