当前位置 博文首页 > JavaEdge全是干货的技术号:数据库索引,真的越建越好吗?

    JavaEdge全是干货的技术号:数据库索引,真的越建越好吗?

    作者:[db:作者] 时间:2021-07-21 09:43

    索引是提高关系型数据库查询性能的利器,但其并非银弹,必须精通其原理,才能发挥奇效。

    InnoDB底层是如何存储数据的?

    MySQL把数据存储和查询操作抽象成了存储引擎。MySQL支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为需要事务,所以InnoDB最常用。
    为减少磁盘随机读取次数,InnoDB采用页而非行的粒度保存数据,即数据被分成若干页,以页为单位保存在磁盘。InnoDB的页大小默认16K。

    • 各数据页形成双向链表

    • 每个数据页中的记录按主键顺序形成单链表

    • 每一个数据页中有一个页目录,方便按主键查询记录

    • 数据页结构

    页目录通过一个个槽把记录分成不同组。记录中最前面的小矩形数字,代表当前组的记录条数。
    最小和最大的槽指向2个特殊的伪记录。

    0 =》 最小记录
    1 =4
    2 =8
    3 =12
    4 =16
    5 =20
    6 =》 最大记录
    

    有了槽,按主键搜索页内记录时,就能用二分查找,而无需从最小记录遍历整个页的记录链表。

    比如要搜索主键(PK)=15的记录:

    • 先二分计算得槽中间位(0+6)/2=3,指向记录12<15,所以从槽3后继续搜索
    • 再二分:(3+6)/2=4.5取整4,槽4对应记录16>15,所以记录在槽3
    • 再从槽3指向的12号记录开始向下搜索3次,定位到15号记录

    聚簇索引和二级索引

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

    为了解决这个问题,InnoDB引入B+树

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

    因此,InnoDB使用B+树,既可以保存实际数据,也可加速数据搜索,这就是聚簇索引。

    如果把上图叶子节点下面方块中的省略号看作实际数据,那么它就是聚簇索引的示意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个。

    InnoDB会自动使用主键(唯一定义一条记录的单或多个字段)作为聚簇索引的索引键(若无主键,则选择第一个不包含NULL值的唯一列)。方框数字代表索引键的值,对聚簇索引,一般就是主键。

    B+树如何快速查找主键

    比如搜索PK=4数据,通过根节点中的索引可知数据在第一个记录指向的2号页,通过2号页的索引又可知道数据在5号页,5号页就是实际数据页,再通过二分查找页目录马上可以找到记录的指针。

    为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。非聚簇索引也是B+树,如下:

    非聚簇索引的叶子节点保存的不是实际数据,而是主键。
    获得主键值后去聚簇索引获得数据行,就是回表。

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

    额外创建二级索引的代价

    维护代价

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

    假设有如下表:

    通过如下存储过程创建10万条测试数据,约140s。

    再创建两个索引:

    则创建10万条记录的耗时提高到154s。

    页中的记录都是按照索引值从小到大的顺序存放的:

    • 新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂
    • 若删除了许多数据使得页很空闲,就需要页合并

    页分裂和合并,都会有I/O代价,且过程中可能产生死锁。

    空间代价

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

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

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

    回表

    二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能拿到想要的数据。
    示例如下:

    key=person_name_score_index,表明走的是person_name_score_index索引。
    type=ref,表明是二级索引的等值匹配,符合预期

    再看如下SQL的执行计划:

    Extra列多了一行Using index,说明直接查的二级索引,没有回表。
    联合索引保存了多个索引列的值,对于页中的记录先按照字段1排序,若相同再按照字段2排序,如下:

    图中叶子节点每一条记录的第1、2个方块是索引列的数据,第三个方块是记录的主键。若查询的是索引列索引或联合索引能覆盖的数据,则查询索引本身已经“覆盖”了需要的数据,无需再回表。这种情况也叫索引覆盖

    索引开销的最佳实践

    • 无需一开始就建立索引,可等到场景明确或数据量超过1w、查询变慢,再针对需要查询、排序或分组的字段创建索引。创建索引后可使用EXPLAIN确认查询是否可以使用索引。
    • 尽量索引轻量级的字段,比如能索引int字段就不要索引varchar字段。索引字段也可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用Elasticsearch等专门用于文本搜索的索引数据库
    • 禁止SELECT *,而是SELECT必须字段,甚至可以考虑使用联合索引包含我们要搜索的字段,既能实现索引加速,又可避免回表。

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

    • 是不是建了索引一定可以用上?
    • 到底是创建联合索引还是多个独立索引?

    索引失效场景

    索引只能匹配列前缀

    LIKE语句查询name后缀为name123的用户,type=ALL全表扫描

    把百分号放到后面走前缀匹配:

    • type=range索引扫描
    • key=person_name_score_index走person_name_score_index索引

    索引中行数据按索引值排序,只能根据前缀进行比较。

    若非要按后缀查询也能走索引,并且永远只是按后缀查询,可以把数据反过来存,用时再倒过来。

    条件涉及函数操作无法走索引

    比如查询条件用到了LENGTH函数,肯定无法走索引,type=ALL全表扫描

    同理,索引保存的是索引列的原始值,而非经过函数计算后的值。若需要针对函数调用还能走索引,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。

    联合索引只能匹配左边的列

    虽然对name和score建了联合索引,但仅按score列查询无法走索引

    因为在联合索引情况下,数据按照索引第一列排序,第一列数据相同时才会按第二列排序。若想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。若仅按第二列搜索,肯定无法走索引。

    • 尝试把查询条件加入name列,可见走了person_name_score_index索引

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

    现在回答一开始的问题:

    • 是不是建了索引一定可以用上?
      并不,只有当查询能符合索引存储的实际结构时,才能用上。刚才几个示例都用不上索引。
    • 联合索引 or 多个独立索引?
      若你的查询条件经常会使用多个字段,则考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。若只会查询单个字段,考虑建单独的索引,毕竟联合索引保存了不必要字段也有成本。

    数据库基于成本决定是否走索引

    查询数据可直接在聚簇索引上进行全表扫描,也可走二级索引扫描后到聚簇索引回表。

    MySQL如何确定走哪个方案?
    MySQL在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。
    包括IO成本和CPU成本:

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

    全表扫描成本

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

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

    MySQL是实时统计的这些信息吗?
    不是的,MySQL维护了表的统计信息,可使用命令:

    可见总行数100147行。里表不是只有10w行记录吗,为啥这里还多了147行?
    因为MySQL的统计信息只是个估算。现在我们估算下CPU成本:

    100147*0.2=20030
    

    数据长度是5783552B。对于InnoDB,这就是聚簇索引占用空间,等于聚簇索引的页面数量 * 每个页面的大小。InnoDB每个页16K,大概计算出页面数量是353,所以I/O成本是353。

    综上,全表扫描总成本约20383。

    MySQL如何基于成本制定执行计划

    现在,我要用下面的SQL

    执行计划是全表扫描。但只要把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的执行结果。

    • 使用person_name_score_indexname84059<name条件进行索引扫描需扫描33918行,成本11872,所以未选择该方案
    33918 = 查询二级索引的I/O成本和CPU成本 + 回表查询聚簇索引的I/O成本和CPU成本
    

    • 使用person_create_time_index进行索引扫描需要扫描35606行,成本是12462,也是因为成本未选择该方案
    • 最终选择全表扫描作为执行计划。全表扫描100147条记录的成本是10103,小于其他方案。

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

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

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

    尝试通过索引进行SQL性能优化时,请一定通过执行计划或实际的效果来确认索引是否能有效改善性能问题,否则增加了索引不但没解决性能问题,还增加了数据库增删改的负担。
    对EXPLAIN结果困惑的,还可以利用optimizer_trace查看详细的执行计划,各个索引的成本是多少,看看到底怎么挑选出来的最终方案。

    参考

    • https://dev.mysql.com/doc/internals/en/optimizer-tracing.html
    cs