作者:北哥
来源:BiggerBoy公众号mp.weixin.qq.com/s/-6ZOUTZT7…
微服务拆分后,原本在一个数据库里的多表关联分页查询(比如“订单表关联用户表”),会因为表被拆分到不同服务而变得困难。本文将用通俗易懂的方式,介绍四种主流解决方案,帮你找到最适合自己业务场景的方法。
一、API 组合模式:拼积木式查询
1. 核心思想
- • 像拼积木一样查询数据:将原本跨表的 SQL 查询拆分成多次服务间调用,在内存中手动拼接结果。
- • 适合场景:查询条件简单、数据量小(单页数据 < 1000 条)。
2. 实现步骤
假设要查询“订单列表(包含用户名称)”:
-
1. 调用用户服务:根据查询条件(如用户名称“Alice”)查出符合条件的用户 ID 列表。
// 用户服务返回:用户ID列表 [101, 102, 103] List<Long> userIds = userService.getUserIdsByName("Alice"); -
2. 调用订单服务:用这些用户 ID 查询订单数据。
-- 订单服务执行的SQL SELECT * FROM orders WHERE user_id IN (101, 102, 103) ORDER BY id LIMIT 10; -
3. 内存拼接数据:将订单数据和用户数据在代码里手动关联。
List<Order> orders = orderService.getOrdersByUserIds(userIds); List<User> users = userService.getUsersByIds(userIds); // 手动关联:把用户名称塞到订单里 List<OrderDTO> result = orders.stream() .map(order -> { User user = users.find(u -> u.id == order.userId); return new OrderDTO(order, user.name); }) .collect(Collectors.toList());
3. 优缺点
- • 优点:简单直接,无需数据同步。
- • 缺点:
-
- • 性能差:多次网络调用,内存处理耗时。
- • 分页不准:如果先查用户再查订单,分页可能错乱(比如用户有 100 个订单,但每页只显示 10 个)。
- • 不适合大数据量:内存拼接 1 万条数据可能导致程序崩溃。
二、数据冗余:把常用数据“抄”一份
1. 核心思想
- • 把其他服务的数据冗余一份存到自己库:比如订单服务冗余用户表的
user_name字段。 - • 适合场景:高频查询(如电商订单列表需要频繁显示用户名称)。
2. 实现步骤
-
1. 冗余字段:在订单表里直接存储用户名称。
CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, user_name VARCHAR(100), -- 冗余字段 amount DECIMAL ); -
2. 同步更新:当用户名称修改时,通过消息队列通知订单服务更新冗余字段。
-
-
• 用户服务:修改用户名称后,发送事件。
// 用户服务代码 userService.updateName(userId, "Bob"); kafka.send("user-updated-topic", new UserUpdatedEvent(userId, "Bob")); -
• 订单服务:监听事件,更新本地冗余字段。
// 订单服务监听代码 @KafkaListener("user-updated-topic") public void updateUserName(UserUpdatedEvent event) { orderDao.updateUserNameByUserId(event.userId, event.newName); }
-
3. 优缺点
- • 优点:查询极快(直接 JOIN 本地表)。
- • 缺点:
-
- • 数据一致性难:可能出现短时间不一致(比如用户改名后,订单列表可能暂时显示旧名称)。
- • 存储成本高:冗余字段占用额外空间。
三、CQRS(读写分离):专门建一个“查询数据库”
CQRS(Command Query Responsibility Segregation)命令查询职责分离,核心是通过读写职责分离和独立的读写模型来解决复杂查询问题。通过事件同步数据到专门用于查询的数据库(如Elasticsearch、MongoDB)构建读模型,然后将关联数据预先聚合到读模型中(如订单+用户信息),最终直接在读模型上执行分页和过滤,实现高效查询。
1. 核心思想
- • 读写分离:写操作和读操作用不同的数据库。
-
- • 写数据库:处理业务逻辑(如 MySQL),保证数据正确性。
- • 读数据库:专门优化查询(如 Elasticsearch),存储冗余的宽表数据。
- • 适合场景:复杂查询(如订单列表需要关联用户、商品、物流信息)。
2. 实现步骤
-
1. 写服务:业务操作时发布事件。
// 下单时发布事件 orderService.createOrder(order); kafka.send("order-created-topic", new OrderCreatedEvent(order)); -
2. 读服务:监听事件,更新读数据库。
// 读服务监听事件,更新Elasticsearch @KafkaListener("order-created-topic") public void updateReadModel(OrderCreatedEvent event) { User user = userService.getUser(event.userId); Product product = productService.getProduct(event.productId); // 构建宽表对象,存入Elasticsearch OrderReadModel readModel = new OrderReadModel(event, user, product); elasticsearch.save(readModel); } -
3. 查询时:直接查询读数据库。
GET /orders/_search { "query":{"match":{"user_name":"Alice"}}, "from":0, "size":10 }
3. 优缺点
- • 优点:查询性能极高,适合复杂场景。
- • 缺点:
-
- • 架构复杂:需维护两套数据库和同步逻辑。数据同步是 CQRS 实现过程中的关键挑战。
- • 数据延迟:读数据库的数据可能比写数据库晚几秒更新。
四、联邦查询:用一个“翻译官”跨库查询
联邦查询(Federated Query) 通常需要依赖中间件或特定技术组件来实现跨库、跨数据源的查询能力。这种中间件负责屏蔽底层异构数据源的差异,提供统一的查询入口,并协调多个数据源的查询执行与结果归并。
1. 核心思想
- • 联邦查询中间件:用户只需写一句 SQL,中间件自动拆解查询,从多个服务/数据库获取数据并合并结果。
- • 适合场景:跨多个服务的联合分析(如同时查 MySQL 订单数据和 Elasticsearch 用户行为数据)。
2.核心功能
(1) 统一查询入口
- • 提供标准 SQL 接口,用户无需感知底层数据分布和异构性。
(2) 跨数据源协调
- • 查询拆分:将复杂查询分解为多个子查询,分发到不同数据源并行执行。
- • 数据关联:支持跨数据源的 JOIN 操作,例如 Elasticsearch 与 Hive 表的关联查询。
(3) 性能优化
- • 执行计划优化:通过 RBO(基于规则优化)和 CBO(基于成本优化)选择最优查询路径。
- • 结果集归并:对分布式查询结果进行内存聚合、排序、分页处理。
(4) 事务与一致性管理
- • 部分中间件支持分布式事务(如 ShardingSphere 的 XA 事务),但多数联邦查询场景以最终一致性为主
3.核心实现方式
(1) 基于数据库中间件
比较有代表性的例如Apache ShardingSphere、SphereEx-DBPlusEngine、Quicksql。它们一般具有 SQL 解析与优化、数据路由与归并、异构数据源支持等功能特性。
(2) 基于数据库原生扩展
比较有代表性的例如Foreign Data Wrapper (FDW)、Google Bigtable 联邦查询。但仅适用于与特定数据库(如 PostgreSQL、Google Cloud)深度集成的场景。
(3) 自研轻量级路由组件
例如某银行基于 TiDB 的自研数据路由 SDK,按时间维度拆分数据到多个集群,通过动态路由和结果归并实现跨集群查询。这种方式主要是具有低侵入性和能够定制化优化的特点。
4. 实现步骤(以 Apache ShardingSphere 为例)
-
1. 配置数据源:告诉中间件有哪些数据库。
dataSources: order_db: MySQL连接信息 user_db: Elasticsearch连接信息 -
2. 写 SQL:直接写跨库 JOIN 的 SQL。
SELECT o.id, u.name, o.amount FROM order_db.orders o JOIN user_db.users u ON o.user_id = u.id WHERE u.city = '北京' LIMIT 10; -
3. 中间件处理:
-
- • 解析 SQL,发现
order_db是 MySQL,user_db是 Elasticsearch。 - • 将查询拆解为:
-
- • 到 Elasticsearch 查询
city=北京的用户 ID。 - • 到 MySQL 用这些用户 ID 查询订单。
- • 到 Elasticsearch 查询
- • 合并结果返回。
- • 解析 SQL,发现
5. 优缺点
- • 优点:无需改造业务代码,适合临时性分析需求。
- • 缺点:
-
- • 性能风险:跨网络查询可能很慢。
- • 功能限制:复杂 SQL 可能不支持。
五、如何选择方案?
| 方案 | 适用场景 | 举个栗子 |
|---|---|---|
| API 组合 | 简单查询,数据量小 | 管理后台的筛选订单列表 |
| 数据冗余 | 高频查询,允许短时间不一致 | 电商订单列表显示用户名称 |
| CQRS | 复杂查询,要求高性能 | 订单报表(关联用户、商品、物流) |
| 联邦查询 | 临时性分析,跨多种数据源 | 财务年度统计(需合并多个系统数据) |
如何选择?
- 1. 优先考虑数据冗余:90% 的高频查询场景可通过冗余解决。
- 2. 复杂场景用 CQRS:比如需要聚合多个服务数据的实时大屏。
- 3. 临时需求用联邦查询:偶尔跑一次的数据分析,不值得改造代码。
六、总结
微服务拆分后,跨服务查询没有“银弹”,需根据业务特点选择:
- • 简单查询:API 组合快速实现。
- • 高频访问:数据冗余 + 事件同步。
- • 复杂分析:CQRS 预聚合数据。
- • 灵活探索:联邦查询中间件。
实际项目中,通常会混合使用多种方案(如核心业务用 CQRS,边缘业务用 API 组合)。理解每种方案的原理和代价,才能找到最佳平衡点。
点赞转发支持一下,谢谢啦!,更多技术文章请关注公众号【BiggerBoy】