想象你是一位图书管理员,读者递来一张写满书号的书单,每本书分散在图书馆不同楼层、不同书架。为了完成借阅,你每看一个书号就跑一趟书架。一会儿从一楼冲到三楼,一会儿又折回一楼,借 30 本书要跑 30 趟,累得气喘吁吁。换成有经验的老师傅,他会先把书单上的书号抄下来,按书架位置排好,再推着小车从一楼一路顺到三楼,一圈走完就把所有书取回来。MySQL 的 MRR(Multi-Range Read,多范围读)正是这个思路:它把“查二级索引 → 逐行回主键索引取数据”这种随机 IO,改造成“先收集编号 → 排序 → 按顺序批量回表”的顺序 IO。本文就用这个故事,带你认识 MRR 的触发条件、执行流程、与索引下推 ICP 的配合,并学会用 EXPLAIN 判断它是否真的生效。

岗位关联

对数据库调优的从业者来说,掌握 MRR 意味着你能用很小的参数改动解决二级索引大范围扫描导致的慢 SQL;这是后端 DBA、高级开发日常排查慢查询和面试中的高频硬技能。

目录
  1. 可视化演示
  2. IO随机之痛
  3. 回表机制解析
  4. MRR执行流程
  5. 触发条件精讲
  6. ICP下推协同
  7. 参数开关调优
  8. EXPLAIN诊断实战
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

通过随机IO问题、MRR工作流程、ICP配合三个模式,展示MySQL如何将随机回表IO转为顺序IO提升范围查询性能。

本节配有交互动效(进入视口后自动加载,约 3KB)。

随机IO瓶颈问题 二级索引范围扫描返回rowid,逐条回表导致随机IO 随机IO在HDD上性能极差,百倍慢于顺序IO Index → rowid → Random Table Lookup 逐条回表是性能瓶颈根源

IO随机之痛

图书馆管理员老李每天都要按书单取书。单子上的书分布在 A 区、B 区、C 区,他每取完一本,就要回到柜台看下一本在哪儿,然后再次出发。结果一上午都在爬楼梯、绕走廊,真正取书的时间反而没多少。数据库里的机械硬盘做范围扫描时也面临同样的问题:数据页不连续,磁头就需要反复移动。

在传统机械硬盘上,一次随机 IO 的开销主要是磁头寻道和盘片旋转等待;而顺序 IO 因为磁头沿着相邻磁道一路读过去,开销几乎只来自数据传输本身。通常一次随机读需要约 10ms,而顺序读一个扇区可能不到 0.1ms。如果范围查询命中几千行,每一行都要这样随机访问,慢查询就被无限放大了。

老李很快发现,问题的根源不是“书太多”,而是“路线太乱”。只要把路线理顺,哪怕书还是那么多,也能轻松很多。这就是 MRR 要解决的第一个痛点。

Trandom=n×(tseek+trotation),Tseq≈n×treadT_{random}=n \times (t_{seek}+t_{rotation}), \qquad T_{seq}\approx n \times t_{read}
通俗理解:随机读一次可能花 10 毫秒,顺序读一次只要 0.1 毫秒;命中 n 行时,这个差距会被放大 n 倍。
  1. 单次随机IO成本访问一个不在当前磁道的数据页,磁头要先移动并等待盘片转到目标扇区。
    tper=tseek+trotationt_{per}=t_{seek}+t_{rotation}
  2. 累加总成本如果二级索引范围查询需要回表 n 行,总成本就是 n 个独立随机访问之和。
    Trandom=n×tperT_{random}=n \times t_{per}
  3. 顺序IO优势连续读多个相邻页时,磁头移动被大幅摊薄,成本接近读取本身。
    Tseq≈n×treadT_{seq}\approx n \times t_{read}

回表机制解析

为什么范围查询会产生这么多随机 IO?关键在 InnoDB 的索引结构。主键索引的叶子节点直接保存完整行,而二级索引的叶子节点只保存“索引字段 + 主键值”。你想通过 user_id 查用户的手机号,就得先用 user_id 索引找到对应主键值,再拿着主键值到主键索引里找完整行。这个过程叫回表,就像你查到一张“索引卡”,上面只有书名和编号,真正取书还得按编号跑到对应书架。

