软件架构:通过 CDC 技术聚合多个微服务的数据

本文讨论 CDC 技术在微服务开发中的应用。在使用微服务以后,除了微服务带来的优势,随之而来的也有以前使用单体应用时不曾遇到的问题,比如:分库以后的多表联查、数据一致性等问题。本文将讨论以下两大问题应用 CDC 技术的解决方案:

  • 分库后的多表联查
    • CQRS(读写分离)
    • 实时数仓
  • 数据一致性
    • 采用事件消息表实现事件驱动性设计
    • 基于最终一致性的分布式事物

有关 CDC 的更详细介绍可以参考:

第一大场景:分库后的多表联查

在采用微服务架构设计后的每个服务使用单独的数据库,这时候存在一个场景,就是需要查询多个服务中的数据,比如:订单服务的列表页面接口需要查询商品服务里的商品明细信息、用户服务里的买家基本信息、……。

在传统方案中,很多时候会选择将基础数据(或维度数据)给冗余到业务表(如:订单表、出入库表等)中,通过这种方式来避免复杂的多表JOIN联查或跨服务的RPC调用。

数据冗余、查询时RPC调用聚合、SQL视图

当采用数据冗余方案时,会有如下缺点:

  1. 冗余数据会增加存储压力;
  2. 冗余数据会增加业务逻辑的复杂度,若需修改,则需要修改多个表,维护成本会增加;
  3. 冗余数据会降低“写”业务的性能,因为它需要存储更多数据
  4. ……

而采用查询时通过RPC调用来聚合多个业务服务的数据或者以前单库(单体服务时的单个数据库)的SQL视图方案,它的缺点是:

  1. 查询性能差,需要跨多个数据库查询;
  2. 难以进行索引优化,或者进一步使用专门的索引服务(如:Elasticsearch);
  3. SQL 视图会越来越复杂
  4. ……

通过 CDC 来实现读写分离模式(如:CQRS)

考虑这个案例:当在创建订单时,我们需要首先查询库存商品是否有货,然后生成订单并扣减或冻结相应库存。同时,我们还需要建模一张订单查询表,它能够看到订单的完整信息并支持复杂的查询。

示例数据

