微服务拆分后,如何解决跨服务分页查询?

264 阅读7分钟

作者:北哥

来源:BiggerBoy公众号mp.weixin.qq.com/s/-6ZOUTZT7…

微服务拆分后,原本在一个数据库里的多表关联分页查询(比如“订单表关联用户表”),会因为表被拆分到不同服务而变得困难。本文将用通俗易懂的方式,介绍四种主流解决方案,帮你找到最适合自己业务场景的方法。


一、API 组合模式:拼积木式查询

1. 核心思想

  • • 像拼积木一样查询数据:将原本跨表的 SQL 查询拆分成多次服务间调用,在内存中手动拼接结果。
  • • 适合场景:查询条件简单、数据量小(单页数据 < 1000 条)。

2. 实现步骤

假设要查询“订单列表(包含用户名称)”:

  1. 1. 调用用户服务:根据查询条件(如用户名称“Alice”)查出符合条件的用户 ID 列表。

    // 用户服务返回:用户ID列表 [101, 102, 103]
    List<Long> userIds = userService.getUserIdsByName("Alice");
    
  2. 2. 调用订单服务:用这些用户 ID 查询订单数据。

    -- 订单服务执行的SQL
    SELECT * FROM orders WHERE user_id IN (101102103ORDER BY id LIMIT 10;
    
  3. 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. 1. 冗余字段:在订单表里直接存储用户名称。

    CREATE TABLE orders (
        id INT PRIMARY KEY,
        user_id INT,
        user_name VARCHAR(100),  -- 冗余字段
        amount DECIMAL
    );
    
  2. 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. 1. 写服务:业务操作时发布事件。

    // 下单时发布事件
    orderService.createOrder(order);
    kafka.send("order-created-topic", new OrderCreatedEvent(order));
    
  2. 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. 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 ShardingSphereSphereEx-DBPlusEngineQuicksql。它们一般具有 SQL 解析与优化、数据路由与归并、异构数据源支持等功能特性。

(2) 基于数据库原生扩展
比较有代表性的例如Foreign Data Wrapper (FDW)Google Bigtable 联邦查询。但仅适用于与特定数据库(如 PostgreSQL、Google Cloud)深度集成的场景。

(3) 自研轻量级路由组件
例如某银行基于 TiDB 的自研数据路由 SDK,按时间维度拆分数据到多个集群,通过动态路由和结果归并实现跨集群查询。这种方式主要是具有低侵入性和能够定制化优化的特点。

4. 实现步骤(以 Apache ShardingSphere 为例)

  1. 1. 配置数据源:告诉中间件有哪些数据库。

    dataSources:
      order_db: MySQL连接信息
      user_db: Elasticsearch连接信息
    
  2. 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. 3. 中间件处理

    • • 解析 SQL,发现 order_db 是 MySQL,user_db 是 Elasticsearch。
    • • 将查询拆解为:
      • • 到 Elasticsearch 查询 city=北京 的用户 ID。
      • • 到 MySQL 用这些用户 ID 查询订单。
    • • 合并结果返回。

5. 优缺点

  • • 优点:无需改造业务代码,适合临时性分析需求。
  • • 缺点
    • • 性能风险:跨网络查询可能很慢。
    • • 功能限制:复杂 SQL 可能不支持。

五、如何选择方案?

方案适用场景举个栗子
API 组合简单查询,数据量小管理后台的筛选订单列表
数据冗余高频查询,允许短时间不一致电商订单列表显示用户名称
CQRS复杂查询,要求高性能订单报表(关联用户、商品、物流)
联邦查询临时性分析,跨多种数据源财务年度统计(需合并多个系统数据)

如何选择?

  1. 1. 优先考虑数据冗余:90% 的高频查询场景可通过冗余解决。
  2. 2. 复杂场景用 CQRS:比如需要聚合多个服务数据的实时大屏。
  3. 3. 临时需求用联邦查询:偶尔跑一次的数据分析,不值得改造代码。

六、总结

微服务拆分后,跨服务查询没有“银弹”,需根据业务特点选择:

  • • 简单查询:API 组合快速实现。
  • • 高频访问:数据冗余 + 事件同步。
  • • 复杂分析:CQRS 预聚合数据。
  • • 灵活探索:联邦查询中间件。

实际项目中,通常会混合使用多种方案(如核心业务用 CQRS,边缘业务用 API 组合)。理解每种方案的原理和代价,才能找到最佳平衡点。

 

点赞转发支持一下,谢谢啦!,更多技术文章请关注公众号【BiggerBoy】