大范围扫描时,二级索引给出的主键值通常不是连续的。比如 user_id 在 5000 到 6000 之间的记录,主键 id 可能是 1、5、9、17、23……它们散落在表的不同数据页里。每回表一次,就是一次随机访问。命中 N 行,就至少发生 N 次回表。

老李意识到,真正浪费时间的不是取书本身,而是“每次只看一个编号就跑一趟”的习惯。如果先把所有编号抄下来排好序,问题就解决了一半。

Nlookup=Nrows,Tlookup=Nrows×TrandomN_{lookup} = N_{rows}, \qquad T_{lookup} = N_{rows} \times T_{random}
通俗理解:回表次数不会打折。查询扫过多少行,就要到主键索引跑多少趟;跑得是否顺畅,取决于主键值的物理分布。
  1. 行数与回表数相等二级索引范围扫描扫出多少行,就要准备多少次主键索引访问。
    Nlookup=NrowsN_{lookup} = N_{rows}
  2. 每次回表都是随机访问主键值对应的数据页分散,导致每一次访问都可能要磁盘寻道。
    Tlookup=Nrows×TrandomT_{lookup} = N_{rows} \times T_{random}
  3. 优化空间出现如果能把 N 次随机访问改成 N 次顺序访问,总耗时就能下降一两个数量级。

MRR执行流程

MRR 的做法和老李的改进一模一样。当优化器决定使用 MRR 时,MySQL 会先把二级索引范围内命中的主键值(rowid)收集到一个内存缓冲区,这个缓冲区由 read_rnd_buffer_size 控制。缓冲区里的主键值收集够一批后,从小到大排序;然后按排序后的顺序,一次性到主键索引批量取行。如果一次装不下,就分批执行。

排序之后,主键值在物理页上的位置更接近,磁头移动距离大幅缩短;当一批主键落在相邻页时,随机 IO 就近似变成了顺序 IO。这也是 MRR 名字里“多范围读”的含义——多个零散的单行读取被合并成有规律的多段顺序读取。

老李推着小车出发了:他不再因为下一个编号在楼上就立刻跑上去,而是等这一批编号都排好,沿路一次取完。如果小车装不下,就分两车,但每车都按书架顺序走。

TMRR=n×tseq+tsort+tbatchT_{MRR} = n \times t_{seq} + t_{sort} + t_{batch}
通俗理解:MRR 并没有减少要回表的行数,而是让回表时少走弯路;排序和分批是额外付出的代价,只要收益大于代价,它就是划算的。
  1. 顺序回表主成本n 行数据按排序后的主键顺序读取,每行近似顺序 IO。
    Tread=n×tseqT_{read} = n \times t_{seq}
  2. 排序成本在内存中对最多 read_rnd_buffer_size 个主键值排序,复杂度约为 O(n log n)。
    tsort=O(nlog⁡n)t_{sort} = O(n \log n)
  3. 分批成本缓冲区不够时要分多批执行,批次越多,重复扫描二级索引的开销越大。
    tbatch=⌈nbuffer_size⌉×tscan_partt_{batch} = \left\lceil \frac{n}{buffer\_size} \right\rceil \times t_{scan\_part}

触发条件精讲

不是所有书单都值得排序。如果读者只借两本书,直接跑两趟反而更快;如果借了几百本分散的书,排序后再取才划算。MySQL 优化器同样要做成本决策,只有满足条件时才会启用 MRR。

触发 MRR 通常需要几个前提:查询使用二级索引,且访问类型是 range 或 ref;查询需要回表获取非索引列;全局开关 optimizer_switch 中 mrr=on;优化器估算 MRR 成本低于直接随机回表。如果查询走主键索引、覆盖索引,或者结果集非常小,MRR 一般不会出现。

老李的经验是“看着办”:单子小就直接跑,单子大就排好序再跑。MySQL 的 mrr_cost_based 就是让优化器拥有同样的判断力。

