三个巧技,让分库分表 LIMIT 翻页性能直接拉满!

线上出了个离谱问题:运营同学在后台导出近 3 个月订单时,点击分页到第 100 页,直接把数据库查崩了。排查后发现,代码里写了 LIMIT 9900, 100,在分库分表场景下,这行 SQL 相当于让 8 个分片各查 1 万条数据,再拉到应用层内存排序,直接把内存溢出了。

其实分库分表的分页查询,藏着很多反常识的奇技淫巧,但这些技巧都有严格的适用边界,用错了反而会埋坑。我来分享 3 个实战中验证过的骚操作。

分库分表 LIMIT 是性能杀手

单表分页用 LIMIT offset, size 没问题,但分库分表后,数据散在多个分片里,比如你要查 LIMIT 10000, 10(第 1001 页),会发生两件离谱的事:

全分片扫描:每个分片都要执行 LIMIT 0, 10010(因为不知道其他分片的数据分布,只能把前 10010 条都查出来,避免漏数据);

内存爆炸排序:假设 8 个分片,每个返回 10010 条,共 8 万多条数据,全拉到应用层排序,再截取第 10000-10010 条。内存和 CPU 直接飙红,我之前见过最夸张的案例,offset=100000 时,一个分页请求耗时 12 秒,直接触发服务熔断。

所以我们下边要讲的都是绕开全分片扫描 + 内存排序,但每个方案的适用场景天差地别,核心原则:不盲目追高性能,先看业务场景是否匹配

锚点分页

锚点分页,性能最优,但仅限加载更多场景。

这是我最常用的技巧,核心思路是用数据本身的有序字段当锚点,替代 offset,比如按自增 ID 或时间戳分页。但注意:不是所有有序字段都能用,必须满足分片内 + 分片间都有序

按 ID 范围分片

假设订单表按 ID 范围分 3 个分片:

分片 1:ID 1-10000(分片内有序,且小于分片 2 的 ID);分片 2:ID 10001-20000(同理);分片 3:ID 20001-30000(同理);

要查第 2 页(10 条 / 页),步骤如下:

查第 1 页时:执行 ORDER BY id DESC LIMIT 10,拿到最后一条数据的 ID 是 last_id=100(这个 ID 就是锚点);查第 2 页时:直接用 WHERE id < 100 ORDER BY id DESC LIMIT 10查第 3 页时:再用第 2 页最后一条的 ID(比如 90)当锚点,执行 WHERE id < 90 ORDER BY id DESC LIMIT 10

为什么性能高?

每个分片都能独立执行 WHERE id < xxx LIMIT 10,只返回 10 条数据(不用查前 N 条)。比如查第 1001 页,每个分片也只返回 10 条,汇总后排序取 10 条,网络和内存开销直接降为原来的 1/1000。

必避的 2 个坑:

别用哈希分片:如果按ID mod 3哈希分片,分片 1 的 ID 可能是 3、6、9...,分片 2 是 1、4、7...,此时 ID 全局有序但分片内无序,执行 WHERE id < 100 仍需全量扫描分片内数据,退化为 “内存聚合”;不支持跳页:只加载更多(下一页依赖上一页的锚点),无法直接从第 1 页跳到第 100 页。但可以通过产品设计规避,比如抖音、小红书的列表都是加载更多,用户体验反而更好。

分片标记法

刚才的锚点分页不支持跳页,但有些场景比如后台管理系统,又必须要跳页,怎么办?我之前在电商后台做订单导出时,用过分片标记法,核心是给每个库、表记录数据范围和总量,快速定位目标页在哪个分片。

分片标记法支持跳页,但必须控制元数据一致性。

用法示例

假设订单表按用户 ID 范围分 2 库,每库按时间分 12 表(如库 1 - 表 202401、库 1 - 表 202402...),先在 Redis 里维护 库、表级别的元数据:

库 - 表

起始 ID

结束 ID

数据总量

库 1 - 表 202401

1

5000

5000

库 1 - 表 202402

5001

12000

7000

库 2 - 表 202401

12001

18000

6000

....

....

..18000

6000

现在要查 LIMIT 15000, 10(第 1501 页),步骤如下:

查元数据定位库、表:计算累计数据量,库 1 - 表 202401(5000)+ 库 1 - 表 202402(7000)= 12000 <15000,再加上库 2 - 表 202401 的 6000,累计 18000>15000,所以目标在库 2 - 表 202401;计算表内偏移量:表内偏移量 = 15000 - 12000 = 3000,所以库 2 - 表 202401 执行 LIMIT 3000, 10直接返回结果:因为库、表按 ID 有序,查询结果就是全局第 15000-15010 条,不用汇总其他分片。

必避的 2 个坑:

1.元数据必须实时但不能强同步

数据新增、删除时,要同步更新 Redis 元数据,但高并发下不能加分布式锁(会卡住业务),建议用定时 + 增量日志:每 5 分钟全量统计一次,同时记录增量(如新增 100 条、删除 10 条),查询时叠加增量;

若允许最终一致性(如后台查询允许误差 10 条),这个方案很稳;若要强一致,只能放弃跳页,用锚点分页;

2.不支持非分片键排序:如果要按支付时间排序(分片键是用户 ID),支付时间在 “库 - 表” 内无序,元数据无法定位,仍需全分片扫描。

反向分页

反向分页,仅适用于查最后 1 页,别乱用

这个技巧最反常识,但局限性也最大。如果要查最后几页数据(比如用户查最早的订单),用普通分页会查 LIMIT 9990, 10,但可以反向查,避开大 offset。

仅查最后 1 页

假设订单表按 ID 范围分片,总数据量 10000 条(1000 页,10 条 / 页),要查最后 1 页(ID 9991-10000):

反向查锚点:执行 ORDER BY id ASC LIMIT 10,拿到最前面 10 条的 ID(1-10),取最大 ID 作为反向锚点(10);查最后 1 页:执行 WHERE id > 10 ORDER BY id DESC LIMIT 10,拿到的就是 ID 10000-9991(最后 10 条);调整顺序:如果需要正序展示,把结果再倒过来即可。

为什么能生效?

因为 LIMIT 0, 10 比 LIMIT 9990, 10 快 100 倍. 每个分片查前 10 条数据,汇总后取最大的 10 个 ID 作为锚点,再查大于锚点的数据,避免了大 offset 扫描。

必避的 2 个坑:

仅适用于最后 1 页:如果要查倒数第 10 页(第 991 页),按这个逻辑无法定位锚点(需要知道第 9900 条数据的 ID),只能查倒数第 1 页;数据不能有大量删除:如果中间有大量 ID 被删除(如 ID 5000-8000 都被删了),总数据量变为 7000 条,此时 WHERE id >10 ORDER BY id DESC LIMIT10 拿到的是 7000-6991(正确),但如果删除的是最后 100 条(ID 9901-10000),总数据量变为 9900 条,需要重新计算反向锚点,增加复杂度。

说在后边

其实分库分表分页的核心不是炫技,而是在业务和技术之间找平衡。能通过产品设计规避跳页(用加载更多),就优先用锚点分页(性能最优),必须跳页就用分片标记法(接受最终一致性);实在没办法才考虑中间件(如 ShardingSphere 的全局排序)。

阅读剩余
THE END