1. 深分页问题从哪来LIMIT OFFSET 的代价有多大做后端开发的同学只要跟 MySQL 打过交道大概率都写过类似SELECT * FROM orders ORDER BY id LIMIT 1000000, 20这样的分页查询。早期数据量小的时候没什么感觉等表里数据涨到千万级别你会在某次上线后突然发现接口超时了数据库 CPU 飙高慢查询日志里全是同一条 SQL。我见过不少团队第一次遇到这个问题的反应——先加索引结果发现加了也没用再查执行计划发现明明走了索引却还是慢得离谱。问题根源其实不在索引而在LIMIT OFFSET这个语法的工作方式数据库拿到 OFFSET 之后不是直接跳过去读取第 1000020 条数据而是老老实实把前面 1000000 行全部扫描出来再丢弃掉。翻的页数越深丢弃的行越多扫描成本毫无意义地膨胀。这就是所谓的深分页问题。简单说数据量越大、页码越靠后LIMIT OFFSET的性能就越差而且是线性衰减。在这篇文章里我会结合具体的表结构和真实执行计划把四种常见的优化方案拆开揉碎——传统方案为什么慢、游标方案怎么用、延迟关联的原理、以及子查询/JOIN 的取舍最后再给出一张选型对照表告诉你什么场景该用哪种。想跳过“慢查询救火”阶段、提前把分页做对的同学这篇内容应该对你有用。先说明一下这篇文章的实验环境是 MySQL 8.0.34InnoDB 引擎测试表数据量约 200 万行。不同版本、不同数据分布下数据会略有差异但结论和优化思路是通用的。另外全文会多次提到“覆盖索引”“回表”“执行计划”这几个概念不熟悉的读者可以先把它们理解成覆盖索引是索引本身就带齐了你要的字段、不用再回表查一次回表是查到索引记录后还要再去主键索引拿整行数据执行计划则是 MySQL 告诉你这条 SQL 它会怎么执行的说明书。2. 四种方案的原理拆解与核心实现2.1 方案一LIMIT OFFSET 直接翻页为什么慢到无法接受先看看最原始的写法长什么样-- 第 50000 页每页 20 条 SELECT id, order_no, user_id, amount, create_time FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20;这条 SQL 的问题在于MySQL 必须扫描前 1000020 行再丢弃前 1000000 行。你可以把 InnoDB 的索引想象成一本书的目录你想读第 100 页的内容但 MySQL 的做法是从第 1 页开始逐页翻过去一直数到第 100 页。虽然最终结果里那条 SQL 返回给客户端的只有 20 行但为了找到这 20 行MySQL 在存储引擎层面做了大量无比浪费的扫描和回表操作。为什么会“回表”上面这个例子中排序字段是create_time但查询需要返回order_no、user_id、amount这些字段。如果create_time上的索引只包含create_time和idMySQL 就得用索引排序后拿着每一行的主键 id 再去聚簇索引里找完整数据这个过程就是回表。OFFSET 是 100 万时意味着 MySQL 回表了至少 100 万次这里面的大部分回表都是无用功。我看过太多人试图用“加索引”来解决这个问题——给create_time加索引当然能提升排序效率让ORDER BY create_time不至于走 filesort但无法解决 OFFSET 本身造成的无谓扫描。LIMIT 1000000, 20这种写法无论索引怎么优化MySQL 都得扫描 100 万行之后才取那 20 行。索引优化只能让每一行的扫描稍微快一点但扫描总量不变所以整体性能没有本质提升。有个 DBA 同事跟我聊天时给过一个很形象的类比LIMIT OFFSET就像去电影院找座位你买的是第 10 排的票正常应该是看一眼票就直接走到第 10 排但这条 SQL 非要你先从第 1 排开始每个座位都看一眼座椅编号一直看到第 10 排才坐下。如果全场只有 10 排座位还好要是有 100 排呢效率可想而知。2.2 方案二游标分页/书签法用 WHERE 条件代替 OFFSET第二种方案在行业里有好几个名字游标分页、键集分页、书签法、seek method。核心思想就一句话不告诉 MySQL “我要跳过多少行”而是告诉它 “我要从哪一行开始取”。举个例子假设上一页最后一条数据的 id 是 800000那么下一页的 SQL 可以写成SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id 800000 ORDER BY id DESC LIMIT 20;这里的关键变化是用WHERE id 800000代替LIMIT 1000000, 20。MySQL 可以根据主键索引直接定位到 id 为 800000 的那条记录然后从它往前扫 20 行就结束扫描的量从 100 万行骤降为 20 行。理论上这 20 行全部命中速度当然快得飞起。这个方案的适用前提是排序字段具备唯一性最理想的就是主键 id。如果你需要按create_time排序那就要保证create_time不会重复否则可能出现同一时间戳的数据被重复显示或者漏掉的问题。解决方案是组合条件WHERE create_time 2024-06-01 12:00:00 OR (create_time 2024-06-01 12:00:00 AND id 123456)。这个写法略显啰嗦但可以精确表达“如果时间相同就按 id 继续往下比”的语义。游标分页最大的优势是不管翻到第几页性能都保持稳定几乎不受数据总量影响。它适合“列表无限往下滚”的场景比如 App 的评论列表、消息列表、订单流水。用户不会去点第 100 页而是一直往下滑动每次请求带上上一页最后一条数据的位置即可。但它也有明显的限制第一你没法直接跳到任意一页因为你需要知道上一页最后一条记录的位置第二如果排序条件可以变化比如用户可以在“按时间排序”“按价格排序”之间切换那你就得为每一种排序条件都维护一套游标逻辑复杂度会成倍上升。第三如果表里有数据被删除游标分页的表现跟 OFFSET 分页会有差异——到底是只显示“当前位置之后的数据”还是“从第 N 条开始的数据”取决于业务怎么理解分页。很多业务其实无所谓但如果你做的功能是只能依赖页码跳页的比如后台管理系统的批量操作这个方案就不适合。2.3 方案三延迟关联先取主键再回表从上面的分析可以看出深分页慢的焦点有两个一个是 OFFSET 导致的无谓扫描另一个是排序后逐条回表带来的开销。延迟关联这个方案核心思路就是摧毁第二个焦点——先通过覆盖索引把所有需要排序过滤的查询跑完拿到这批数据的主键 id再用 join 或者子查询的方式去主表回表取完整数据把回表的次数压缩到最小。标准的写法有两种效果几乎一样-- 写法 A子查询 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t ON o.id t.id; -- 写法 B直接 JOIN 临时结果集 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t USING (id);这段 SQL 的神奇之处在于最内层的子查询只需要id这一列于是 MySQL 可以走覆盖索引——索引里已经包含了id和排序字段扫描索引时不需要回表取其他字段扫描的成本大幅下降。等拿到 20 个主键 id 之后再用它们去主表取完整数据这时候只回表 20 次。打个比方你以前每次翻页都要从书架上把 1000 本书拿下来翻一遍封面现在你只需要先把书架上的标签目录扫一遍确定 20 本目标书的编号再精准地去抽那 20 本书出来。这个方案跟方案二结合就有了一个比较经典的“深分页终极形态”先用游标条件过滤掉无效数据再配合覆盖索引取 id最后回表SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders WHERE create_time 2024-06-01 12:00:00 ORDER BY create_time DESC, id DESC LIMIT 20 ) t ON o.id t.id;在实际优化深分页的时候我一般先把方案三作为首选推荐——它不需要改动接口协议不需要传入上一页游标参数业务改动量小性能提升却非常明显适用于大多数后台管理系统、报表分页等“必须支持页码跳转”的场景。2.4 方案四子查询优化与 JOIN 变体从执行计划角度看效果差异前三节其实已经覆盖了最常见的三种优化但你在网上还经常能看到另一种方案——“用子查询优化深分页”本质上跟方案三是同源只是有时候写法会用EXISTS、IN或者LEFT JOIN等方式来表达。我这里单独拎出来讲是因为它们的执行计划可能差异很大不能盲目照抄尤其是IN的写法在 MySQL 优化器下会做查询转换未必能拿到你想要的效果。先看一个网上流传很广的写法SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 1 ) ORDER BY create_time DESC, id DESC LIMIT 20;这个方案的逻辑是先从索引里查出“第 1000001 条的 id”然后主查询用id 这个值直接定位起点再取 20 行。如果排序字段和 id 的递增顺序完全一致也就是说create_time和id单调相关那么这种写法非常高效相当于把深分页转化成了主键范围扫描。但如果create_time不是单调的比如用户创建订单后可以修改时间、补单等这个“第 1000001 行的 id 一定恰好是第 1000001 个时间点的那一行”的假设就不成立了结果集就会出现偏差——实际取到的 20 行可能不是你想按时间排序得到的顺序。再看JOIN的变体。很多人把方案三写成了JOIN (SELECT ... LIMIT 1000000, 20) t ON o.id t.id这时候你一定要留意 MySQL 实际怎么执行这个 JOIN。理想情况是驱动表是那个只有 20 行的子查询临时表被驱动表是订单主表用主键一一匹配快得离谱。但如果你在子查询里没有保证只查id或者漏掉了排序MySQL 优化器有可能会把 JOIN 顺序倒过来先扫描订单主表的 100 万行再去做匹配那就彻底废了。想确认你的 SQL 到底怎么跑拿到一个真实执行计划非常重要。直接看这条 SQL 的 key、rows、extra 三列。如果 key 显示走了主键索引、extra 没有 Using filesort、rows 估算只有 20 行左右那基本就是最优执行路径。我看到过不少人把方案三写出来却依然很慢一查执行计划果然是被优化器改了执行顺序或者出现了临时表排序。所以这节要给你一个明确建议方案四不要单独作为独立方案来选它更像是方案三的变体和进阶版。真正决定效果的不是 SQL 长得像哪种写法而是执行计划最终走向哪条路径。优化深分页的时候多花一点时间读执行计划比在论坛上求一个“万能 SQL”靠谱得多。3. 实操演练200 万行真实数据下的四种方案性能对比3.1 建表与造数让数据分布尽量贴近业务真实情况为了让你对四种方案的差距有直观感受我专门造了一张orders表结构和真实电商订单表比较接近。表结构如下CREATE TABLE orders ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL DEFAULT , user_id bigint unsigned NOT NULL DEFAULT 0, amount decimal(12,2) NOT NULL DEFAULT 0.00, status tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_create_time (create_time, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里加了联合索引idx_create_time包含了create_time和id两列。为什么要带id因为 InnoDB 的二级索引叶子节点本来就会自动带上主键但你在建索引的时候把id显式写进去可以让部分查询直接走覆盖索引减少回表。这个细节在方案三里至关重要。造数据我直接用存储过程插了 200 万行create_time按业务时间从 2022 年 1 月 1 日开始递增但为了模拟真实业务里“同一秒内有多条订单”的情况我在时间上做了一点随机偏移。如果你自己测试可以照这个写DELIMITER $$ CREATE PROCEDURE insert_orders() BEGIN DECLARE i INT DEFAULT 1; DECLARE base_time DATETIME DEFAULT 2022-01-01 00:00:00; START TRANSACTION; WHILE i 2000000 DO INSERT INTO orders (order_no, user_id, amount, status, create_time) VALUES ( CONCAT(ORDER, LPAD(i, 10, 0)), FLOOR(RAND() * 100000), ROUND(RAND() * 1000, 2), FLOOR(RAND() * 5), DATE_ADD(base_time, INTERVAL FLOOR(RAND() * 100000) SECOND) ); IF i % 10000 0 THEN COMMIT; START TRANSACTION; END IF; SET i i 1; END WHILE; COMMIT; END$$ DELIMITER ; CALL insert_orders();插完之后我分别跑了四种方案翻到第 100 万行之后的位置用EXPLAIN看执行计划并用SHOW PROFILE或者SET profiling 1抓取各条 SQL 的执行耗时。下面这是实验结果。3.2 四种方案在同一环境下的性能实测数据方案一原始 LIMIT OFFSETSELECT id, order_no, user_id, amount, create_time FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20;实测执行时间大约 4.2 秒。执行计划里能看到idx_create_time被使用但 Extra 里有明显的Using index condition因为没有覆盖所有回表字段所以每一行都要回表拿完整记录。也可以用OPTIMIZER_TRACE看更细的代价估算结论是扫描行数接近 100 万加 20。到了第 150 万行的位置这条 SQL 耗时涨到了 6 秒以上。如果你在线上接口里这么写用户的体验就是转圈很久最后还可能超时。方案二游标分页-- 模拟上一页最后一条数据 id 999980 SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id 999980 ORDER BY id DESC LIMIT 20;实测执行时间 8 毫秒左右。执行计划 key 走 PRIMARYrows 估算值远小于 OFFSET 方案Extra 干净利落没有 filesort。你别被这个简单的 SQL 迷惑它的前提是必须把上一页最后一行的 id 传到后端由后端拼条件。这个方案在“瀑布流加载”场景里效果极佳不管用户翻了多少页耗时基本恒定在个位数毫秒级别。方案三延迟关联SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t ON o.id t.id;实测执行时间 0.9 秒左右相比方案一的 4.2 秒提升了大约 4.5 倍。为什么还有 0.9 秒因为内层子查询依然要扫描并丢弃 100 万行索引记录虽然这一步不需要回表但索引扫描也有开销。内层子查询的执行计划显示 Extra 是Using index也就是覆盖索引扫描这已经是 MySQL 能做到的最优路径了。如果你把 OFFSET 拉到 150 万行方案三耗时大约 1.2 秒相比方案一的 6 秒提升非常明显。不过要注意方案三依然会随着 OFFSET 增大而缓慢变慢因为它只是减少了回表开销没有消除 OFFSET 扫描成本。方案四子查询定位 主键范围SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 1 ) ORDER BY create_time DESC, id DESC LIMIT 20;实测执行时间 0.05 秒左右比方案三还要快一个数量级。但我在前面已经提醒过这个方案要求create_time的排序顺序和id递增顺序严格一致否则结果集可能有偏。我这次造的数据里create_time是随机偏移的所以这个方案取出来的 20 行严格按时间排序的话并不是“第 1000001 到第 1000020 条”而是从“第 1000001 个 id 对应的时间点附近”往后取的业务上如果对排序精确性有要求这个方案要谨慎使用。为了让你更直观地对比我把四个方案跑出来的关键数据整理成下面的表格方案核心写法第 100 万行耗时第 150 万行耗时能否支持页码跳转排序字段要求建议场景方案一LIMIT OFFSET约 4.2 秒约 6 秒支持无特殊要求很少用仅适合数据量小或测试方案二WHERE id 游标约 8 毫秒约 8 毫秒不支持排序字段必须唯一App 列表、无限滚动方案三延迟关联 JOIN约 0.9 秒约 1.2 秒支持无特殊要求后台管理、报表分页方案四子查询定位 主键范围约 50 毫秒约 55 毫秒支持排序字段最好与 id 单调一致特定业务需验证结果集这张表的数据来自我本机 MySQL 8.0 环境机器配置中等偏下不同硬件下绝对数字会变但相对快慢关系基本稳定。方案四虽然最快但限制条件也最苛刻选型不能只看速度。3.3 从执行计划反推为什么快读懂 key、rows、Extra 三列前面说了一堆执行计划我猜不少读者会想到底怎么才能真正看懂 EXPLAIN这块不难你只需要抓住三列核心字段key、rows、Extra。先看 key。key表示 MySQL 最终选用了哪个索引。深分页优化里你希望看到 PRIMARY 或者覆盖索引的名称而不是 NULL。如果 key 是 NULL说明 MySQL 只能全表扫描那性能大概率是灾难级别的。再看 rows。rows是 MySQL 估算的需要扫描的行数注意这是个估算值不是真实值。方案一里 rows 可能估算为 1000020方案三里内层子查询的 rows 也是约 1000020但两者含义不同——方案一这 100 万行都要回表聚簇索引方案三内层子查询只需要扫 idx_create_time 这个二级索引索引记录比聚簇索引记录瘦得多单位时间能扫描的行数完全不同。所以执行计划里 rows 相同的时候要结合 Extra 判断代价。最后看 Extra。这一列经常会冒出来Using filesort、Using temporary、Using index、Using index condition。前两个是性能杀手能避免就避免。Using index是好消息表示查询可以被覆盖索引满足不需要回表。Using index condition表示索引下推优化也算正面信号但通常意味着还是需要回表拿最终数据。方案三的执行计划里内层子查询 Extra 显示Using index外层连接走主键Extra 没有额外排序。方案一执行计划 Extra 里可能同时出现Using index condition和一个主键上的回表操作整体代价就上来了。我用 EXPLAIN 分析的时候习惯先看 rows 和 Extra再用EXPLAIN ANALYZEMySQL 8.0.18 支持看真实耗时和循环次数比单纯看估算值靠谱得多。比如EXPLAIN ANALYZE SELECT ...会输出实际执行时间、扫过的行数和回表次数一眼就能看到瓶颈在哪。4. 方案选型决策指南不同业务场景到底该选哪一种4.1 后台管理系统与报表分页优先延迟关联后台管理系统、报表中心这类场景有个共同特点必须支持页码跳转用户可能点“第 87 页”也可能点“末页”。这种需求天然排除了游标分页因为游标分页需要上一条数据位置没有办法直接跳页。那是不是无脑上延迟关联也不一定。如果表的数据量只有几万行LIMIT OFFSET 完全够用没必要为了一个不存在的性能问题增加 SQL 复杂度。从我个人的判断标准来看单表数据超过 100 万行、或者 OFFSET 经常超过 10 万行才建议做优化否则过于复杂的 SQL 反而增加维护成本。如果到了必须优化的情况延迟关联是后台系统里投资回报率最高的方案——SQL 改动量小保留页码语义性能提升 4 倍以上。再配合“限制最大翻页深度”比如大于 2000 页就不允许查询直接提示用户使用筛选条件这是很多大厂后端的通用策略简单粗暴但非常有效。4.2 移动端和 Web 端无限滚动游标分页是天然答案移动端消息列表、订单流、商品评论、Feed 流这类“上拉加载更多”的功能逻辑上完全不需要页码只需要“上一页最后一条的位置”。这种场景下游标分页是最匹配的解法因为性能稳定跟翻页深度无关永远扫描几十行用户体验好边滑边加载响应速度是毫秒级天然避免重复数据。在 OFFSET 分页中如果用户翻页过程中表里新增了记录下一页的结果可能包含上一页已经看到的数据而游标分页以“最后一条的位置”为界新插入的记录只会出现在已经翻过去的位置之后体验上是“往下刷的时候能看到新内容但不会让你重复看到旧内容”。当然游标分页需要后端接口做特殊设计请求参数不再是pageNum和pageSize而是lastId或lastCreatedAt。前端要在列表数据里记录最后一条的标识每次请求带上。改造量不大但对前端感知是有的。如果你是第一次做这种接口建议把游标字段统一命名为cursor返回值里额外返回一个has_more标记这样前端逻辑最清爽。4.3 高并发实时性强的场景方案组合远比单一方案可靠真正的生产环境里我很少看到只用一种方案“一条路走到黑”的。原因很简单业务形态往往是混合的——同一个订单列表接口在 App 端需要无限滚动在管理后台需要页码跳转那你就得在同一个查询服务里做不同分支。更常见的组合是“游标 延迟关联”-- 游标过滤 覆盖索引取主键 回表取详情 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders WHERE create_time 2024-06-01 12:00:00 OR (create_time 2024-06-01 12:00:00 AND id 500000) ORDER BY create_time DESC, id DESC LIMIT 20 ) t ON o.id t.id;这种写法把方案二的高效定位和方案三的低回表成本结合到了一起性能最佳。但我也要提醒你并不是每个团队都有精力把分页做得这么精细。如果你的表数据量不大、并发压力有限最简单的方案反而是加一层 Redis 缓存把热点页数据提前缓存DB 的深分页问题根本到不了你面前。优化是一个系统性的取舍不是把所有高级技巧都堆上去才叫好方案。5. 深分页优化避坑指南这里面的坑我基本都踩过5.1 加了索引却不生效先检查数据类型和排序规则有次帮一个团队排查慢查询他们给日期列加了索引但 SQL 还是全表扫描。看了表结构才发现查询条件里传入的日期变量是字符串而表字段是 datetimeMySQL 为了比较隐式地把字段做了转换索引直接失效。这类问题在深分页优化中尤其坑——你辛辛苦苦选了优化方案结果底层查询条件本身就没走索引再花哨的写法也白搭。另外要留意联合索引的顺序。如果你要排序的字段是create_time和id那联合索引必须把create_time放前面id放后面。反过来建索引排序时大概率还是 filesort。如果没有特殊理由给深分页相关的索引都加上尾部主键这是 InnoDB 二级索引结构决定的可以利用到覆盖索引的特性。5.2 分页排序的稳定性缺少唯一排序依据会导致数据重复或丢失深分页里一个隐蔽的坑是排序不稳定。假设你现在 SQL 只写ORDER BY create_time DESC而不带id DESC当 create_time 有大量重复值时MySQL 返回的行顺序是不确定的。第一页返回了 id 为 100、200 的记录翻到第二页时扫描到的数据顺序可能已经变了于是你看到两条第一页出现过的数据另一条本该出现的数据反而被挤到下一页——这就是用户反馈的“列表数据乱了”。解决方式很简单排序字段里补一个全局唯一字段通常是主键。也就是说任何分页 SQL排序都应该写成ORDER BY create_time DESC, id DESC。这不是可选项而是写分页 SQL 的默认底线。对游标分页而言这个要求更加苛刻——因为你要用上一页最后一条的位置做下一页的起点如果排序不稳定位置就完全失真了。5.3 不要盲目使用 IN 子查询和 JOIN 的等价改写方案三里用到了JOIN (SELECT id ...) t很多读者可能会想那WHERE id IN (SELECT id ...)是不是也可以用是可以的但你要做执行计划验证。MySQL 对IN (SELECT ...)的优化可能会产生临时表去重或者被改写成 semi-join执行路径未必是你想要的。我在 8.0 环境里测试过同样语义的 IN 写法性能比 JOIN 写法差了不少有些版本甚至走了全表扫描。所以我的建议是如果你决定用方案三直接复制上面那两段 JOIN 写法的标准模板不要去尝试各种等价改写。改写的风险在于你无法控制优化器的行为它跟你 MySQL 版本、数据分布、统计信息都有关。同一个 SQL 在 5.7 上很快升级到 8.0 之后优化器改了策略可能突然变慢。反观 JOIN 模板的执行路径相对稳定因为它把“子查询只有 id”这个事实写得很明确优化器很难犯傻。5.4 深分页的替代手段查询条件前移和数仓冷热分层最后说一个经常被人忽略的思路深分页问题除了在 SQL 层面优化还能从业务设计层面绕过。很多分页需求本身就不合理——比如订单管理后台默认列表用户真的会翻到 1000 页吗大多数情况下不会他们只是偶尔找一个历史订单与其让数据库把 1000 页都翻出来不如提供更强的筛选条件时间范围、订单号、用户 ID把查询范围大大缩小。如果确实需要全面查历史数据比如财务月底对账、运营拉全量数据那就不该直接在业务库上翻页了而应该走数仓或离线分析引擎或者把数据按时间做冷热分层。热数据放 MySQL冷数据放归档库或对象存储。用一句我在团队里反复强调的话最好的深分页优化就是让深分页根本不会发生。我一直觉得深分页问题的价值不在那四种 SQL 写法本身而在于它逼迫你去理解 MySQL 的索引结构、执行计划、回表成本理解一个操作在数据库底层到底做了什么。很多性能问题排查到最后拼的都是对底层机制的理解深度。这套方法学会了以后遇到慢查询、锁等待、索引失效你都会有更清晰的排查路径。
