当查询遇到分布式

在现代企业架构中,微服务式的服务拆分是必须面对的一个话题。具体为什么选择微服务,姑且不论。今天单聊一个微服务拆分后遇到的经典问题场景:列表类数据查询问题


分布式查询的困境

在单体架构的情况下,核心业务数据一般都存储在一个关系型数据库中。这时候的查询不论是过滤条件还是分页条件,都可以通过 SQL 交给数据库来执行。有其他人负重前行,自然一片岁月静好的景象。然而到了分布式场景,为了解耦上下游服务,通常倾向于每个服务管理自己的数据。拆分后的架构如下图所示:

how_to_query.png

原来可以直接通过 SQL join 操作查询的过程,无法再在一个数据库中完成。我们来举一个更具体的例子:

一个电商的运营后台,期望进行产品的用户画像分析,既能通过商品的属性查询到相关的购买用户,又能通过用户属性查询到其购买的商品信息。

在单体式架构中,用户信息存储在 User 表中,商品信息存储在 Good 表中,购买的关系存储在 Record 表中。上述的查询过程通过将不同的过滤条件分别映射到 UserGood 表的对应字段上,然后通过 Record 进行关联,仅通过一个 SQL 即可将查询结果返回。

而在分布式的情况下,用户信息由 User Server 服务维护,商品信息由 Good Server 维护,两个服务之间只通过 API 进行交互,无法再通过数据库联查。这就是分布式场景中的查询困境。


解决方案

好消息是分布式架构已经发展了很多年,上述问题已经形成了不少比较成熟的解决方案。然而,相比单体服务中实现查询的简单直观,在分布式场景中,不论选择哪种查询的实现方案,都需要引入比单体架构多得多的实现复杂度。

本节中我们先简单概述一下目前业界常见的查询解决方案:

  • API 聚合模式:这是最直观的一种实现方案。既然在分布式场景中我们无法在单个服务内解决查询问题,那就找个独立的组件来进行查询聚合。这个组件很显然应该放到查询的入口位置,那么在分布式架构中常见的 API Server 自然非常适合来承担聚合查询的职责。
  • CQRS 模式:全称为 Command Query Responsibility Segregation(命令查询职责分离)。既然 SQL 天然适合进行关联查询,那么想个办法继续将数据存储到一起来进行查询,但在实现数据统一存储的同时还要保证各个服务的数据自治。
  • 共用存储模式:虽然微服务模式提倡各个服务的数据自治(即单个服务维护自己的数据库),但是为了快速实现联合查询,如果能让多个服务共用一个数据库进行数据存储,既能实现服务的拆分,又能实现查询,岂不美哉。

但软件行业没有银弹。每解决一个问题的同时,一定会引入新的问题。银弹法则既是行业的诅咒,也是行业不断向前发展的驱动力。而架构决策的过程就是一个取舍(trade-off)的过程,每种方案都有优劣,接下来我们逐一进行分析。


API 聚合模式

API Server 中根据查询接口的查询逻辑,将查询请求拆分为不同的子请求转发到下游的其他服务上,在其他服务的结果返回后,将结果按照查询要求组合为结果返回给前端,如下图所示:

API_compent_model.png

由于数据被拆分到不同的数据库中进行存储,分页的逻辑需要从数据库迁移到 API Server 的内存中执行。更困难的是,如果数据的查询条件所对应的数据分布在多个服务中,API Server 将请求转发给下游服务的时候,无法预知每个服务应该返回多少条数据。拉取太多数据占用内存,大量的数据传输拉低查询效率;拉取少量数据又无法满足查询的分页条目要求。

根据查询情况的不同,我们可以将 API Server 查询分成两种情况:

  1. 数据属于不同的实体:不同实体的数据分布在多个服务中,API Server 将多个实体按照一定的规则组合为一个列表返回。比如一个请求既查询 A 产品信息(存储在 A 服务),也查询 B 产品信息(存储在 B 服务),返回的列表中包含 A、B 两种产品信息。
  2. 数据属于相同的实体:但是实体的属性分布在不同的服务中,查询的数据在 API Server 中组合为一个完整的实体返回。比如 A 产品信息的部分属性存储在 A 服务,剩余属性存储在 B 服务,返回的列表中要包含 A、B 服务中存储的属性。

我们分别对上述的情况提供列表查询的解决方案。

数据属于不同的实体

这种情况相对而言比较简单,我们可以分别从下游的服务读取指定条目的数据,然后再按照一定的规则将所有返回的数据排序,在内存中对排序后的列表进行分页返回。这依赖几个前提条件:

  • 1. 全局稳定排序:数据能够按照某个字段进行全局、稳定的排序。
  • 2. 记录游标:有个地方能记录各个服务的查询游标。因为最终返回分页列表数据可能来自多个下游服务,每个服务在下一次查询的时候都需要基于上次的查询游标进行迭代查询。

当下主流的分页查询方案主要有:

  1. offset-limit 方案offset 表示分页查询的起始游标,limit 表示查询的数据条目数量。
  2. next-token / max-result 方案next-token 表示下次查询的状态信息,max-result 表示查询的数据条目数量。

