像素岛沙盒冒险
64.86MB · 2026-02-07
在后端开发中,分库分表是解决单库单表数据量爆炸、并发瓶颈的必经之路。但随之而来的,是一系列“简单功能变复杂”的坑——分页查询就是最典型的一个。
单库单表时代,我们用 LIMIT offset, size 就能轻松实现分页,比如查询第11页(每页10条),一句 SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100, 10 就能搞定,数据库引擎能通过索引快速定位全局有序数据,性能稳定。
可当数据被拆分到多个库、多个表后,这句简单的SQL就彻底“失灵”了:要么查出来的数据重复、缺失,要么深度分页时性能雪崩,甚至出现排序错乱。很多开发者卡在这里反复调试,却始终找不到优雅的解决方案。
今天,我们就彻底把这个问题讲透——从问题根源出发,拆解4种主流实战方案,分析各自的优缺点与适用场景,再总结避坑技巧,帮你在实际业务中快速选型、落地。
分页查询变难的核心根源,不是“数据多”,而是分布式环境下的全局有序性缺失——单库单表中,数据库维护着全局有序索引(比如 create_time 上的B+树),offset 和 size是基于全局数据的截取;但分库分表后,每个分片都是独立的数据库实例,仅维护本地数据的有序性,缺乏“全局有序视图”。
具体来说,会遇到3个核心问题,每一个都能直接影响业务体验:
当 offset 过大时(比如 LIMIT 100000, 10),查询耗时会呈指数级增长。举个例子:假设 t_order 按用户ID哈希分为10个分片(t_order_0至t_order_9),如果沿用单库分页逻辑,会发生两件事:
SELECT * FROM t_order_x ORDER BY create_time DESC LIMIT 100010(要取到全局第100001-100010条,每个分片都要查前100010条);这就意味着,原本只需扫描1010条数据的操作,变成了扫描100万+条数据,大量冗余数据的传输和内存排序,直接拖垮应用性能,甚至导致查询超时。某电商案例显示,LIMIT 10000000, 10 的执行时间是 LIMIT 10, 10 的200倍以上。
很多时候,分页查出来的数据要么重复出现,要么莫名缺失,核心原因有两个:
当业务需要按非分片键多字段排序时(比如“按订单金额降序+支付时间升序”),性能会极差甚至无法实现。因为非分片键字段没有全局索引,必须扫描所有分片的全量数据才能完成排序,相当于“分布式全表扫描”,分片数越多,性能越差。
分库分表分页没有“银弹”,所有方案都是“trade-off”(取舍)。下面4种方案,覆盖从浅分页到深分页、从简单查询到复杂查询的所有场景,建议结合自身业务选型。
这是最基础、最通用的方案,本质是“先拉取、再聚合、最后截取”,完全沿用单库分页逻辑,仅在应用层增加聚合排序步骤。
offset 到 offset+size 的数据,作为最终分页结果。// 1. 构建分片SQL(每个分片都查offset+size条)
String sql = "SELECT * FROM t_order_{0} ORDER BY create_time DESC LIMIT " + (offset + size);
// 2. 并行查询所有分片
List<List<Order>> allShardData = shardExecutor.parallelExecute(sql);
// 3. 全局聚合排序
List<Order> globalData = allShardData.stream()
.flatMap(Collection::stream)
.sorted(Comparator.comparing(Order::getCreateTime).reversed())
.collect(Collectors.toList());
// 4. 截取目标页数据
int start = offset;
int end = Math.min(offset + size, globalData.size());
List<Order> pageData = globalData.subList(start, end);
方案1的核心问题是“传输全量数据”,而方案2通过“先查主键、再查详情”的方式,大幅减少数据传输量,是浅分页(offset<1万)场景的首选方案。
利用全局唯一有序键(比如雪花ID、create_time+order_id),将分页查询拆分为两次:第一次仅查询主键和排序字段(数据量极小),聚合排序后确定目标页的主键列表;第二次根据主键列表,路由到对应分片查询完整数据,避免传输冗余字段。
-- 分片t_order_0的查询(仅查主键和排序字段) ``SELECT order_id, create_time FROM t_order_0 ORDER BY create_time DESC LIMIT 10010;create_time 排序,截取第10000-10010条的 order_id 列表(如 (100001, 100002, …, 100010));order_id 的分片规则(比如哈希路由),路由到对应分片,查询完整数据并排序; -- 路由到order_id对应的分片,查询完整数据 ``SELECT * FROM t_order_0 WHERE order_id IN (100001, 100002, ..., 100010) ORDER BY create_time DESC;无论是方案1还是方案2,都无法解决“offset过大导致的性能雪崩”。而游标分页法放弃了 offset,改用“上一页最后一条数据的游标位置”作为查询条件,实现“无偏移量”分页,性能与页码无关,是深分页场景的最优解。
类比我们读书:传统offset分页是“翻到第100页”,而游标分页是“从第99页最后一行继续读”。每次查询时,用上次分页返回的“最后一条数据的排序字段值”(游标)作为过滤条件,仅查询游标之后的数据,无需扫描前面的冗余数据。
查询第一页:无需游标,直接查询前10条数据,记录最后一条数据的游标
(比如 create_time=2026-02-05 10:00:00,order_id=100010);
-- 第一页:按创建时间倒序,取10条
SELECT * FROM t_order ORDER BY create_time DESC, order_id DESC LIMIT 10;
查询下一页:用游标作为过滤条件,查询游标之后的数据,同样取10条,更新游标;-- 下一页:用上次的游标过滤,避免offset
SELECT * FROM t_order
WHERE create_time < '2026-02-05 10:00:00'
OR (create_time = '2026-02-05 10:00:00' AND order_id < 100010)
ORDER BY create_time DESC, order_id DESC LIMIT 10;
分库分表适配:将上述SQL发送到所有分片,各分片仅查询符合游标条件的10条数据,应用层聚合排序后,再取前10条(避免跨分片数据遗漏)。
如果排序字段不唯一(比如 create_time 可能重复),仅用单个字段作为游标会导致数据缺失。此时需要用“排序字段组合”作为游标(如 create_time+order_id),确保游标唯一,避免遗漏数据。
| 页码 | Offset分页耗时 | 游标分页耗时 | 性能提升 |
|---|---|---|---|
| 第1页 | 5ms | 5ms | - |
| 第100页 | 150ms | 6ms | 25倍 |
| 第1000页 | 1.2s | 6ms | 200倍 |
| 第10000页 | 8.5s | 7ms | 1200倍 |
如果业务需要“多条件筛选+多维度排序+深度分页”(比如电商的商品搜索分页、用户行为日志查询),前面3种方案都无法满足性能要求。此时最优雅的方式,是采用“查询与存储分离”的架构,用搜索引擎(Elasticsearch/OpenSearch)承担分页查询压力。
数据的CRUD操作在MySQL分库分表中完成,分页查询、多条件筛选、排序操作在搜索引擎中完成,通过binlog同步数据,实现“存储强一致、查询高性能”。
{
"query": {
"bool": {
"must": [
{"term": {"user_id": 123}},
{"range": {"create_time": {"gte": "2026-01-01"}}}
]
}
},
"sort": [{"create_time": "desc"}, {"order_id": "desc"}],
"from": 100000, // 深分页无压力
"size": 20
}
对于非实时场景(比如报表系统、历史数据查询),还可以采用“预计算分页锚点”的方式,进一步优化性能,减少无效扫描。
通过定时任务,预计算各分片的“分页锚点”(比如每1000条记录的排序字段值),存储在Redis等元数据服务中。查询时,先通过锚点定位目标数据所在的分片及范围,减少各分片的扫描量。
SELECT id, create_time FROM orders ORDER BY create_time LIMIT 0, 1000, 2000...,记录每1000条的 create_time 作为锚点,存入Redis;create_time 值,确定全局最小 time_min;create_time >= time_min 的数据,按 create_time 排序后取前20条(冗余量避免边界误差),汇总后再排序取目标10条。非实时场景(如报表系统、历史数据查询),对数据延迟容忍度高(分钟级),但对查询性能要求高的场景。
即使选对了方案,落地时也容易踩坑。下面5个坑,是我在实际项目中反复遇到的,附上具体解决方案,帮你少走弯路。
「表现」:offset超过1万后,查询耗时急剧增加,甚至超时;「根源」:全局偏移量无法转化为本地偏移量,每个分片都要扫描大量冗余数据;「解决方案」:禁用深分页跳页,改用游标分页;或用预计算锚点法优化;浅分页场景用方案2。
「表现」:分页数据无序、缺失;「根源」:按哈希分片(如用户ID),却按非分片键(如创建时间)排序,各分片数据范围重叠;「解决方案」:分表时尽量选择“排序字段+分片键”的组合(如按创建时间范围分片);若已按哈希分片,排序时需在应用层做全局排序,确保结果有序。
「表现」:前后页数据重复,或某条数据凭空消失;「根源」:查询过程中,分片插入、删除、更新数据,导致全局排序结果变化;「解决方案」:游标分页时,用“唯一游标”(如create_time+order_id);关键业务场景,分页查询走主库,避免从库同步延迟;可添加版本号,过滤已删除/更新的数据。
「表现」:关联分库分表后的两张表(如订单表+用户表)分页,性能极差;「根源」:关联字段可能不在同一个分片,需跨分片关联,再分页,成本极高;「解决方案」:优先用宽表设计(将用户信息冗余到订单表),避免跨表关联;若必须关联,在业务层做两次查询(先查订单分页,再批量查用户信息),而非数据库层关联。
「表现」:分页查询从库,出现数据缺失(主库已写入,从库未同步);「根源」:主从同步延迟;「解决方案」:关键业务的分页查询走主库;非关键业务,可等待从库同步完成(如延迟100ms)再查询;或用“主从一致性校验”,发现缺失数据时从主库补查。
结合前面的方案和避坑技巧,给出4条落地建议,帮你快速选型,降低开发成本:
分表前,明确分页的排序字段和查询场景,尽量选择“排序字段+分片键”的组合(如按创建时间范围分片),减少后续分页优化的成本;避免用无意义的哈希键分片(如随机ID),否则多维度排序会非常困难。
如果业务分页查询频率低、数据量不大,无需追求“最优方案”,用全局扫描法快速落地即可;只有当分页成为性能瓶颈时,再逐步优化为游标分页或搜索引擎辅助法。
坚控各分片的查询耗时、内存使用情况,及时发现热点分片;给分页查询设置超时时间(如500ms),超时后降级(如返回前100页数据);关键业务场景,预留“回退方案”(如切换到单库查询、临时关闭分库分表)。
分库分表下的分页查询,本质是“解决分布式环境下的全局有序性和性能平衡”问题。我们不需要掌握所有方案,只需记住:
随着分布式数据库(如ShardingSphere、TiDB)的发展,很多分页问题已经可以通过中间件自动处理(如ShardingSphere的分页插件,可自动优化offset分页)。但了解底层原理和手动优化方案,能让我们在遇到复杂场景时,依然能快速定位问题、解决问题。
最后,如果你在实际项目中遇到了特殊的分页场景,欢迎在评论区交流,一起探讨最优解决方案~