MRR  ⟺  (type∈{range,ref})∧(need_lookup)∧(mrr=on)∧(Cmrr<Cdirect)\text{MRR} \iff (type \in \{range, ref\}) \land (need\_lookup) \land (mrr=on) \land (C_{mrr} < C_{direct})
通俗理解:MRR 是优化器的“路线规划工具”:只有路线混乱且规模够大时它才值得介入。
  1. 访问类型要求二级索引上的范围扫描或等值引用最容易产生大量离散主键值。
    type∈{range,ref}type \in \{range, ref\}
  2. 回表必要性只有 SELECT 需要二级索引以外的列,才必须回到主键索引取值。
    need_lookup=trueneed\_lookup = true
  3. 开关与成本双重门槛即使开关打开,优化器仍会比较 MRR 和直接回表的估算成本。
    Cmrr<CdirectC_{mrr} < C_{direct}

ICP下推协同

索引下推(ICP)是 MRR 的好搭档。它解决的是另一个问题:回表之前能不能先过滤掉一部分行?联合索引 (user_id, status) 上执行 WHERE user_id BETWEEN 100 AND 2000 AND status=1 时,ICP 允许存储引擎在扫描二级索引时就直接用 status=1 过滤,而不是每行都回表后才发现不满足。

这和 MRR 并不冲突。ICP 帮助的是“减少回表行数”,MRR 帮助的是“让必然发生的回表更顺序”。MySQL 5.6 之后两者可以同时工作,EXPLAIN 中往往同时出现 Using index condition 和 Using MRR。先由 ICP 在索引层把明显不符合条件的行划掉,剩下的 rowid 再交给 MRR 排序批量回表。

老李整理书单时发现,有些书虽然编号在单子上,但借阅规则显示“不在服务范围”。他直接在索引卡阶段就把这些书划掉,而不是跑到书架旁才发现取错了。

npush=σcond(nscan),Ttotal=npush×tseqn_{push}=\sigma_{cond}(n_{scan}), \qquad T_{total}=n_{push}\times t_{seq}
通俗理解:ICP 是“少跑几趟”,MRR 是“一趟顺路走完”。先划掉不合适的书,再把真正需要的书按顺序取回来。
  1. 索引层过滤存储引擎扫描二级索引时,用索引包含的列直接过滤条件。
    npush=σcond(nscan)n_{push}=\sigma_{cond}(n_{scan})
  2. 过滤后行数减少实际需要回表的行数小于等于扫描行数。
    npush≤nscann_{push} \le n_{scan}
  3. 与MRR串联过滤后的行进入 MRR 缓冲区,再按主键顺序回表。
    Ttotal=npush×tseqT_{total}=n_{push}\times t_{seq}

参数开关调优

在实际运维中,MRR 是否需要干预,取决于业务的 IO 特征。MySQL 提供几个关键参数:optimizer_switch 中的 mrr 和 mrr_cost_based 控制开关;read_rnd_buffer_size 控制 MRR 缓冲区大小;engine_condition_pushdown 控制 ICP 是否下推。

调优时建议先观察 EXPLAIN。如果大范围回表场景没有出现 Using MRR,可以临时把 mrr_cost_based=off 强制开启,对比执行时间;如果耗时下降明显,说明成本模型低估了 MRR 的收益。read_rnd_buffer_size 不宜设得太小,否则会分成很多批次,导致反复扫描二级索引;也不宜设得过大,毕竟这是会话级内存。

没有一种参数组合适合所有业务。老李需要根据书单的“数量”和“分散程度”来调整自己的策略,DBA 也要根据慢查询和 IO 特征做对照实验。

Cmrr=Csort+n×tseq,Cdirect=n×trandomC_{mrr}=C_{sort}+n\times t_{seq},\quad C_{direct}=n\times t_{random}
通俗理解:参数调整就是给老李定制度:什么时候允许“顺路取书”,以及每次推多大的车。
  1. MRR成本构成排序花费的 CPU 时间,加上顺序回表的 IO 时间。
    Cmrr=Csort+n×tseqC_{mrr}=C_{sort}+n\times t_{seq}
  2. 直接回表成本n 次独立随机 IO 的累加。
    Cdirect=n×trandomC_{direct}=n\times t_{random}
  3. 优化器选择标准当 MRR 总成本更低时,选择 MRR 才是合理的。
    choose(MRR)  ⟺  Cmrr<Cdirectchoose(MRR) \iff C_{mrr} < C_{direct}