需要注意的是,next-token 不一定是某个数据库字段的值。它更常见的是一种不透明的、序列化后的状态令牌,其内容可能包含:最后一条记录的排序字段值(如时间戳、ID)、最后一条记录的唯一标识符、以及当前的分页状态(如已遍历的 ID 集合,在复杂过滤条件下)。如果将其直接暴露为数据库字段值可能存在安全风险(如信息泄露)或功能限制(无法处理复合排序)。

其实在常规的查询实现中,也推荐使用 next-tokenmax-result 的方案而非传统的 offset-limit 方案。其一可以提升查询效率,其次可以让查询时间一致(不会因为翻页越靠后而查询时间越长)。对于我们还有个好处:next-token 可以存储复合游标来表示我们之前讨论的多个下游服务的分页游标位置。这个复合游标在 Go 语言中可以定义为:

type unionIndex struct {
    serverAIndex uint32
    serverBIndex uint32
}

一个请求的查询实现过程如下:

  1. API Server 收到请求后,解析 next-token(为避免内部的实现细节暴露,next-token 一般需要在序列化后进行加密)。如果 next-token 中各服务游标为空,说明是第一次查询。
  2. 将查询转发给下游的多个服务,查询的数据条目数量为 max-result 个数。
  3. 下游数据返回后,按照全局排序字段的值进行排序,然后再在内存中进行分页。
  4. 根据分页的结果生成下一次分页查询的复合 next-token
  5. 将查询结果和 next-token 返回。

上述方案中需关注几个问题:

  • 向前翻页的场景:因为每次 API Server 只返回一个 next-token,可以考虑前端缓存一下 next-token,以实现向前翻页。
  • 查询条件变化next-token 的生成依赖查询条件,如果查询条件发生变化,则需要重新开始分页查询,无法通过原有的 next-token 计算出正确的新查询条件的分页数据。

数据属于相同的实体

这种情况的查询就要复杂很多。数据属于不同实体的查询本质是取查询结果的并集,所以在各自服务查询 max-result 条目数据后一定能满足分页查询的数据量。而数据属于相同实体的查询本质上是在取多个服务查询结果的交集,这就无法保证在查询指定数据量的数据之后能满足查询结果的条目要求。

如何才能获取到满足查询条件的列表数据,有两种思路:

  1. 获取全量 ID 列表:一次性获取全量的满足查询条件的 ID 列表,之后的查询对排序的 ID 列表进行内存分页,再从下游服务通过 ID 查询其他属性信息。
  2. 增量进行查询补全:增量地进行查询补全,不满足分页查询数量的时候循环查询下个游标的数据,直到满足查询结果为止。

我们接下来详细讨论下实现细节和优劣。

获取全量 ID 列表

在 API Server 收到请求后,将查询条件透传给下游服务但是不添加分页信息,只查询下游服务命中查询条件的 ID 列表。在收到所有响应后计算 ID 列表的交集,对这个交集在内存进行排序分页,即获取到了满足查询条件的 ID 列表。之后再以 ID 作为查询条件通过下游服务补全其他数据属性,作为本次查询结果返回。查询过程如下图所示:

all_id_list_query_model.png

  1. API Server 收到外部查询请求。
  2. API Server 将查询条件透传给 Server A 与 Server B。
  3. Server A、Server B 返回符合查询条件的全量 ID 列表,API Server 在内存中计算交集并分页。
  4. API Server 通过 ID 列表向各个服务查询补全其他剩余数据。
  5. Server A、Server B 通过 ID 查询返回属性数据,API Server 根据 ID 将数据合并。
  6. 将合并后的完整数据结果返回。

为了提升查询效率,可以将全量的 ID 列表进行缓存。之后的分页查询如果查询条件没有发生变化,可直接在缓存的 ID 列表基础上进行分页查询。

这个方案中第一次查询需计算全量的 ID 列表,可能有非常大量的数据传输。其次要考虑 ID 列表的缓存和失效问题。如果不考虑缓存,也可以按照 next-token 的逻辑随着分页逐步缩短全量 ID 列表的长度,但在数据量很大的情况下,依然难以解决数据传输问题。

增量进行查询补全

增量查询补全在每轮的查询中逐步从下游服务拿到分页的数据结果,在内存中计算交集。如果发现条目不足,则增加查询游标进行下一轮的数据查询,直到满足查询要求为止。整个查询过程比较直观,但有几个问题需要关注:

  • 查询的终止条件:满足查询的条目要求,或者下游服务返回空。为了避免陷入循环中耗时太久,需要设置一个查询轮次的上限限制。
  • 查询的下个起始游标:不满足查询条件下的末尾游标。
  • 内存缓存:查询到的数据需要缓存在内存中,因为多个服务的单次结果并不是完全 ID 匹配的。

该查询方案在数据比较均匀的情况下能改善查询效率,避免大量的 ID 列表传输。但如果查询条件命中的数据倾斜严重,可能会导致较多的无效数据传输过程。


CQRS 模式

既然多个服务拆分后的数据库无法进行关联查询,那么就将所有需要查询的数据收集到一个数据库中,这个数据库只面向查询使用,独立于各个服务,不对服务原本的业务逻辑产生入侵。

