网易首页 > 网易号 > 正文 申请入驻

数据库索引:索引并不是万能药

0
分享至

  几乎所有的业务项目都会涉及数据存储,虽然当前各种NoSQL和文件系统大行其道,但MySQL等关系型数据库因为满足ACID、可靠性高、对开发友好等特点,仍然最常被用于存储重要数据。在关系型数据库中,索引是优化查询性能的重要手段。

  为此,我经常看到一些同学一遇到查询性能问题,就盲目要求运维或DBA给数据表相关字段创建大量索引。显然,这种想法是错误的。今天,我们就以MySQL为例来深入理解一下索引的原理,以及相关误区。

  InnoDB是如何存储数据的?

  MySQL把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式各不相同。MySQL支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持事务,我们最常使用的是InnoDB。为方便理解下面的内容,我先和你简单说说InnoDB是如何存储数据的。

  虽然数据保存在磁盘中,但其处理是在内存中进行的。为了减少磁盘随机读取次数,InnoDB采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘中。InnoDB的页面大小,一般是16KB。

  各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:

  页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向2个特殊的伪记录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小记录开始遍历整个页中的记录链表。

  举一个例子,如果要搜索主键(PK)=15的记录:

  • 先二分得出槽中间位是(0+6)/2=3,看到其指向的记录是12<15,所以需要从#3槽后继续搜索记录;
  • 再使用二分搜索出#3槽和#6槽的中间位是(3+6)/2=4.5取整4,#4槽对应的记录是16>15,所以记录一定在#4槽中;
  • 再从#3槽指向的12号记录开始向下搜索3次,定位到15号记录。

  理解了InnoDB存储数据的原理后,我们就可以继续学习MySQL索引相关的原理和坑了。

  聚簇索引和二级索引

  说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。

  为了解决这个问题,InnoDB引入了B+树。如下图所示,B+树是一棵倒过来的树:

  B+树的特点包括:

  • 最底层的节点叫作叶子节点,用来存放数据;
  • 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;
  • 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
  • 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。

  因此,InnoDB使用B+树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个

  InnoDB会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键(如果没有主键,就选择第一个不包含NULL值的唯一列)。上图方框中的数字代表了索引键的值,对聚簇索引而言一般就是主键。

  我们再看看B+树如何实现快速查找主键。比如,我们要搜索PK=4的数据,通过根节点中的索引可以知道数据在第一个记录指向的2号页中,通过2号页的索引又可以知道数据在5号页,5号页就是实际的数据页,然后再通过二分法查找页目录马上可以找到记录的指针。

  为了实现非主键字段的快速搜索,就引出了二级索引,也叫做非聚簇索引、辅助索引。二级索引,也是利用的B+树的数据结构,如下图所示:

  这次二级索引的叶子节点中保存的不是实际数据,而是主键值,获得主键值后去聚簇索引中获得数据行。这个过程就叫做回表。

  举个例子,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照顺序形成链表。如果我们要搜索用户名为b的数据,经过两次定位可以得出在#5数据页中,查出所有的主键为7和6,再拿着这两个主键继续使用聚簇索引进行两次回表得到完整数据。

  考虑额外创建二级索引的代价

  创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。接下来,我就与你仔细分析一下吧。

  首先是维护代价。创建N个二级索引,就需要再创建N棵B+树,新增数据时不仅要修改聚簇索引,还需要修改这N个二级索引。

  我们通过实验测试一下创建索引的代价。假设有一个person表,有主键ID,以及name、score、create_time三个字段:

  CREATE TABLE `person` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `score` int(11) NOT NULL, `create_time` timestamp NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

  通过下面的存储过程循环创建10万条测试数据,我的机器的耗时是140秒(本文的例子均在MySQL 5.7.26中执行):

  CREATE DEFINER=`root`@`%` PROCEDURE `insert_person`()begin declare c_id integer default 1; while c_id<=100000 do insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second)); set c_id=c_id+1; end while;end

  如果再创建两个索引,一个是name和score构成的联合索引,另一个是单一列create_time的索引,那么创建10万条记录的耗时提高到154秒:

  KEY `name_score` (`name`,`score`) USING BTREE,KEY `create_time` (`create_time`) USING BTREE

  这里,我再额外提一下,页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。页分裂和合并,都会有IO代价,并且可能在操作过程中产生死锁。

  你可以查看这个文档,以进一步了解如何设置合理的合并阈值,来平衡页的空闲率和因为再次页分裂产生的代价。

  其次是空间代价。虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间。比如,person表创建了两个索引后,使用下面的SQL查看数据和索引占用的磁盘:

  SELECT DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME='person'

  结果显示,数据本身只占用了4.7M,而索引占用了8.4M。

  最后是回表的代价。二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能得到我们要的数据。比如,使用SELECT * 按照name字段查询用户,使用EXPLAIN查看执行计划:

  EXPLAIN SELECT * FROM person WHERE NAME='name1'

  执行计划如下,可以发现:

  • key字段代表实际走的是哪个索引,其值是name_score,说明走的是name_score这个索引。
  • type字段代表了访问表的方式,其值ref说明是二级索引等值匹配,符合我们的查询。

  把SQL中的*修改为NAME和SCORE,也就是SELECT name_score联合索引包含的两列:

  EXPLAIN SELECT NAME,SCORE FROM person WHERE NAME='name1'

  再来看看执行计划:

  可以看到,Extra列多了一行Using index的提示,证明这次查询直接查的是二级索引,免去了回表。

  原因很简单,联合索引中其实保存了多个索引列的值,对于页中的记录先按照字段1排序,如果相同再按照字段2排序,如图所示:

  图中,叶子节点每一条记录的第一和第二个方块是索引列的数据,第三个方块是记录的主键。如果我们需要查询的是索引列索引或联合索引能覆盖的数据,那么查询索引本身已经“覆盖”了需要的数据,不再需要回表查询。因此,这种情况也叫做索引覆盖。我会在最后一小节介绍如何查看不同查询的成本,和你一起看看索引覆盖和索引查询后回表的代价差异。

  最后,我和你总结下关于索引开销的最佳实践吧。

  第一,无需一开始就建立索引,可以等到业务场景明确后,或者是数据量超过1万、查询变慢后,再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用EXPLAIN命令,确认查询是否可以使用索引。我会在下一小节展开说明。

  第二,尽量索引轻量级的字段,比如能索引int字段就不要索引varchar字段。索引字段也可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用Elasticsearch等专门用于文本搜索的索引数据库。

  第三,尽量不要在SQL语句中SELECT *,而是SELECT必要的字段,甚至可以考虑使用联合索引来包含我们要搜索的字段,既能实现索引加速,又可以避免回表的开销。

  不是所有针对索引列的查询都能用上索引

  在上一个案例中,我创建了一个name+score的联合索引,仅搜索name时就能够用上这个联合索引。这就引出两个问题:

  • 是不是建了索引一定可以用上?
  • 怎么选择创建联合索引还是多个独立索引?

  首先,我们通过几个案例来分析一下索引失效的情况。

  第一,索引只能匹配列前缀。比如下面的LIKE语句,搜索name后缀为name123的用户无法走索引,执行计划的type=ALL代表了全表扫描:

  EXPLAIN SELECT * FROM person WHERE NAME LIKE '%name123' LIMIT 100

  把百分号放到后面走前缀匹配,type=range表示走索引扫描,key=name_score看到实际走了name_score索引:

  EXPLAIN SELECT * FROM person WHERE NAME LIKE 'name123%' LIMIT 100

  原因很简单,索引B+树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来。

  第二,条件涉及函数操作无法走索引。比如搜索条件用到了LENGTH函数,肯定无法走索引:

  EXPLAIN SELECT * FROM person WHERE LENGTH(NAME)=7

  同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。

  第三,联合索引只能匹配左边的列。也就是说,虽然对name和score建了联合索引,但是仅按照score列搜索无法走索引:

  EXPLAIN SELECT * FROM person WHERE SCORE>45678

  原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。尝试把搜索条件加入name列,可以看到走了name_score索引:

  EXPLAIN SELECT * FROM person WHERE SCORE>45678 AND NAME LIKE 'NAME45%'

  需要注意的是,因为有查询优化器,所以name作为WHERE子句的第几个条件并不是很重要。

  现在回到最开始的两个问题。

  • 是不是建了索引一定可以用上?并不是,只有当查询能符合索引存储的实际结构时,才能用上。这里,我只给出了三个肯定用不上索引的反例。其实,有的时候即使可以走索引,MySQL也不一定会选择使用索引。我会在下一小节展开这一点。
  • 怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜索,那么可以考虑针对这几个字段建立联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引保存了不必要字段也有成本。