EXPLAIN诊断实战

要确认 MySQL 到底有没有走 MRR,最直接的方法是看执行计划。EXPLAIN 输出的 Extra 列如果出现 Using MRR,说明优化器选择了多范围读优化。例如下面这条典型的大范围回表查询:

如果 user_id 有二级索引且查询需要取所有列,执行计划里就可能出现 type: range 和 Extra: Using MRR。如果没有看到,可以检查 optimizer_switch、read_rnd_buffer_size,以及查询是否真的需要回表;也可以对比开启和关闭 MRR 后的实际耗时。

老李每次优化完都会在记录单上画一笔,确认到底走了哪条路。DBA 也一样,EXPLAIN 是衡量优化是否落地的第一证据。

Extra⊇{Using MRR}⇒MRR active\text{Extra} \supseteq \{\text{Using MRR}\} \Rightarrow \text{MRR active}
通俗理解:EXPLAIN 就像一张“取书记录单”,上面写着管理员有没有按顺序推车取书。
  1. Extra关键值执行计划的 Extra 列包含 Using MRR 时,代表当前计划启用了 MRR。
    Extra∋Using MRR\text{Extra} \ni \text{Using MRR}
  2. 结合type判断MRR 通常配合 range 或 ref 出现,而不是 ALL 或主键扫描。
    type∈{range,ref}type \in \{range, ref\}
  3. 验证收益对比开关前后的 rows 与实际耗时,确认 MRR 是否带来性能提升。
sql2 行
EXPLAIN SELECT * FROM ordersWHERE user_id BETWEEN 100 AND 2000\G

AI 应用场景

大模型训练样本抽取

阶段:数据采集与预处理(ETL 数据抽取)

解决的问题:AI 训练经常要从 MySQL 中按用户 ID 范围拉取大量行为数据,传统写法命中二级索引后逐行回表,随机 IO 让抽取任务动不动跑几小时。MRR 可以把这些回表变成顺序读,显著缩短数据管道耗时。

具体案例:推荐系统离线管道执行 SELECT user_id, item_id, action FROM user_behavior WHERE user_id BETWEEN 10000 AND 20000 AND action_time > '2024-01-01'。开启 MRR 并配合 ICP 后,抽取任务耗时下降约 40%,原来 30 分钟的任务缩短到 18 分钟。

增量特征宽表构建

阶段:特征工程与样本生成

解决的问题:每日批量构建特征宽表时,需要从订单表按商户 ID 范围扫描并与商品表关联。若直接回表取非索引字段,随机 IO 会造成大量磁盘抖动;MRR 让扫描更平滑,为下游特征服务提供稳定延迟。

具体案例:某风控系统每天凌晨从订单表增量抽取 500 万条记录,二级索引范围查询加回表导致磁盘 IO 利用率达到 90%。DBA 调整 optimizer_switch 并调大 read_rnd_buffer_size 后,IO 利用率降到 55%,批处理时间提前 25 分钟完成。

Python 演示

python46 行
import random  class DiskSim:    def __init__(self, positions):        self.positions = positions[:]        self.head = 0        self.seek_distance = 0        self.time_ms = 0.0     def read(self, cell):        self.seek_distance += abs(self.head - cell)        self.time_ms += 0.1 + 5.0 * abs(self.head - cell)        self.head = cell     def run_direct(self):        self.head = 0        self.seek_distance = 0        self.time_ms = 0.0        for cell in self.positions:            self.read(cell)        return self.time_ms, self.seek_distance     def run_mrr(self):        self.head = 0        self.seek_distance = 0        self.time_ms = 0.0        for cell in sorted(self.positions):            self.read(cell)        return self.time_ms, self.seek_distance  def main():    random.seed(42)    positions = [random.randint(0, 99) for _ in range(20)]    sim = DiskSim(positions)    direct_time, direct_dist = sim.run_direct()    mrr_time, mrr_dist = sim.run_mrr()    print('随机位置:', positions)    print('直接回表耗时: %.1f ms, 寻道: %d' % (direct_time, direct_dist))    print('MRR回表耗时: %.1f ms, 寻道: %d' % (mrr_time, mrr_dist))    print('耗时下降: %.1f%%' % ((1 - mrr_time / direct_time) * 100))  if __name__ == '__main__':    main()
目的:用一次磁盘寻道模拟,直观对比“按书单原顺序取书”和“排好序再取书”的耗时差异。
  1. self.positions = positions[:]复制一份随机生成的书号位置,避免影响原始数据。
  2. self.time_ms += 0.1 + 5.0 * abs(self.head - cell)模拟顺序读基础耗时加寻道成本,距离越远耗时越长。
  3. for cell in self.positions:直接按随机顺序访问,对应不使用 MRR 的离散回表。
  4. for cell in sorted(self.positions):排好序后访问,对应 MRR 的按主键顺序回表。
  5. print('耗时下降: %.1f%%' % ...)输出 MRR 相比直接回表的收益百分比。

