☰
分库分表架构下的分页查询解决方案与实践
2026/10/27 13:11:08 网站建设 项目流程

1. 分库分表架构下的分页查询困境

当数据库达到千万级数据量时,单表查询性能会显著下降。去年我们电商系统就遇到了这个问题——订单表超过5000万条记录后,最简单的分页查询都要花费3秒以上。这时候分库分表就成了必然选择,但随之而来的就是分页查询这个看似简单的需求变得异常复杂。

我见过不少团队在分库分表后,发现原本流畅的分页功能突然变得卡顿甚至出错。典型症状包括:

  • 页码跳转时出现重复数据
  • 每页显示数量不稳定
  • 深度分页(如第100页)查询超时
  • 排序结果与预期不符

这些问题本质上是因为数据被分散到不同物理节点后,传统的LIMIT offset, size分页方式完全失效了。下面这张对比表能清晰说明问题:

查询类型单库单表分库分表
SELECT * FROM orders ORDER BY create_time DESC LIMIT 10000, 20直接定位到10000行开始取20条需要在每个分片取10020条再合并
性能影响线性增长指数级恶化
结果准确性完全准确可能出现重复/遗漏

2. 分页查询的四种解决方案实践

2.1 全局视野法:简单但昂贵的方案

这是最直观的解决方案——在所有分片执行完整查询,然后在内存中合并排序。我们最初在订单系统就是这么实现的:

-- 每个分片执行 SELECT * FROM orders_${shard} ORDER BY create_time DESC -- 应用层合并所有结果后 List<Order> allResults = mergeFromAllShards(); return allResults.stream() .skip(offset).limit(pageSize) .collect(Collectors.toList());

警告:当单分片数据量达到百万级时,这种方案会导致JVM内存溢出。我们曾因此导致整个订单查询服务崩溃。

适用场景:

  • 数据量较小(单分片<10万条)
  • 分片数量少(<=2个)
  • 对查询延迟不敏感的后台系统

2.2 二次查询法:平衡性能与准确性的选择

这是目前我们生产环境采用的方案,核心思想是:

  1. 先获取符合条件的ID集合
  2. 再根据ID精确查询完整数据

具体实现代码:

// 第一阶段:获取分页ID List<Long> ids = shardJdbcTemplate.queryForList( "SELECT id FROM orders WHERE status=? ORDER BY create_time DESC LIMIT ?,?", Long.class, status, offset, pageSize); // 第二阶段:获取完整数据 List<Order> orders = shardJdbcTemplate.query( "SELECT * FROM orders WHERE id IN (:ids) ORDER BY create_time DESC", new MapSqlParameterSource("ids", ids), new OrderRowMapper());

性能对比(测试环境数据):

数据量传统分页二次查询
50万1200ms300ms
500万超时800ms

2.3 分片路由法:需要业务配合的优化

如果查询条件包含分片键(如user_id),可以直接路由到特定分片执行传统分页。我们用户中心的查询就采用了这种方式:

-- 根据user_id确定分片 SELECT * FROM orders_${user_id % 4} WHERE user_id = ? ORDER BY create_time DESC LIMIT ?,?

关键限制:

  • 必须包含完整分片键
  • 不支持跨分片排序
  • 分片策略变更时需要数据迁移

2.4 游标分页法:适合无限滚动的场景

类似于微博、抖音的"加载更多"模式,使用上一页最后一条记录作为锚点:

// 请求参数包含last_id和last_create_time List<Order> orders = shardJdbcTemplate.query( "SELECT * FROM orders WHERE create_time < ? OR (create_time = ? AND id < ?) " + "ORDER BY create_time DESC, id DESC LIMIT ?", lastCreateTime, lastCreateTime, lastId, pageSize);

优势:

  • 完全避免offset问题
  • 性能稳定不受页码影响
  • 天然防翻页过程中的数据变动

3. ShardingSphere的实践方案

对于使用ShardingSphere的项目,它提供了内置的分页优化方案。在我们的财务系统中这样配置:

spring: shardingsphere: props: max.connections.size.per.query: 5 # 控制每次查询的最大连接数 sql.show: true sharding: tables: t_order: actual-data-nodes: ds${0..3}.t_order_${0..15} table-strategy: inline: sharding-column: order_id algorithm-expression: t_order_${order_id % 16} database-strategy: inline: sharding-column: user_id algorithm-expression: ds${user_id % 4}

关键配置项说明:

  • max.connections.size.per.query:控制分片并行度
  • allow.range.query.with.inline.sharding:是否允许范围查询
  • executor.size:执行线程池大小

实测发现:当分片数超过8个时,需要适当调大executor.size(我们设置为分片数的1.5倍)

4. 分页查询的避坑指南

4.1 排序字段选择的三原则

我们踩过的坑:曾经使用"更新时间"作为排序字段,结果出现大量相同值导致分页混乱。现在强制要求:

  1. 必须包含主键作为次要排序条件
  2. 优先选择值唯一的字段(如创建时间)
  3. 避免使用频繁更新的字段

错误示例:

-- 可能导致分页异常 SELECT * FROM orders ORDER BY update_time DESC LIMIT 100, 20

正确写法:

SELECT * FROM orders ORDER BY create_time DESC, id DESC LIMIT 100, 20

4.2 深度分页的优化技巧

对于必须支持跳转到任意页码的场景(如后台管理系统),我们采用"ID分段缓存"方案:

  1. 建立ID索引表:
CREATE TABLE order_id_index ( seq_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT, create_time DATETIME, INDEX idx_ct (create_time) );
  1. 分页查询改为:
SELECT o.* FROM orders o JOIN ( SELECT order_id FROM order_id_index ORDER BY create_time DESC, order_id DESC LIMIT 10000, 20 ) tmp ON o.id = tmp.order_id

性能提升对比:

方案第10页(200条)第100页(2000条)
直接分页450ms超时
ID索引80ms120ms

4.3 分布式事务下的特殊处理

在支付系统中,我们发现分页查询可能漏掉刚提交但未同步的数据。解决方案:

  1. 为所有写操作添加binlog监听
  2. 查询时先检查本地事务快照
  3. 必要时进行二次验证

核心代码逻辑:

public List<Order> queryWithConsistency(String userId, int page) { List<Order> orders = queryFromDB(userId, page); List<Long> recentTxIds = getRecentTransactions(userId); if (!recentTxIds.isEmpty()) { List<Order> supplemental = querySupplementalOrders(recentTxIds); orders = mergeResults(orders, supplemental); } return orders; }

5. 性能压测数据参考

我们在4分片、每分片500万数据的集群上进行了对比测试:

方案QPS平均延迟99分位延迟适用场景
全局视野12850ms2.1s小数据量后台
二次查询23545ms120ms通用场景
分片路由18008ms15ms带分片键查询
游标分页62022ms55msC端无限滚动

硬件配置:8核16G服务器,SSD存储,千兆网络

一个有趣的发现:当使用MyBatis-Plus的分页插件时,需要特别注意拦截器顺序:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 必须先添加分片插件,再添加分页插件 interceptor.addInnerInterceptor(new ShardingPaginationInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; }

配置错误会导致分页计算完全错误,这是我们通过3次线上事故才排查出的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询