数据库基于成本决定是否走索引

  通过前面的案例,我们可以看到,查询数据可以直接在聚簇索引上进行全表扫描,也可以走二级索引扫描后到聚簇索引回表。看到这里,你不禁要问了,MySQL到底是怎么确定走哪种方案的呢。

  其实,MySQL在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。

  这里的成本,包括IO成本和CPU成本:

  • IO成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的IO成本常数是1(也就是读取1个页成本是1)。
  • CPU成本,是检测数据是否满足条件和排序等CPU操作的成本。默认情况下,检测记录的成本是0.2。

  基于此,我们分析下全表扫描的成本。

  全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。那么,要计算全表扫描的代价需要两个信息:

  • 聚簇索引占用的页面数,用来计算读取数据的IO成本;
  • 表中的记录数,用来计算搜索的CPU成本。

  那么,MySQL是实时统计这些信息的吗?其实并不是,MySQL维护了表的统计信息,可以使用下面的命令查看:

  SHOW TABLE STATUS LIKE 'person'

  输出如下:

  可以看到:

  • 总行数是100086行(之前EXPLAIN时,也看到rows为100086)。你可能说,person表不是有10万行记录吗,为什么这里多了86行?其实,MySQL的统计信息是一个估算,其统计方式比较复杂我就不再展开了。但不妨碍我们根据这个值估算CPU成本,是100086*0.2=20017左右。
  • 数据长度是4734976字节。对于InnoDB来说,这就是聚簇索引占用的空间,等于聚簇索引的页面数量*每个页面的大小。InnoDB每个页面的大小是16KB,大概计算出页面数量是289,因此IO成本是289左右。

  所以,全表扫描的总成本是20306左右。

  接下来,我还是用person表这个例子,和你分析下MySQL如何基于成本来制定执行计划。现在,我要用下面的SQL查询name>‘name84059’ AND create_time>‘2020-01-24 05:00:00’

  EXPLAIN SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00'

  其执行计划是全表扫描:

  只要把create_time条件中的5点改为6点就变为走索引了,并且走的是create_time索引而不是name_score联合索引:

  我们可以得到两个结论:

  • MySQL选择索引,并不是按照WHERE条件中列的顺序进行的;
  • 即便列有索引,甚至有多个可能的索引方案,MySQL也可能不走索引。

  其原因就是,MySQL并不是猜拳决定是否走索引的,而是根据成本来判断的。虽然表的统计信息不完全准确,但足够用于策略的判断了。

  不过,有时会因为统计信息的不准确或成本估算的问题,实际开销会和MySQL统计出来的差距较大,导致MySQL选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了。比如,像这样强制走name_score索引:

  EXPLAIN SELECT * FROM person FORCE INDEX(name_score) WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00'

  我们介绍了MySQL会根据成本选择执行计划,也通过EXPLAIN知道了优化器最终会选择怎样的执行计划,但MySQL如何制定执行计划始终是一个黑盒。那么,有没有什么办法可以了解各种执行计划的成本,以及MySQL做出选择的依据呢?

  在MySQL 5.6及之后的版本中,我们可以使用optimizer trace功能查看优化器生成执行计划的整个过程。有了这个功能,我们不仅可以了解优化器的选择过程,更可以了解每一个执行环节的成本,然后依靠这些信息进一步优化查询。

  如下代码所示,打开optimizer_trace后,再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了,最后可以关闭optimizer_trace功能:

  SET optimizer_trace="enabled=on";SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';SELECT * FROM information_schema.OPTIMIZER_TRACE;SET optimizer_trace="enabled=off";

  对于按照create_time>'2020-01-24 05:00:00’条件走全表扫描的SQL,我从OPTIMIZER_TRACE的执行结果中,摘出了几个重要片段来重点分析:

  • 使用name_score对name84059
{"index": "name_score","ranges": ["name84059 < name"],"rows": 25362,"cost": 30435,"chosen": false,"cause": "cost"},
  • 使用create_time进行索引扫描需要扫描23758行,成本是28511,同样因为成本原因没有选择这个方案:
{"index": "create_time","ranges": ["0x5e2a79d0 < create_time"],"rows": 23758,"cost": 28511,"chosen": false,"cause": "cost"}
  • 最终选择了全表扫描方式作为执行计划。可以看到,全表扫描100086条记录的成本是20306,和我们之前计算的一致,显然是小于其他两个方案的28511和30435:
{"considered_execution_plans": [{"table": "`person`","best_access_path": {"considered_access_paths": [{"rows_to_scan": 100086,"access_type": "scan","resulting_rows": 100086,"cost": 20306,"chosen": true}]},"rows_for_plan": 100086,"cost_for_plan": 20306,"chosen": true}]},

  把SQL中的create_time条件从05:00改为06:00,再次分析OPTIMIZER_TRACE可以看到,这次执行计划选择的是走create_time索引。因为是查询更晚时间的数据,走create_time索引需要扫描的行数从23758减少到了16588。这次走这个索引的成本19907小于全表扫描的20306,更小于走name_score索引的30435:

  {"index": "create_time","ranges": ["0x5e2a87e0 < create_time"],"rows": 16588,"cost": 19907,"chosen": true}

  有关optimizer trace的更多信息,你可以参考MySQL的文档。

  重点回顾

  今天,我先和你分析了MySQL InnoDB存储引擎页、聚簇索引和二级索引的结构,然后分析了关于索引的两个误区。

  第一个误区是,考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多越好。索引一定是按需创建的,并且要尽可能确保足够轻量。一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表的成本。

  第二个误区是,不能认为建了索引就一定有效,对于后缀的匹配查询、查询中不包含联合索引的第一列、查询条件涉及函数计算等情况无法使用索引。此外,即使SQL本身符合索引的使用条件,MySQL也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个索引。

  因此,在尝试通过索引进行SQL性能优化的时候,务必通过执行计划或实际的效果来确认索引是否能有效改善性能问题,否则增加了索引不但没解决性能问题,还增加了数据库增删改的负担。如果对EXPLAIN给出的执行计划有疑问的话,你还可以利用optimizer_trace查看详细的执行计划做进一步分析。

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
扮猪吃虎?隐忍四个月,委代总统撕下面具,率几十万大军硬刚美国

