ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度分页慢?真相竟是随机回表

深度分页慢?真相竟是随机回表 Buffer Pool 与深度分页优化核心认知冲突为什么数据都在内存Buffer Pool里了深度分页回表依然那么慢答案不在有没有进内存而在内存访问是顺序还是随机——这决定了 CPU 缓存是否命中。一、Buffer PoolInnoDB 的内存心脏1.1 页是内存与磁盘交换的最小单位默认 16 KB。Buffer Pool 中存的是页不是单行。数据页存放实际行记录SELECT *最终要拿的数据索引页存放 BTree 的节点关键认知BTree 是按需加载不是全量加载。查一条数据最多产生3 次索引页 I/O 1 次数据页 I/O。一个 200GB 的大表磁盘上绝大多数页在内存中都不存在冷数据。1.2 双层内存架构与 O_DIRECT数据从磁盘读取要经过两道门OS Page Cache磁盘数据先进入操作系统内核空间InnoDB Buffer Pool数据从内核拷贝到 MySQL 进程的用户空间生产环境致命设置innodb_flush_method O_DIRECT设置后数据页直接绕过 OS Page Cache只存放在 InnoDB Buffer Pool 中。结论生产环境中内存特指InnoDB Buffer Pool完全由数据库自身管理LRU 链表不受操作系统内存回收干扰。1.3 冷热分离 LRUBuffer Pool 没有使用标准 LRU而是冷热分离改进版区域占比存放数据Young 区热数据默认 63%100 - innodb_old_blocks_pct高频访问的热点数据Old 区冷数据默认 37%innodb_old_blocks_pct刚加载进来的数据晋升机制防止缓存污染新页首次加载 → 放入Old 区头部必须在 Old 区停留超过innodb_old_blocks_time 1000ms并且再次被访问→ 晋升到 Young 区头部为什么要有 1 秒等待一次毫无节制的SELECT * FROM big_table全表扫描会加载大量数据页。如果没有这 1 秒等待这些一次性冷数据会瞬间挤满 Young 区把真正高频的热数据冲走 → 性能雪崩。有了 1 秒等待全表扫描的数据页在 Old 区到此一游还没熬到 1 秒就被覆盖淘汰永远不会污染 Young 区。1.4 Buffer Pool 命中率检测SHOWGLOBALSTATUSLIKEInnodb_buffer_pool_read%;指标含义Innodb_buffer_pool_read_requests逻辑读总次数请求内存Innodb_buffer_pool_reads物理读总次数请求磁盘命中率(读请求总数 - 物理读次数) / 读请求总数 × 100% 99%内存极佳热点数据常驻 95%内存严重不足或 SQL 正在高频访问冷数据如深度分页二、深度分页的物理本质2.1 问题 SQLSELECT*FROMordersWHEREstatus1ORDERBYcreate_timeLIMIT1000000,10;2.2 为什么大 offset 慢MySQL 沿排序索引扫offset size行每一行都回表取完整字段然后在 Server 层丢弃前offset行。等于做了offset次白干的随机回表——offset越大浪费的随机 I/O 越多耗时非线性增长。2.3 为什么优化器可能放弃索引如果走二级索引(status, create_time)索引有序ORDER BY不需额外排序致命点要确定LIMIT终点必须从索引起点顺着链表遍历1,000,010 条索引记录前 1,000,000 条满足条件的记录执行器必须回表 1,000,000 次随机 I/O最后只保留 10 条优化器判定1,000,000 次随机回表的磁盘 I/O 代价远大于全表扫描顺序 I/O 内存文件排序filesort的代价。因此 MySQL 宁可扫全表也不走索引。2.4 回表是随机 I/O二级索引叶子按「索引列」排序如 create_time但聚簇索引按「主键 id」物理存储。从二级索引跳聚簇索引时id 顺序是乱的 → 每次跳转都是一次随机寻址。存储介质随机 IOPS顺序 IOPS差距机械盘~100~10,000~100 倍SSD——10~50 倍2.5 火山模型的死脑筋MySQL 执行器基于火山模型逐行迭代上层(客户端) → 执行器 → 索引扫描/回表执行器调用一次handler::rnd_next()索引层就返回一条完整的*数据索引取 ID 立即回表取行LIMIT和OFFSET是计数器挂在执行器层每吐出一条完整行计数器 1累加到 1,000,010 才停为什么先暂存 100 万个 ID 再回表因为内存风险OOM。如果偏移量是LIMIT 100000000, 10数据库要在内存中暂存 1 亿个主键 ID。为了稳定性执行器选择拿一行丢一行宁愿牺牲性能也绝不允许内存爆掉。三、解决方案一延迟关联Deferred Join3.1 核心思想先走覆盖索引查出主键最后再用主键批量回表。SELECT*FROMorders oINNERJOIN(SELECTidFROMordersWHEREstatus1ORDERBYcreate_timeLIMIT1000000,10)AStmpONo.idtmp.id;3.2 为什么子查询能走索引子查询SELECT id中二级索引(status, create_time, id)覆盖了查询所需的所有列没有回表动作遍历 100 万条索引记录时所有数据都在索引页中完全是内存级 / 顺序 I/O子查询精确拿到 10 个id后外层查询仅对这10 个主键进行回表3.3 延迟关联本质 5 句深度分页慢因沿排序索引扫Nsize行 → 每行随机回表取*→ 前 N 行取了又丢弃白干。优化器陷阱SELECT * 大 offset 时优化器可能主动放弃索引选择「全表扫 filesort」。延迟关联 3 步走① 子查询覆盖索引只查 id → 扫索引但 0 次回表取出最后size个 id② 用size个 id 精确回表主键等值 eq_ref→ 只回表size次③ 按子查询 id 顺序重排IN 不保证顺序。本质把N次随机聚簇回表 →size次有序主键回表。随机 I/O 次数削减N/size数量级。Buffer Pool 稀释效应数据全在内存时随机回表退化成内存访问延迟关联提升可能只有 ~7%数据远大于内存、offset 到百万级时通常5~10× 提升。3.4 成本对比维度原始 SQL延迟关联索引遍历1,000,010 次1,000,010 次回表次数offset size次size 次I/O 类型随机跳聚簇主键等值eq_ref子查询 Extra—Using index3.5 代码落地publicListOrderpageOptimized(intoffset,intsize){// Step 1覆盖索引查 id不回表ListLongidsorderMapper.selectIdsByCreateTime(offset,size);if(ids.isEmpty())returnCollections.emptyList();// Step 2主键精确回表ListOrderordersorderMapper.selectBatchByIds(ids);// Step 3必写IN 不保证返回顺序按 ids 列表重排orders.sort(Comparator.comparingInt(o-ids.indexOf(o.getId())));returnorders;}!-- 延迟关联第 1 步覆盖索引只查 id --selectidselectIdsByCreateTimeresultTypejava.lang.LongSELECT id FROM orders FORCE INDEX(idx_create_time) ORDER BY create_time LIMIT #{offset}, #{size}/select!-- 延迟关联第 2 步主键 IN 回表 --selectidselectBatchByIdsresultMapOrderResultMapSELECT * FROM orders WHERE id INforeachcollectionidsitemidopen(separator,close)#{id}/foreach/select3.6 两坑必背IN 查询不保证返回顺序子查询按 create_time 排好序的 id经过IN(...)后按主键 id 顺序返回。Service 层必须重排。FORCE INDEX 为什么要加不加的话优化器在大 offset 时可能选全表扫 filesort延迟关联失去对比意义。四、解决方案二游标查询Seek Method警告延迟关联依然无法避免遍历 100 万条索引链表指针。当数据量达到千万级时即便延迟关联也可能需要 0.5~1 秒。4.1 核心思想彻底放弃OFFSET利用WHERE条件进行范围切割。-- 假设上次查询最后一条 create_time 2023-01-01 12:00:00-- 下一页直接这样查SELECT*FROMordersWHEREstatus1ANDcreate_time2023-01-01 12:00:00ORDERBYcreate_timeLIMIT10;4.2 为什么是 O(1) 恒定速度索引(status, create_time)能瞬间定位到时间戳的 BTree 位置一次二分查找O(log N)从该位置开始只向后顺序取10 条索引记录回表次数仅 10 次如果create_time有毫秒级重复建议在ORDER BY中追加id并在WHERE中带上(create_time, id) (?, ?)保证数据不丢不重复。4.3 适用场景对比维度延迟关联游标查询时间复杂度O(N)仍需扫 N 行索引O(log N size)直接定位跳页支持✅ 支持任意页码❌ 只能前后翻页前端交互传统分页页码条瀑布流 / 加载更多前端需传offset size上一页最后一条的排序键值五、为什么深度分页即使命中内存也慢回到最初的问题数据都在 Buffer Pool 里了为什么还慢答案在CPU 缓存层级L1/L2/L3 Cache。对比维度顺序索引扫描延迟关联子查询随机回表原SELECT *数据特征扫描 BTree 叶子链表物理内存地址连续或相邻根据主键 ID 跳转内存地址毫无规律CPU 预读相邻数据预加载到 L2 缓存命中率极高每次寻址无法预判下一个地址频繁CPU Cache Miss物理代价即使数据在 Buffer PoolDRAM也是顺序内存访问纳秒级即使数据在 Buffer Pool也是随机内存访问微秒级慢5~10 倍极端情况索引页极大概率是热点数据常驻内存深度分页偏移量大涉及的数据页极大概率不在 Buffer Pool冷数据触发磁盘随机读毫秒级再慢 100 倍延迟关联的本质站在内存管理角度子查询SELECT id确保遍历 100 万条索引记录时全部操作在二级索引页上这部分数据大概率是Young 区热数据或顺序内存读。当只取出 10 个id后外层查询仅对这 10 个 ID 进行随机回表。即使这 10 条数据不在 Buffer Pool 中也只需要从磁盘随机读取10 个数据页代价完全可控。六、踩坑 Top 3#坑根因解1无排序索引时ORDER BY col LIMIT N,10走全表扫 filesort延迟关联无效排序字段没有索引先在建排序列建索引如idx_create_time否则深度分页无药可救2建了排序索引但 EXPLAIN keyNULL优化器认为「随机回表 N 次 cost 全表扫 filesort」实验用 FORCE INDEX生产靠 SQL 改写或游标分页3pageOptimized 返回顺序不对IN (...)按主键顺序返回跟子查询排序无关Service 层用ids.indexOf()按原 id 列表重排不能省核心速查表全链路数据流动磁盘冷数据16KB 页 → 物理 I/O 加载 → 开启 O_DIRECT直接进入 InnoDB Buffer Pool数据库内存 → 冷热分层新页进 Old 区 → 停留 1s 且二次访问 → 晋升 Young 区 → CPU 指令执行 - 顺序读索引遍历地址连续CPU 预读命中 L2纳秒级 - 随机读回表查 *地址跳跃CPU Cache Miss微秒级甚至触发磁盘读深度分页优化决策树有排序索引 ├─ 否 → 先建索引否则无药可救 └─ 是 → 需要跳页 ├─ 是 → 延迟关联子查询 SELECT id JOIN └─ 否 → 游标查询WHERE 上一页最后一条O(1)一句话心法延迟关联优化的不是减少 I/O 次数而是将随机的、不可预判的冷数据回表转化为顺序的、预载友好的热数据索引扫描。它从物理底层磁盘寻道和微观架构CPU Cache Miss两个维度同时避开了性能陷阱。
返回列表