MySQL深分页优化及试验
深分页
试验准备
试验背景:MySQL8,三星高速ssd,AMD Ryzen 7 5800H
试验基础SQL:
构建一张大表(1000w)用于试验,嫌慢的可以用pg,但pg相对查的慢
-- drop table tb_sku;
CREATE TABLE `tb_sku` (`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',`sn` varchar(100) NOT NULL COMMENT '商品条码',`name` varchar(200) NOT NULL COMMENT 'SKU名称',`price` int(20) NOT NULL COMMENT '价格(分)',`num` int(10) NOT NULL COMMENT '库存数量',`alert_num` int(11) DEFAULT NULL COMMENT '库存预警数量',`image` varchar(200) DEFAULT NULL COMMENT '商品图片',`images` varchar(2000) DEFAULT NULL COMMENT '商品图片列表',`weight` int(11) DEFAULT NULL COMMENT '重量(克)',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`update_time` datetime DEFAULT NULL COMMENT '更新时间',`category_name` varchar(200) DEFAULT NULL COMMENT '类目名称',`brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称',`spec` varchar(200) DEFAULT NULL COMMENT '规格',`sale_num` int(11) DEFAULT '0' COMMENT '销量',`comment_num` int(11) DEFAULT '0' COMMENT '评论数',`status` char(1) DEFAULT '1' COMMENT '商品状态 1-正常,2-下架,3-删除',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';DELIMITER $$
CREATE PROCEDURE generate_sku_data()
BEGINDECLARE i INT DEFAULT 0;DECLARE batch_size INT DEFAULT 100000;DECLARE total_batches INT DEFAULT 100;DECLARE start_val INT DEFAULT 1;DECLARE end_val INT DEFAULT 0;WHILE i < total_batches DOSET start_val = 1 + (i * batch_size);SET end_val = (i + 1) * batch_size;INSERT INTO tb_sku (sn, name, price, num, alert_num, image, images, weight,create_time, update_time, category_name, brand_name, spec,sale_num, comment_num, status)SELECTCONCAT('SN', seq), -- snCONCAT('商品', seq), -- nameFLOOR(RAND() * 10000), -- priceFLOOR(RAND() * 100), -- numFLOOR(RAND() * 10), -- alert_numCONCAT('http://example.com/img/', seq, '.jpg'), -- imageCONCAT('http://example.com/img/', seq, '.jpg,http://example.com/img/', seq+1, '.jpg'), -- imagesFLOOR(RAND() * 1000), -- weightNOW() - INTERVAL FLOOR(RAND() * 1000) MINUTE, -- create_timeNOW() - INTERVAL FLOOR(RAND() * 1000) MINUTE, -- update_timeCONCAT('类目', (seq % 10) + 1), -- category_nameCONCAT('品牌', (seq % 5) + 1), -- brand_nameCONCAT('规格', (seq % 3) + 1), -- specFLOOR(RAND() * 100), -- sale_numFLOOR(RAND() * 50), -- comment_num'1' -- statusFROM (SELECT (start_val + t4.i*10000 + t3.i*1000 + t2.i*100 + t1.i*10 + t0.i) AS seqFROM(SELECT 0 i UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t0,(SELECT 0 i UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t1,(SELECT 0 i UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t2,(SELECT 0 i UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t3,(SELECT 0 i UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t4HAVING seq BETWEEN start_val AND end_val) numbers;SET i = i + 1;-- 可选:每批提交后输出进度SELECT CONCAT('已插入第 ', i, ' 批数据,共 ', i*batch_size, ' 条记录') AS progress;END WHILE;
END$$
DELIMITER ;-- 执行存储过程
CALL generate_sku_data(); -- 9m18s
原始情况
查询偏移量过大的场景我们称为深度分页,这会导致查询性能较低,例如:
-- tb_sku是一张1000w数据的表,扫表情况
select * from tb_sku order by id limit 9999980,10; -- 13s(ssd)
解决思路:
连续id时
主键id是连续的,这其实蕴含这一个情况,就是可以直接计算所取页数对应的id,这是使用数学降维打击了:
select * from tb_sku where id > 9999980 order by id limit 10; -- 180ms
但很多时候往往数据id是不连续的。
使用主键加速
扫表的时候如果只扫主键,那么能快一点:
select * from tb_sku ORDER BY id limit 9999980,10; -- 11s
select id from tb_sku ORDER BY id limit 9999980,10; -- 5s
这是由于只取id,io开销少一点,速度就快了一些,但是如果还是想获取很多其他的字段,可以使用子查询
select *
from tb_sku t,(select id from tb_sku ORDER BY id limit 9999980,10) a
where t.id = a.id; -- 5s
这样速度快的同时也有了其他的字段,如果逻辑复杂起来,可以用join
-- 使用 INNER JOIN 进行延迟关联
SELECT t1.*
FROM tb_sku t1
INNER JOIN (SELECT id FROM tb_sku where id > 9999980 order by id LIMIT 10 ) t2 ON t1.id = t2.id;
通常这种手法叫做延迟关联
什么是延迟关联?延迟关联 是一种SQL查询优化技术,其核心思想是:先通过一个高效的范围查询(通常使用覆盖索引)快速定位到符合条件的主键ID,然后再通过主键ID关联回原表获取完整的行数据。
为什么延迟关联更高效?
传统写法的执行过程:
- 根据WHERE条件扫描索引或全表扫描
- 对于每一条匹配的记录,都立即回表获取完整数据
- 对所有完整数据进行排序
- 应用LIMIT和OFFSET
问题:即使最后只需要20条数据,也可能需要回表上千次!延迟关联的执行过程:
- 在索引上完成所有筛选和排序(索引通常包含where条件和order by字段)
- 只对最终需要的20条记录进行回表
- 用主键ID快速定位到完整数据
优势:大大减少了回表操作次数!
加速手法
select *
from tb_sku t,(select id from tb_sku where 1=1 limit 9999980,10) a
where t.id = a.id; -- 5s
这种做法的基础上,还能加速,分析一下索引:
EXPLAIN (select id from tb_sku where 1=1 limit 9999980,10);
-- 1,SIMPLE,tb_sku,,index,,PRIMARY,4,,9876750,100,Using index
走的主键索引,但是,如果建立其他的二级索引,例如给name
字段价一个索引
CREATE INDEX tb_sku_name_index ON tb_sku (name);-- 30s
EXPLAIN select id from tb_sku WHERE 1=1
# ORDER BY id -- order by id会让name索引失效limit 9999980,10; -- 2s(有二级索引情况);
速度就可以进一步加速,这属于具体情况具体分析的优化了。为什么会加速呢?explain结果:
1,SIMPLE,tb_sku,,index,,tb_sku_name_index,802,,9876750,100,Using index
是因为走了二级索引的缘故,二级索引没挂数据,io次数少,然后也不回表,所以哪怕都是索引,二级索引的时间比主键索引还能少一点。
游标做法
-- 第一次查
select * from tb_sku order by id limit 10;
-- 下一页查(记住上页最后一个 id = 100)
select * from tb_sku where id > 100 order by id limit 10;
优点:性能 O(limit) 而不是 O(offset+limit),非常快。
缺点:只能做“向后翻页”,不支持随机跳页,需要依赖前一次的查询。
预计算分页锚点
- 建一张辅助表
page_anchor
,存储每 N 条的起点 ID,比如每 1 万条存一个。 - 用户要第 999,998 页时,可以快速找到最近的锚点,再往后翻少量 OFFSET。
-- page_anchor 表:
-- page_no | id_anchor
-- 100000 | 2000000
-- 200000 | 4000000
-- ...-- 跳到第 999,998 页
-- 先找到最近的锚点
SELECT id_anchor FROM page_anchor WHERE page_no <= 999998 ORDER BY page_no DESC LIMIT 1;-- 假设拿到 id_anchor = 9980000
-- 再往后偏移少量
SELECT *
FROM tb_sku
WHERE id >= 9980000ORDER BY id
LIMIT (999998-998000)*20, 20;
优点:速度很快。
缺点:需要额外的工作去维护表page_anchor
。
倒叙索引
如果是那种有些要从后往前翻的场景,可以建立一个倒叙索引。
CREATE INDEX tb_sku_id_desc_index ON tb_sku (id DESC);-- 18s
EXPLAIN (select id from tb_sku limit 9999980,10); -- 1s
其他
业务逻辑优化:把表拆了,分页。
禁止此行为:ui上封掉,或者后台拒绝随机访问页数等。
另外一条路:这个问题的讨论范畴已经在mysql这种OLTP(联机事务处理) 数据库的边缘了,也就是当数据量继续增长的时候,其实已经逐渐脱离OLTP,进入到OLAP(联机分析处理)的范畴了。直白的说,就是到底是继续在 MySQL 上优化(索引、架构、分表、缓存等方案(就是上述讨论的范畴)),还是换到 专门的分析型数据库(ClickHouse、Doris、Greenplum、TiDB HTAP 等)。如果使用ClickHouse / Doris,只要稍微做一些简单的优化,深分页有机会稳定在 1 秒以内,这是mysql做许多工作才能达到的效果。但是是否要换,那就是另一件事了。
数据仓库是独立于业务数据库之外的一套数据存储体系,OLAP(OnLine Analytical Processing联机分析处理)是数据仓库系统的主要应用,能够支持复杂的分析操作,与数据库需要直接直接线上业务不同,数据仓库侧重于分析决策,提供直观的数据查询结果。(当然数据仓库也是可以支持线上业务的,后面会举例子)。
**数据库:**是OLTP(联机事务处理)应用的场景,其存储的主要是与业务直接相关的数据,强调准确、低时延、高并发,如果没有特别强调,基本上数据库里只会去存储与业务相关的数据。
**数据仓库:**OLAP(联机分析处理)是数据仓库系统的主要应用,其支持的对象只要是面向分析场景的应用,提供结构化的、主题化的数据提供给运营,做业务反馈和辅助决策用,同时,有些场景下,也可以由数据仓库对业务进行支持。
ref:1. 什么是OLTP、OLAP、数据库和数据仓库? - 知乎
总结
方法 | 核心思路 / 优点 | 适用场景 / 限制 |
---|---|---|
范围查询 / 分页标签 (cursor / keyset pagination / tag record 方法) | 不使用 OFFSET,而是记住上一次查询的某个关键字段(常是主键 ID 或者 timestamp),下一次分页从这个点开始查询,比如 WHERE id > last_id 或 id < last_id 。效率高,因为利用索引,不跳过太多。 (掘金) | 必须有可排序且可作为游标的字段,例如 ID 时间戳等。当允许顺序翻页(下一页),不要求用户能随意跳到第 N 页。对“跳页”(直接访问第 1000 页)支持差一些。 |
子查询 + 主键定位 + 联结(Delayed join / inner join) | 先用子查询(或内联查询)只查出主键(或索引列),加上 LIMIT 偏移来定位起点,然后再 join 主表取得完整记录。减少回表 / 排序 /扫描开销。 (JavaGuide) | 适合主键或唯一标识的场景,且子查询能用索引快查到起始点。对于非常大的偏移也可能有子查询开销和内存压力。 |
覆盖索引(covering index) | 如果所需返回的字段都在索引里,那么不需要回表查数据,只查索引数据,IO 小。排序/过滤字段如果也在索引中,效果更佳。 (JavaGuide) | 需要设计好索引;如果返回字段很多或者不能预先确定,覆盖索引可能难以完全覆盖所有情况。 |
设定阈值 / 自动切换策略 | 当分页深度(Offset)超过某个阈值时,不用传统分页,而用不同策略(如 keyset 分页、流式查询、分片、缓存等)来处理。这样浅页保持通用分页方式,深页则用优化路径。 ((这个例子是分布式的方案)博客园) | 需要在业务 /代码中能判断分页深度,并做到有备用方案。用户体验上可能要有差异(例如无法跳太深)。 |
倒序 + 限制跳页行为 | (mysql8可以建立倒叙索引)在某些排行榜、日志类型的场景,如果用户要看“最后几页”或最新数据,可以倒序查询最近的记录,然后分页。这样深度不会大。而如果用户真的要看最早的一些旧数据,再用特定接口。 (CSDN 博客) | 不适所有场景。若排序是时间或 ID,倒序有意义;但用户跳页或按页码要求定定位可能受到限制。 |
另外的方法 | 核心思路 / 优点 | 适用场景 / 限制 |
---|---|---|
分表 / 分区 | 大表拆分成多个分区或多个子表(按时间、按某些标签等分区),这样单次分页查询只在某个子表中操作,数据量相对小。减少扫描量和索引树大小。 (CSDN 博客) | 分区管理复杂;有些查询跨多个分区会变复杂;写入 /维护成本增高。 |
缓存 / 预计算 /中间表 | 对一些热门页数、热门查询预先缓存结果(或者分页列表的索引列表),避免每次都查询原始表。或者预先维护一个快照表(或物化视图),供分页查询。 (知乎专栏) | 当数据变更频繁时缓存会失效或维护成本高。快照 /预计算也会有延迟问题。 |
禁止或限制深页的跳转 | 在 UI 或 API 层面限制用户不能跳太深;例如只允许前N页、最新数据或者“查看历史”用专门接口。这样业务上规避深分页带来的性能问题。 | 对用户体验可能有影响,需要业务上接受这种限制或者给出替代方式(搜索、过滤、关键词定位等)。 |
OLAP技术(ClickHouse / Doris等) | 深分页性能好(千万级秒级返回)、聚合统计快、支持分布式扩展,适合大数据展示、报表、日志分析、OLAP 查询 | 写入/事务能力弱、超大 offset 仍有性能消耗、建表索引规划重要、运维复杂 |