扮猪吃虎?隐忍四个月,委代总统撕下面具,率几十万大军硬刚美国

万物知识圈
2026-04-24 12:13:57
中国联通推出全新手机套餐品牌“魔方”,自由组合,阶梯定价,39元起

中国联通推出全新手机套餐品牌“魔方”,自由组合,阶梯定价,39元起

TMT流程审计
2026-04-28 10:35:38
G1山东67-77不敌辽宁 球员评价:4人及格,8人低迷

G1山东67-77不敌辽宁 球员评价:4人及格,8人低迷

篮球资讯达人
2026-04-29 01:45:02
央视《爱情没有神话》开播!看完4集,我断言:这剧又要火向全国

央视《爱情没有神话》开播!看完4集,我断言:这剧又要火向全国

阿纂看事
2026-04-28 20:45:05
150万人香港研究:低剂量他汀吃10年,整体癌症风险降低57%?

150万人香港研究:低剂量他汀吃10年,整体癌症风险降低57%?

医学科普汇
2026-04-28 21:55:07
71.5%!历史性暴跌,以贷养贷的泡沫崩了

71.5%!历史性暴跌,以贷养贷的泡沫崩了

月满大江流
2026-04-16 13:54:38
最大秘密被揭开了,纪念墙照片显示:援俄朝鲜军队:阵亡2000多人