实现示意图如下所示:

CQRS_query_model.png

在 Server A、Server B 等业务服务将数据入库的时候,将查询用到的数据同步一份到只读查询数据库中。整个方案的难点在于如何保证数据的一致性,这主要表现在数据的同步方案上:

  • 1. 异步消息队列:通过 Kafka 等消息中间件来解耦数据的同步过程,避免同步过程产生的问题影响主服务的写入逻辑。
  • 2. CDC 自动触发(Outbox 模式):如何触发数据同步?如果由业务服务主动进行数据同步,一是对业务数据有入侵,其次服务的实现逻辑需考虑多个写操作与发送 API 之间的一致性问题。微服务设计模式中推荐使用 Outbox 模式,即通过监听数据库的数据变化 binlog(CDC 管道)来触发数据同步。

CQRS 避免了在 API Server 中产生大量的查询聚合逻辑,而是将查询的复杂度移到了数据写入端。这会导致查询产生一定的延迟,因此通常适用于“允许读延迟”的场景(如数据分析、报表、运营后台查询),而不适用于需要“强一致性读写”的场景(如用户立即查看自己刚下的订单)。


共用存储模式

共用存储模式见名知意,实现细节不再赘述。虽然看起来是技术成本最低的模式,但天下没有免费的午餐,该方案的成本隐藏在代码以外的地方。

软件的复杂度一是体现在技术的实现成本上,二是体现在人员的沟通成本上。随着软件行业的逐步发展,实现成本总会涌现成熟的技术方案得以借鉴,但沟通成本却有愈演愈重之趋势。服务进化到分布式的目的在于实现服务的职责解耦、降低沟通成本。而共用存储违背了这个初衷,为后续的迭代埋下祸患。

为什么不要共用存储?

  1. Schema 变更耦合:数据库的 schema 并不稳定,一个服务对 Schema 的改动会导致其他服务不兼容,变更成本高,影响范围广。
  2. Schema 废弃困难:由于无法使用显式的版本管理,导致管理 schema 的责任方只能增加字段而无法废弃旧字段,需要一直维护旧字段的入库逻辑。
  3. 无法共享行为:只能共享数据而无法共享行为。这要求关联的数据查询必须体现在数据库的字段上,无法在查询时做计算聚合。

总结

在这里对本文描述的方案做下总结和对比:

特性维度API 聚合模式CQRS 模式共用存储模式
核心思想在网关/API层进行查询分解、数据获取和内存聚合。将查询所需的数据从各服务同步到一个专供查询的只读数据库中。多个微服务在逻辑上解耦,但物理上共享同一个数据库。
查询延迟
(尤其是深分页或需要多轮聚合时)

(查询在单一数据库中进行,效率高),但存在数据同步延迟。

(与单体架构查询性能相当)
数据一致性强一致性
(直接查询各服务的权威数据源)
最终一致性
(查询库的数据滞后于源服务)
强一致性
实现与开发复杂度
(需在 API 层实现复杂的查询拆分、游标管理、数据聚合和错误处理逻辑)

(业务逻辑简单,但需搭建并维护可靠的数据同步管道)

(初期开发成本最低,与单体开发无异)
运维与架构成本
(无新增基础设施,但 API 层可能成为性能瓶颈和单点)

(需引入并维护消息队列、CDC 工具、查询数据库等额外基础设施)
极高
(隐性成本高,体现在团队协作和系统演进上)
扩展性
(各服务可独立扩展,API 层也可水平扩展)

(读写分离,查询库可独立扩展)

(数据库容易成为单点瓶颈,难以按服务进行独立扩展)
适用场景1. 查询条件灵活多变
2. 对数据一致性要求高
3. 涉及的服务和数据量不多
1. 复杂的报表和分析系统
2. 运营后台查询
3. 可接受秒级数据延迟的场景
1. 快速原型验证
2. 过渡期临时方案
(强烈不推荐用于长期正式项目)
主要优势灵活性最高,不破坏服务的自治性和数据所有权。查询性能最优,将复杂的查询问题转变为更简单的数据同步问题。初期实现速度最快,技术门槛低。
主要劣势与风险深分页性能差,内存和网络开销大,API 层逻辑极其复杂。架构复杂,存在数据延迟,可能重复存储数据。强耦合破坏服务自治,数据库 Schema 变更牵连广,影响系统演进。

架构的本质是权衡(trade-off),而非寻找银弹。

面对分布式查询的困境,API 聚合、CQRS 与共用存储三种方案,分别将复杂性代价支付在了查询时写入时未来

  • API 聚合以极高的即时开发复杂度为代价,换取服务的纯粹自治与数据的强一致性。
  • CQRS以数据延迟和架构复杂度为代价,换取极致的查询性能与清晰的职责分离。
  • 共用存储则以未来的技术债和团队协作成本为代价,换取眼前的开发便利。

最终的选择,并非寻求一个“正确”的答案,而是根据业务对一致性、延迟和团队结构的容忍度,选择一个“最合适”的代价。理解并主动管理这些代价,才是构建稳健分布式系统的核心要义。