1. 分库分表场景下的分页查询困境
当数据量达到千万级甚至亿级时,单表查询性能会急剧下降。这时我们通常会采用分库分表策略,将数据分散到多个数据库实例或表中。但分页查询这个看似简单的需求,在分库分表环境下却变成了一个棘手问题。
我最近在重构一个电商平台的订单系统时就遇到了这个难题。原先的单表订单数据已经超过2亿条,查询性能严重下降。在将订单表按用户ID哈希分片到16个库后,发现分页查询接口的响应时间从原来的200ms飙升到3秒以上,而且返回的结果完全不准确。
问题的根源在于:传统的LIMIT offset, size分页方式在分库分表环境下会失效。假设我们在第2页每页10条数据,原始SQL是LIMIT 10, 10。但在分片环境下,这个查询会在每个分片上都执行LIMIT 10, 10,然后合并结果。最终你得到的可能是各个分片的第10-20条记录的混合,而不是全局的第10-20条记录。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常见解决方案的优劣对比
2.1 全局排序法(不推荐)
最直观的想法是在应用层做全局排序:从所有分片获取全部数据,在内存中排序后再分页。这种方法确实能保证结果准确,但当数据量大时(比如第100页),性能会极其糟糕。我曾经测试过,查询第100页(每页20条)需要从16个分片各获取2000条数据,总共3.2万条记录在内存中排序,耗时超过8秒。
2.2 二次查询法(中等推荐)
这种方法分为两步:
- 先在各分片查询满足条件的ID和排序字段
- 在内存中排序后确定目标页的ID范围
- 二次查询获取完整数据
虽然减少了网络传输量,但仍然需要扫描大量数据。在我们的测试中,查询第50页(每页20条)平均需要1.2秒,而且当排序字段有重复值时可能出现结果不一致的问题。
2.3 业务折中方案(推荐)
经过多次尝试,我们发现大多数业务场景其实不需要严格的全局分页。可以考虑以下折中方案:
- 允许小范围误差:比如商品列表页,第5页和第6页之间有少量重复是可以接受的
- 使用非精确分页:显示"1-20条,共约1000+"而不是精确总数
- 基于游标的分页:用上一页的最后一条记录作为下一页的起始条件
这些方案虽然不完美,但在实际业务中往往能够平衡性能和准确性。
3. 最优解决方案:分片键+时间戳排序
经过多次迭代,我们最终采用了一种基于分片键和时间戳的组合方案。核心思路是:
- 确保每个分片内部是有序的(通常按时间戳倒序)
- 查询时携带分片键信息(如用户ID)
- 使用类似下面这种SQL:
sql复制SELECT * FROM orders
WHERE user_id = ? AND create_time < ?
ORDER BY create_time DESC
LIMIT 20
这种方案的关键点在于:
- 利用了分片键(user_id)直接定位到特定分片
- 通过create_time实现分页的连续性
- 避免了全表扫描和内存排序
在我们的生产环境中,这种方案的查询性能稳定在50ms以内,完全满足业务需求。
4. 具体实现步骤与代码示例
4.1 数据表设计
sql复制CREATE TABLE `orders` (
`id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL COMMENT '分片键',
`order_no` varchar(32) NOT NULL,
`create_time` datetime NOT NULL COMMENT '排序字段',
-- 其他字段...
PRIMARY KEY (`id`),
KEY `idx_user_time` (`user_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
4.2 Java实现代码
java复制public PageResult<Order> queryOrders(Long userId, Date lastTime, int pageSize) {
// 1. 构造查询条件
LambdaQueryWrapper<Order> query = new LambdaQueryWrapper<>();
query.eq(Order::getUserId, userId);
if (lastTime != null) {
query.lt(Order::getCreateTime, lastTime);
}
query.orderByDesc(Order::getCreateTime);
query.last("LIMIT " + pageSize);
// 2. 执行查询
List<Order> orders = orderMapper.selectList(query);
// 3. 构造分页结果
PageResult<Order> result = new PageResult<>();
result.setData(orders);
result.setHasMore(orders.size() == pageSize);
if (!orders.isEmpty()) {
result.setLastTime(orders.get(orders.size()-1).getCreateTime());
}
return result;
}
4.3 前端调用方式
前端不再使用传统的pageNum/pageSize参数,而是记录上一页最后一条记录的createTime,作为下一页的查询条件:
javascript复制// 第一页查询
const result1 = await api.queryOrders(userId, null, 20);
// 第二页查询
const lastTime = result1.data[result1.data.length-1].createTime;
const result2 = await api.queryOrders(userId, lastTime, 20);
5. 性能优化与注意事项
5.1 索引设计要点
必须确保查询使用了正确的复合索引。在我们的例子中,(user_id, create_time)这个索引是关键。可以通过EXPLAIN验证:
sql复制EXPLAIN SELECT * FROM orders
WHERE user_id = 123 AND create_time < '2023-01-01'
ORDER BY create_time DESC
LIMIT 20;
5.2 分页深度的限制
这种方案虽然性能好,但不适合随机跳页(如直接从第1页跳到第100页)。如果需要支持随机分页,可以考虑以下优化:
- 预估总页数:通过抽样统计估算总数
- 限制最大页数:比如最多允许查看前50页
- 结合搜索引擎:将分页查询转移到Elasticsearch等专业搜索系统
5.3 数据一致性问题
在分库分表环境下,时间戳可能存在微小误差(不同服务器时钟不完全同步)。如果对排序精度要求极高,可以考虑:
- 使用数据库自增序列作为辅助排序条件
- 在应用层统一生成时间戳
- 采用Snowflake等分布式ID生成算法,其中包含时间戳信息
6. 真实案例:电商订单系统的改造实践
在我们的电商系统中,订单表按user_id哈希分片到16个库,每个库再按月份分表。改造前后的性能对比如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 平均响应时间 | 1200ms | 45ms |
| 第10页查询时间 | 超时(>5s) | 52ms |
| CPU使用率峰值 | 85% | 30% |
| 网络传输量 | 平均2MB/请求 | 平均20KB/请求 |
这个改造不仅提升了性能,还简化了代码逻辑。前端从传统的页码分页改为无限滚动加载,用户体验也得到了改善。
7. 其他场景的适配方案
7.1 多条件排序场景
如果业务需要支持多种排序方式(如按价格、销量等),可以考虑:
- 为每种排序方式建立单独的索引表
- 使用Elasticsearch等搜索引擎
- 在应用层实现小型排序缓存
7.2 无分片键的全局查询
对于管理员后台等需要全局查询的场景,建议:
- 使用专门的只读从库
- 定期将数据同步到分析型数据库(如ClickHouse)
- 实现异步导出功能,避免直接大规模分页查询
7.3 分表不分库的情况
如果只是分表而没有分库,可以考虑使用UNION ALL合并查询结果:
sql复制(SELECT * FROM orders_1 WHERE ... ORDER BY create_time DESC LIMIT 20)
UNION ALL
(SELECT * FROM orders_2 WHERE ... ORDER BY create_time DESC LIMIT 20)
...
ORDER BY create_time DESC
LIMIT 20
这种方案比全量合并性能更好,但仍然有局限性。
在实际项目中,分库分表下的分页查询没有银弹,需要根据具体业务场景选择最适合的方案。我们的经验是:在保证基本业务需求的前提下,适当放宽对分页精确性的要求,可以换来极大的性能提升。