最大秘密被揭开了,纪念墙照片显示:援俄朝鲜军队:阵亡2000多人

兵鉴史
2026-04-28 22:15:08
黄一鸣携女闪闪亮相时装周,时尚造型酷似爷爷王健林

黄一鸣携女闪闪亮相时装周,时尚造型酷似爷爷王健林

铁锤妹妹是只猫
2026-04-28 23:01:11
苦也!北斗导航被干扰,连民用的也全部失效,只能用传统手段航行

苦也!北斗导航被干扰,连民用的也全部失效,只能用传统手段航行

我心纵横天地间
2026-03-05 18:59:08
勇士队格林呼吁所有球馆都应提高球员待遇标准,而这却被忽视了

勇士队格林呼吁所有球馆都应提高球员待遇标准,而这却被忽视了

好火子
2026-04-28 23:58:05
钱学森夫人蒋英的罕见照片,美的不可方物

钱学森夫人蒋英的罕见照片,美的不可方物

黎庶文史
2026-04-25 22:56:52
新冠后遗症的长期侵袭,无数人在不知不觉中深陷困境

新冠后遗症的长期侵袭,无数人在不知不觉中深陷困境

律法刑道
2026-04-01 10:15:47
日本率28国联合国围攻中国,中方当场反击审判已启动