常见误区

误区我以为是 MRR 把散在各处的行合并了,所以回表次数变少了。既然叫“多范围读”,应该能把多个查询合并成一次吧?

正解MRR 并不减少回表行数。它只是改变回表发生的顺序:先收集主键值、排序、再批量顺序访问。就像老李取 30 本书,书还是那 30 本,只是不再楼上楼下反复折返。真正减少回表行数的是索引下推 ICP 或更优的索引设计。

为何常见:人们容易把“更快”等同于“做更少的事”。但性能优化除了减少次数,还可以是让每一次访问更便宜。MRR 属于后者,这也是它偶尔让结果集排序不稳定时容易被误解的原因。

误区我把 `optimizer_switch` 里的 `mrr=on` 打开,所有二级索引查询就该自动加速,实测却有的变慢了。

正解MRR 有额外成本:收集主键值需要内存缓冲区,排序需要 CPU,分批还会导致二级索引被多次扫描。MySQL 默认用 mrr_cost_based 做成本估算,小结果集或覆盖索引场景下,直接回表反而更快。强制开启前必须用 EXPLAIN 和实际耗时验证。

为何常见:初学者看到“随机 IO 变顺序 IO”这个优点后,容易忽略排序成本这另一面。任何优化都是有代价的,MRR 也不例外。

误区EXPLAIN 里要么出现 `Using index condition`,要么出现 `Using MRR`,所以两者互斥,只能启用一个。

正解ICP 在索引层先过滤行,MRR 在回表阶段再排序批量取数,二者分工不同、天然互补。MySQL 允许它们同时生效,优化器甚至经常把两者一起用在同一个二级索引范围查询上。ICP 决定“哪些行值得回表”,MRR 决定“剩下的行怎么高效回表”。

为何常见:人们看到 Extra 中多个关键字并列时,容易误以为它们是互斥开关。实际上这些关键字描述的是执行计划的不同阶段,可以同时存在。

误区既然 `mrr_cost_based=on` 有时不选 MRR,那我直接 `SET optimizer_switch='mrr_cost_based=off'` 不就行了吗?

正解mrr_cost_based=off 会让优化器放弃成本判断,尽量使用 MRR。这适合在压测时观察 MRR 上限,但不适合直接铺到生产。强行使用时,小范围查询和覆盖索引查询可能白白付出排序成本,反而拖慢性能。正确做法是先用 EXPLAIN 和对照实验确认收益。

为何常见:遇到慢查询时,大家总想找一个“一键加速”的神秘开关。但数据库优化没有银弹,任何强制手段都需要建立在可量化的对比之上。

面试题

Q1. MRR 主要解决哪类 IO 问题?

单选题 · easy

  • A.随机IO转顺序IO← 正确答案
  • B.网络IO转磁盘IO
  • C.日志IO转内存IO
  • D.小文件IO转大文件IO

答案:A。MRR 把二级索引回表时大量离散的随机访问,先按主键排序再批量读取,从而把随机 IO 变成顺序 IO。其他选项描述的不是 MRR 的核心目标。

Q2. InnoDB 二级索引叶子节点存储的是什么?