我们考虑如下几个微服务:用户、商品、库存、订单。假定我们都使用 PostgreSQL 作为数据库,并且每个微服务使用独立的数据库。一个简单的示例如下(对于本文讲解可用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
-- microservice/database: user (用户服务)
create table user (
id bigserial primary key,
name varchar not null,
status int not null default 1
);

-- microservice/database: commodity (商品服务)
create table commodity ( -- 商品
id bigserial primary key,
name varchar not null,
status int not null default 1
);
create table commodity_price ( -- 商品价格
id bigserial primary key,
commodity_id bigint,
price decimal not null,
begin_time timestamptz not null, -- 价格有效期起始时间
end_time timestamptz, -- 价格有效期结束时间,可为空(代表永远有效或一只到下一条有效价格起始时间为止)
status int not null default 1
);

-- microservice/database: inventory (库存服务)
create table inventory (
id bigserial primary key,
commodity_id bigint, -- commodity.commodity.id
quantity int not null, -- 库存数量,为理解简单这里直接使用整数
frozen_quantity int not null, -- 冻结数量,比如:订单已创建但还未出库
unit int not null, -- 库存单位,如:件、箱、KG等
status int not null default 1
);
-- create table inventory_record (....); -- 库存出入库明细记录略

-- microservice/database: order (订单服务)
create table order (
id bigserial primary key, -- 订单ID
buyer_id bigint not null, -- 买家用户ID, user.user.id
deliver_address_id bigint not null, -- 送货地址ID, delier_address 表暂略
status int not null default 1 -- 订单状态
);

create table order_item ( -- 订单明细
order_id bigint,
inventory_id bigint,
price decimal not null, -- 购买单价
quantity int not null, -- 购买数量
primary key (order_id, commodity_id)
);

会发现,示例的表结构足够简单,并没有使用冗余字段。这样在使用读写分离模式(CQRS)时,我们只需要写入 orderorder_item 两张表数据即可,而且只需要存储必要的业务表ID、价格、数量、状态、时间,不需要冗余商品名字、买家名字、送货地址等冗余字段。写入逻辑更简单,且可以按更范式化的方式进行设计,存储压力小,性能也更高。

而对于各种查询逻辑,我们可以通过 CDC 和事件消息通知来聚合多个业务服务的数据。而在这里可以有两种方案,方案一是使用一个统一的 PG ods 库;方案二是通过消费事件来填充 orderorder_item 表的冗余字段。我们先来讨论方案一。

方案一:通过 CDC 来实现实时“ods”

通过 CDC 机制,订阅各业务数据库表并“实时”同步到一个 ods 库中,我们可以把业务数据库名作为同步后的 schema 名,这样同步后的 ods 库表结构示例如下:

1
2
3
4
5
6
7
ods
order.order
order.order_item
inventory.inventory
commodity.commodity
commodity.commodity_price
user.user

这时,我们可以使用三种方式来实现查询 SQL:

  1. 添加一个公用服务:query-system。来实现所有业务的查询逻辑。对于查询SQL,比如:列表查询,我们可以继续使用 多表JOIN 来实现。
  2. 宽表(物化视图)。也可以进一步抽象出 dwd 库来实现宽表,这样可以进一步简化查询SQL。而宽表的生成,可以选择数据库的 物化视图 (对于同步延迟要求不高),
  3. 宽表(流计算)。对于宽表生成有速度要求的,可以基于 Pulsar CDC 进行流式计算生成。

相关技术知识在本文就不在细述,后续有机会另文介绍。

方案二:通过消费事件来填充冗余字段

对于一些架构设计来说,在一个“公用”的 query-system 服务中实现业务查询逻辑,打破了业务边界的隔离,可能不是一种好的设计。因此,我们可以通过消费事件来填充冗余字段。

在订单创建成功以后,可以发出一个事件:OrderCreatedEvent。然后我们再消费此事件,来更新如: order 表里的 buyer_name 字段,order_item 表里的 commodity_name 字段。

而对于 OrderCreatedEvent 事件,采用事件消息表的方式来发送。业务只需要在创建订单的同时向 domain_event 表里面同步写入事件记录即可,这样数据库的事务将确保订单创建与事件发出会同时成功或失败。而之后,通过 CDC 机制实时读取事件记录并发送到 MQ 中。

采用 CDC 来读取事件消息表和通过定时任务轮询读取事件消息表有什么区别?CDC 通过读取并解析数据库的 WAL 或 Redo 日志来实时获取表数据变更,相比定时任务轮询更及时,几乎没有延迟(秒级,通常在毫秒内可完成),且可以实时流式获取。这对于很多对及时性有要求的业务很重要。

会有数据延迟吗?

可能有同学会有疑问,这样通过 ods 或消费事件消息来同步数据或生成宽表,数据延迟怎么办?比如:当一个买家下单成功后,我想马上就在管理后台查询到这笔订单;或者前端APP创建订单成功后跳转到订单列表/订单详情需要显示订单。这能实现吗?

我们把问题分解来看。首先,CDC 同步数据到 ods 或者消费事件消息,它们的延迟都是在秒级的,对于类似管理后台这样的系统来说通常是可以接受的,我们不用特殊处理(当然,需要保证 CDC 系统的稳定性)。

但是对于前端APP来说,秒级延迟不可接受,从下单成功后跳转到订单列表或订单详情页面可能在毫秒级延迟。解决这个问题也不复杂,但需要前端同学多写一些代码。也有几种处理方式:

  1. 订单创建后直接跳转到订单详情页面(对于编辑功能,也可以采用编辑后保持在当前页面而不进行跳转),同时将创建订单时的冗余信息传递到订单详情页面(如:用户名、商品名称等);
  2. 若是跳转到订单列表页面,也采用上面方式,将新创建订单时的冗余信息传递到订单列表页以补充订单列表里刚创建订单的可能的冗余字段内容缺失。

第二大场景:数据一致性

传统单体服务时,因为所有业务都使用一个数据库,我们可以很简单的将数据一致性保障交由数据据事务来完成,但在微服务后因为数据分散保存在多个数据库中(有时甚至是异构数据库),数据一致性就变成了一个很棘手的问题了。通常会有两种方式来解决:

  1. 使用分布式数据库技术,如:Seata 等。但这通常会限制各微服务对数据存储的独立选型,并且通常用在能快速完成的事务场景;
  2. 使用最终一致性这样的“长事物”机制,如:Saga 模式。

结合的事件消息表 + CDC 捕获读取事件消息来发送到 MQ 中,这确保了类似 Saga 模式单个节点的数据变更(数据库表)和消息发送到 MQ 的事务一致性。在保证了单个微服务节点的数据一致后,多个节点之间的事件发送就可以使用一直重试到成功、重试到指定次数后回滚等策略来保证长事务的最终执行完成或回滚。而且在回滚过程中我们也可以明确地选择需要回滚到哪个节点。

区别于通常类似于 Seata 这样的一个节点失败则所有节点都回滚的方式。很多时候,我们并不希望一个长事物在执行几个节点后因遇到偶发的网络抖动或某种可修复原因造成的错误而回滚,我们更希望它能自动重度直到成功,或者在人工干预修复问题后成功。那这样,采用使用了CDC技术的事件消息表方法就是一个很不错的选择,它确保个单个服务的数据库处理和消息发送能够同时成功。

小结

读完这篇文章,我们知道了 CDC 是什么,以及它应用场景。相信读者在规划微服务架构时,也会遇到类似问题,希望本文能帮助到你们。

对于采用微服务后会遇到的数据同步和数据一致性,使用 CDC 技术能够很好的解决我们遇到的问题。而且,从业务开发者的角度出发,更多的只需要关心怎样设计合理的数据结构并持久化,而不需要关心数据同步和数据一致性。而对于复杂的数据查询,将其从写入数据时考虑延后到数据写入后按实际需求设计宽表或查询服务,CDC 也是我们落地实践 CQRS 的技术保障。

对于本文介绍的各种方法,要问我是否有选择建议?我的建议是:

  1. 对于多表联查:对于有多个前台系统的应用或者聚合查询业务场景很多的,可以考虑优先选择 ods 小数仓 + query-system 服务的方式。而对于领域边界清晰或有设计“洁癖”的,可以选择通过消费事件消息来填充冗余字段的方式;
  2. 数据一致性:建议使用类似 Saga 模式的长事务。

对于 Pulsar CDC 的使用,可以参考我的上一篇文章: 《通过 Pulsar CDC 获取 Postgres 数据表变更记录》。而后面,我也会专文介绍怎么通过 Pulsar CDC + Pulsar function 来结合 PostgreSQL 数据库实现事件消息表,让业务人员更专注于实现业务逻辑,而不需要关心消息发送的各类细节问题。

参考资料

分享到