日本率28国联合国围攻中国,中方当场反击审判已启动

元宝课堂
2026-04-29 01:46:02
最新 | 我市通报5起违反中央八项规定精神典型问题

最新 | 我市通报5起违反中央八项规定精神典型问题

天津广播
2026-04-28 16:20:16
恭喜勇士!2年1.36亿!库里续约合同曝光,网友:白菜价

恭喜勇士!2年1.36亿!库里续约合同曝光,网友:白菜价

晚雾空青
2026-04-28 16:49:20
中共中央政治局会议明确“努力稳定房地产市场”,业内:城市更新已成为新的增长动力

中共中央政治局会议明确“努力稳定房地产市场”,业内:城市更新已成为新的增长动力

每日经济新闻
2026-04-28 19:36:18
动真格了,国安部出手,揪出鼓吹躺平的境外势力,评论区意味深长

动真格了,国安部出手,揪出鼓吹躺平的境外势力,评论区意味深长

谭谈社会
2026-04-28 15:10:51
31省份去年常住人口数据公布

31省份去年常住人口数据公布

第一财经资讯
2026-04-28 18:58:16
父亲去世12年,银行突然发来催债短信,欠款滚到2.3万,本金仅7000元,女子当场崩溃!更离谱的是,账单居然查不到?

父亲去世12年,银行突然发来催债短信,欠款滚到2.3万,本金仅7000元,女子当场崩溃!更离谱的是,账单居然查不到?

都市快报橙柿互动
2026-04-27 23:21:16
克罗斯撕破遮羞布!姆巴佩沦为体系牺牲品,巨星堆砌反而拖垮全队

克罗斯撕破遮羞布!姆巴佩沦为体系牺牲品,巨星堆砌反而拖垮全队

体育闲话说
2026-04-28 14:57:47
2026-04-29 04:39:00
Java架构技术
Java架构技术
专注于Java领域优质技术
502文章数 14662关注度
往期回顾 全部

科技要闻

10亿周活目标落空!传OpenAI爆发内部分歧

头条要闻

19岁中国女孩被困缅甸 交20万赎金园区仍未放人

头条要闻

19岁中国女孩被困缅甸 交20万赎金园区仍未放人

体育要闻

魔术黑八活塞,一步之遥?!

娱乐要闻

蔡卓妍官宣结婚,老公比她小10岁

财经要闻

中央政治局会议定调,八大看点速览!

汽车要闻

拒绝疯狂套娃!现代艾尼氪金星长在未来审美点上

态度原创

时尚
教育
房产
健康
军事航空

普通女性春天穿什么好看?这些穿搭值得借鉴,自然舒适

教育要闻

教育部通知,今年9月份开始,上学的规则全变了

房产要闻

红利爆发!海南,冲到全国人口增量第4省!

干细胞治疗烧烫伤三大优势!

军事要闻

德国总理默茨:美国正遭受伊朗领导层的羞辱

无障碍浏览 进入关怀版