单选题 · easy

  • A.完整的行数据
  • B.主键字段的值← 正确答案
  • C.指向数据页的指针
  • D.数据库系统表编号

答案:B。二级索引叶子节点保存“索引列 + 主键值”。查到主键值后还需要到主键索引回表取完整行。这也是 MRR 存在的背景:回表访问是离散的。

Q3. 下列哪项不是 MRR 执行流程中的步骤?

单选题 · mid

  • A.收集目标行的rowid
  • B.对rowid排序等待
  • C.按主键顺序批量回表
  • D.扫描前对整表做全排序← 正确答案

答案:D。MRR 是“先收集、再排序、后回表”,排序对象是缓冲区里的 rowid,而不是对整表做全排序。对整表排序会破坏索引扫描的初衷,所以不是 MRR 的步骤。

Q4. EXPLAIN 中 Extra 出现 Using MRR 表示什么?

单选题 · mid

  • A.正在使用索引下推
  • B.使用了多范围读优化← 正确答案
  • C.发生全表扫描了
  • D.使用了临时表排序

答案:B。Using MRR 是执行计划中 MRR 生效的标志。它通常和 range 或 ref 一起出现。索引下推的标志是 Using index condition,与 MRR 不同。

Q5. mrr_cost_based=off 的含义是什么?

单选题 · mid

  • A.优化器强制尽量用MRR← 正确答案
  • B.优化器彻底禁用MRR
  • C.只对主键扫描生效
  • D.优化器忽略索引成本

答案:A。mrr_cost_based=off 表示关闭基于成本的 MRR 选择,让优化器尽量使用 MRR。这是一个测试手段,可能让小查询付出额外排序成本,不推荐直接用于生产。

Q6. 关于 ICP 与 MRR 的配合,正确的是?

单选题 · mid

  • A.二者互斥只能选一个
  • B.ICP先过滤MRR再回表← 正确答案
  • C.MRR先排序ICP再过滤
  • D.ICP只需要覆盖索引

答案:B。ICP 在索引层先过滤掉不符合条件的行,减少进入 MRR 缓冲区的 rowid,MRR 再对剩下的行排序批量回表。二者作用于不同阶段,可以同时出现。

Q7. read_rnd_buffer_size 主要影响 MRR 的哪个环节?

单选题 · hard

  • A.缓冲rowid的空间大小← 正确答案
  • B.二级索引页的内存大小
  • C.最终结果集的缓存大小
  • D.连接线程的栈空间大小

答案:A。MRR 先把二级索引扫描出的 rowid 放入 read_rnd_buffer_size 指定的缓冲区,缓冲区决定一批能容纳多少个 rowid。太小会分成很多批次,增加二级索引扫描次数。

Q8. 下列哪些条件有利于 MRR 生效?

多选题 · mid

  • A.二级索引范围查询← 正确答案
  • B.查询需要回表取列← 正确答案
  • C.结果集只有两行
  • D.大范围扫描离散行← 正确答案

答案:ABD。MRR 适合二级索引范围/ref 查询、需要回表、且扫描行数多而离散的场景。结果集很小的时候,排序和缓冲区的额外成本反而可能超过节省的 IO 时间,因此 C 不利于 MRR。

Q9. 关于 MRR 的收益,正确的说法有?

多选题 · hard

  • A.回表总行数变为原来的十分之一
  • B.把离散回表变成顺序批量读← 正确答案
  • C.减少磁头寻道和设备切换← 正确答案
  • D.消除所有索引范围扫描

答案:BC。MRR 不减少回表行数,而是让行访问顺序更连续,降低磁头寻道和调度切换成本。它不能消除范围扫描,只能让范围扫描回表更快。A 和 D 都把能力夸大了。

Q10. 关于 MRR 触发条件,正确的有?

多选题 · mid

  • A.主键全表扫描也常触发MRR
  • B.走二级索引且需回表才划算← 正确答案
  • C.mrr=on开关是必要条件← 正确答案
  • D.结果集一定按主键有序返回

答案:BC。主键扫描本身已经有序,不需要 MRR 排序;MRR 主要用于二级索引+回表场景,且需要 mrr=on。MRR 不保证最终输出按主键有序,它只改变内部取数顺序。