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 二次查询法:平衡性能与准确性的选择
这是目前我们生产环境采用的方案,核心思想是:
- 先获取符合条件的ID集合
- 再根据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万 | 1200ms | 300ms |
| 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 排序字段选择的三原则
我们踩过的坑:曾经使用"更新时间"作为排序字段,结果出现大量相同值导致分页混乱。现在强制要求:
- 必须包含主键作为次要排序条件
- 优先选择值唯一的字段(如创建时间)
- 避免使用频繁更新的字段
错误示例:
-- 可能导致分页异常 SELECT * FROM orders ORDER BY update_time DESC LIMIT 100, 20正确写法:
SELECT * FROM orders ORDER BY create_time DESC, id DESC LIMIT 100, 204.2 深度分页的优化技巧
对于必须支持跳转到任意页码的场景(如后台管理系统),我们采用"ID分段缓存"方案:
- 建立ID索引表:
CREATE TABLE order_id_index ( seq_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT, create_time DATETIME, INDEX idx_ct (create_time) );- 分页查询改为:
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索引 | 80ms | 120ms |
4.3 分布式事务下的特殊处理
在支付系统中,我们发现分页查询可能漏掉刚提交但未同步的数据。解决方案:
- 为所有写操作添加binlog监听
- 查询时先检查本地事务快照
- 必要时进行二次验证
核心代码逻辑:
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分位延迟 | 适用场景 |
|---|---|---|---|---|
| 全局视野 | 12 | 850ms | 2.1s | 小数据量后台 |
| 二次查询 | 235 | 45ms | 120ms | 通用场景 |
| 分片路由 | 1800 | 8ms | 15ms | 带分片键查询 |
| 游标分页 | 620 | 22ms | 55ms | C端无限滚动 |
硬件配置:8核16G服务器,SSD存储,千兆网络
一个有趣的发现:当使用MyBatis-Plus的分页插件时,需要特别注意拦截器顺序:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 必须先添加分片插件,再添加分页插件 interceptor.addInnerInterceptor(new ShardingPaginationInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; }配置错误会导致分页计算完全错误,这是我们通过3次线上事故才排查出的问题。