浅析InnoDB索引结构

InnoDB表的索引有哪些特性,以及索引组织结构是怎样的

Posted in MySQL onApril 05, 2021

1、InnoDB聚集索引特点

我们知道,InnoDB引擎的聚集索引组织表,必然会有一个聚集索引。

行数据(row data)存储在聚集索引的叶子节点(除了发生overflow的列,参见 ,后面简称 “前置文”),并且其存储的相对顺序取决于聚集索引的顺序。这里说相对顺序而不是物理顺序,是因为叶子节点数据页中,行数据的物理顺序和相对顺序可能并不是一致的,放在后面会讲。

InnoDB聚集索引的选择先后顺序是这样的:

  1. 如果有显式定义的主键(PRIMARY KEY),则会选择该主键作为聚集索引
  2. 否则,选择第一个所有列都不允许为NULL的唯一索引
  3. 若前两者都没有,则InnoDB会选择内置的DB_ROW_ID作为聚集索引,命名为GEN_CLUST_INDEX

特别提醒: DB_ROW_ID占用6个字节,每次自增,且是整个实例内全局分配。也就是说,当前实例如果有多个表都采用了内置的DB_ROW_ID作为聚集索引,则在这些表插入新数据时,他们的内置DB_ROW_ID值并不是连续的,而是跳跃的。像下面这样:

t1表的ROW_ID:1、3、7、10
t2表的ROW_ID:2、4、5、6、8、9

2、InnoDB索引结构

InnoDB默认的索引数据结构采用B+树(空间索引采用R树),索引数据存储在叶子节点。

InnoDB的基本I/O存储单位是数据页(page),一个page默认是16KB。我们在 前置文 说过,每个page默认会预留1/16空闲空间用于后续数据“变长”更新所需,因此在最理想的顺序插入状态下,其产生的碎片也最少,这时候差不多能填满15/16的page空间。如果是随机写入的话,则page空间利用率大概是1/2 ~ 15/16。

当 row_format = DYNAMIC|COMPRESSED 时,索引最多长度为 3072字节,当 row_format = REDUNDANT|COMPACT 时,索引最大长度为 767字节。当page size不是默认的16KB时,最大索引长度限制也会跟着发生变化。

我们接下来分别验证关于InnoDB索引的基本结构特点。

首先创建如下测试表:

[root@yejr.me] [innodb]> CREATE TABLE `t1` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `c1` int(10) unsigned NOT NULL DEFAULT '0',
  `c2` varchar(100) NOT NULL,
  `c3` varchar(100) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `c1` (`c1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
用下面的方法写入10条测试数据:
set @uuid1=uuid(); set @uuid2=uuid();
insert into t1 select 0, round(rand()*1024),
                @uuid1, concat(@uuid1, @uuid2);
看下 t1 表的整体结构:
# 用innodb_ruby工具查看
[root@yejr.me]# innodb_space -s ibdata1 -T innodb/t1 space-indexes
id    name       root   fseg        fseg_id   used    allocated   fill_factor
238   PRIMARY    3      internal    1         1       1           100.00%
238   PRIMARY    3      leaf        2         0       0           0.00%
239   c1         4      internal    3         1       1           100.00%
239   c1         4      leaf        4         0       0           0.0

# 用innblock工具查看
[root@yejr.me]# innblock innodb/t1.ibd scan 16
...
===INDEX_ID:238
level0 total block is (1)
block_no:     3,level:   0|*|
===INDEX_ID:239
level0 total block is (1)
block_no:     4,level:   0|*|
可以看到
索引ID 索引类型 根节点page no 索引层高
238 主键索引(聚集索引) 3 1
239 辅助索引 4 1

3、InnoDB索引特点验证

3.1 特点1:聚集索引叶子节点存储整行数据

先扫描第3个page,截取其中第一条物理记录的内容:
[root@yejr.me]# innodb_space -s ibdata1 -T innodb/t1 -p 3 page-dump
...
records:
{:format=>:compact,
 :offset=>127,
 :header=>
  {:next=>263,
   :type=>:conventional,
   :heap_number=>2,
   :n_owned=>0,
   :min_rec=>false,
   :deleted=>false,
   :nulls=>[],
   :lengths=>{"c2"=>36, "c3"=>72},
   :externs=>[],
   :length=>7},
 :next=>263,
 :type=>:clustered,
 #第一条物理记录,id=1
 :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>1}],
 :row=>
  [{:name=>"c1", :type=>"INT UNSIGNED", :value=>777},
   {:name=>"c2",
    :type=>"VARCHAR(400)",
    :value=>"a1c1a7c7-bda5-11e9-8476-0050568bba82"},
   {:name=>"c3",
    :type=>"VARCHAR(400)",
    :value=>
     "a1c1a7c7-bda5-11e9-8476-0050568bba82a1c1aec5-bda5-11e9-8476-0050568bba82"}],
 :sys=>
  [{:name=>"DB_TRX_ID", :type=>"TRX_ID", :value=>10950},
   {:name=>"DB_ROLL_PTR",
    :type=>"ROLL_PTR",
    :value=>
     {:is_insert=>true,
      :rseg_id=>119,
      :undo_log=>{:page=>469, :offset=>272}}}],
 :length=>129,
 :transaction_id=>10950,
 :roll_pointer=>
  {:is_insert=>true, :rseg_id=>119, :undo_log=>{:page=>469, :offset=>272}}}

很明显,的确是存储了整条数据的内容。

聚集索引树的键值(key)是主键索引值(i=10),聚集索引节点值(value)是其他非聚集索引列(c1,c2,c3)以及隐含列(DB_TRX_ID、DB_ROLL_PTR)。

优化建议1:尽量不要存储大对象数据,使得每个叶子节点都能存储更多数据,降低碎片率,提高buffer pool利用率。此外也能尽量避免发生overflow

3.2 特点2:聚集索引非叶子节点存储指向子节点的指针

对上面的测试表继续写入新数据,直到聚集索引树从一层分裂成两层。

我们根据旧文 InnoDB表聚集索引层高什么时候发生变化 里的计算方式,推算出来预计一个叶子节点最多可存储111条记录,因此在插入第112条记录时,就会从一层高度分裂成两层高度。经过实测,也的确是如此。

[root@yejr.me] [innodb]>select count(*) from t1;
+----------+
| count(*) |
+----------+
|      112 |
+----------+

[root@yejr.me]# innblock innodb/t1.ibd scan 16
...
===INDEX_ID:238
level1 total block is (1)
block_no:     3,level:   1|*|
level0 total block is (2)
block_no:     5,level:   0|*|block_no:     6,level:   0|*|
...

此时可以看到根节点依旧是pageno=3,而叶子节点变成了[5, 6]两个page。由此可知,根节点上应该只有两条物理记录,存储着分别指向pageno=[5, 6]这两个page的指针。

我们解析下3号page,看看它的具体结构:

[root@yejr.me]# innodb_space -s ibdata1 -T innodb/t1 -p 3 page-dump
...
records:
{:format=>:compact,
 :offset=>125,
 :header=>
  {:next=>138,
   :type=>:node_pointer,
   :heap_number=>2,
   :n_owned=>0,
   :min_rec=>true, #第一条记录是min_key
   :deleted=>false,
   :nulls=>[],
   :lengths=>{},
   :externs=>[],
   :length=>5},
 :next=>138,
 :type=>:clustered,
 #第一条记录,只存储key值
 :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>1}],
 :row=>[],
 :sys=>[],
 :child_page_number=>5, #value值是指向的叶子节点pageno=5
 :length=>8} #整条记录消耗8字节,除去key值4字节外,指针也需要4字节

{:format=>:compact,
 :offset=>138,
 :header=>
  {:next=>112,
   :type=>:node_pointer,
   :heap_number=>3,
   :n_owned=>0,
   :min_rec=>false,
   :deleted=>false,
   :nulls=>[],
   :lengths=>{},
   :externs=>[],
   :length=>5},
 :next=>112,
 :type=>:clustered,
 #第二条记录,只存储key值
 :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>56}],
 :row=>[],
 :sys=>[],
 :child_page_number=>6, #value值是指向的叶子节点pageno=6
 :length=>8}

优化建议2: 索引列数据长度越小越好,这样索引树存储效率越高,在非叶子节点能存储越多数据,延缓索引树层高分裂的速度,平均搜索效率更高

3.3 特点3:辅助索引同时会存储主键索引列值

在辅助索引中,总是同时会存储主键索引(或者说聚集索引)的列值,其作用就是在对辅助索引扫描时,可以从叶子节点直接得到对应的聚集索引值,并可根据该值回表查询获取行数据(如果需要回表查询的话)。这个特性也被称为Index Extensions(5.6版本之后的优化器新特性,详见 Use of Index Extensions)。

此外,在辅助索引的非叶子节点中,索引记录的key值是索引定义的列值,而对应的value值则是聚集索引列值(简称PKV)。如果辅助索引定义时已经包含了部分聚集索引列,则索引记录的value值是未被包含的余下的聚集索引列值。

创建如下测试表:

CREATE TABLE `t3` (
  `a` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `b` int(10) unsigned NOT NULL DEFAULT '0',
  `c` varchar(20) NOT NULL DEFAULT '',
  `d` varchar(20) NOT NULL DEFAULT '',
  `e` varchar(20) NOT NULL DEFAULT '',
  PRIMARY KEY (`a`,`b`),
  KEY `k1` (`c`,`b`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
随机插入一些测试数据:
# 调用shell脚本写入500条数据
[root@yejr.me]# cat insert.sh
#!/bin/bash
. ~/.bash_profile
cd /data/perconad
i=1
max=500
while [ $i -le $max ]
do
 mysql -Smysql.sock -e "insert ignore into t3 select
    rand()*1024, rand()*1024, left(md5(uuid()),20) ,
    left(uuid(),20), left(uuid(),20);" innodb
 i=`expr $i + 1`
done

# 实际写入498条数据(其中有2条主键冲突失败)
[root@yejr.me] [innodb]>select count(*) from t3;
+----------+
| count(*) |
+----------+
|      498 |
+----------+
解析数据结构:
# 主键
[root@test1 perconad]# innodb_space -s ibdata1 -T innodb/t2 space-indexes
id    name     root  fseg        fseg_id   used   allocated   fill_factor
245   PRIMARY  3     internal    1         1      1           100.00%
245   PRIMARY  3     leaf        2         5      5           100.00%
246   k1       4     internal    3         1      1           100.00%
246   k1       4     leaf        4         2      2           1

[root@yejr.me]# innodb_space -s ibdata1 -T innodb/t2 -p 4 page-dump
...
records:
{:format=>:compact,
 :offset=>126,
 :header=>
  {:next=>164,
   :type=>:node_pointer,
   :heap_number=>2,
   :n_owned=>0,
   :min_rec=>true,
   :deleted=>false,
   :nulls=>[],
   :lengths=>{"c"=>20},
   :externs=>[],
   :length=>6},
 :next=>164,
 :type=>:secondary,
 :key=>
  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"00a5d42dd56632893b5f"},
   {:name=>"b", :type=>"INT UNSIGNED", :value=>323}],
 :row=>
  [{:name=>"a", :type=>"INT UNSIGNED", :value=>310},
   {:name=>"b", :type=>"INT UNSIGNED", :value=>9}],
   # 此处给解析成b列的值了,实际上是指向叶子节点的指针,即child_page_number=9
   # b列真实值是323
 :sys=>[],
 :child_page_number=>335544345,
 # 此处解析不准确,实际上是下一条记录的record header,共6个字节
 :length=>36}

{:format=>:compact,
 :offset=>164,
 :header=>
  {:next=>112,
   :type=>:node_pointer,
   :heap_number=>3,
   :n_owned=>0,
   :min_rec=>false,
   :deleted=>false,
   :nulls=>[],
   :lengths=>{"c"=>20},
   :externs=>[],
   :length=>6},
 :next=>112,
 :type=>:secondary,
 :key=>
  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"7458824a39892aa77e1a"},
   {:name=>"b", :type=>"INT UNSIGNED", :value=>887}],
 :row=>
  [{:name=>"a", :type=>"INT UNSIGNED", :value=>623},
   {:name=>"b", :type=>"INT UNSIGNED", :value=>10}],
   # 同上,其实是child_page_number=10,而非b列的值
 :sys=>[],
 :child_page_number=>0,
 :length=>36} #数据长度16字节

顺便说下,辅助索引上没存储TRX_ID, ROLL_PTR这些(他们只存储在聚集索引上)。

上面用innodb_ruby工具解析的非叶子节点部分内容不够准确,所以我们用二进制方式打开数据文件二次求证确认:

# 此处也可以用 hexdump 工具
[root@yejr.me]# vim -b path/t3.ibd
...
:%!xxd

# 找到辅助索引所在的那部分数据
0010050: 0002 0272 0000 00e1 0000 0002 01b2 0100  ...r............
0010060: 0200 1b69 6e66 696d 756d 0003 000b 0000  ...infimum......
0010070: 7375 7072 656d 756d 1410 0011 0026 3030  supremum.....&00
0010080: 6135 6434 3264 6435 3636 3332 3839 3362  a5d42dd56632893b
0010090: 3566 0000 0143 0000 0136 0000 0009 1400  5f...C...6......
00100a0: 0019 ffcc 3734 3538 3832 3461 3339 3839  ....7458824a3989
00100b0: 3261 6137 3765 3161 0000 0377 0000 026f  2aa77e1a...w...o
00100c0: 0000 000a 0000 0000 0000 0000 0000 0000  ................

# 参考page物理结构方式进行解析,得到下面的结果
/* 第一条记录 */
1410 0011 0026, record header, 5字节
3030 6135 6434 3264 6435 3636 3332 3839 3362 3566,c='00a5d42dd56632893b5f',20B
0000 0143, b=323, 4B
0000 0136, a=310, 4B
0000 0009, child_pageno=9, 4B

/* 2 */
1400 0019 ffcc, record header
3734 3538 3832 3461 3339 3839 3261 6137 3765 3161, c='7458824a39892aa77e1a'
0000 0377, b=887
0000 026f, a=623
0000 000a, child_pageno=10

现在反过来看,上面用innodb_ruby工具解析出来的page-dump结果应该是这样的才对(我只选取一条记录,请自行对比和之前的不同之处):

{:format=>:compact,
 :offset=>164,
 :header=>
  {:next=>112,
   :type=>:node_pointer,
   :heap_number=>3,
   :n_owned=>0,
   :min_rec=>false,
   :deleted=>false,
   :nulls=>[],
   :lengths=>{"c"=>20},
   :externs=>[],
   :length=>6},
 :next=>112,
 :type=>:secondary,
 :key=>
  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"7458824a39892aa77e1a"},
   {:name=>"b", :type=>"INT UNSIGNED", :value=>887}],
 :row=> [{:name=>"a", :type=>"INT UNSIGNED", :value=>623}],
 :sys=>[],
 :child_page_number=>10,
 :length=>36}

可以看到,的确如前面所说,辅助索引的非叶子节点的value值存储的是聚集索引列值

优化建议3:辅助索引列定义的长度越小越好,定义辅助索引时,没必要显式的加上聚集索引列(5.6版本之后)

3.4 特点4:没有可用的聚集索引列时,会使用内置的ROW_ID作为聚集索引

创建几个像下面这样的表,使其选择内置的ROW_ID作为聚集索引:

[root@yejr.me] [innodb]> CREATE TABLE `tn1` (
  `c1` int(10) unsigned NOT NULL DEFAULT 0,
  `c2` int(10) unsigned NOT NULL DEFAULT 0
) ENGINE=InnoDB;
循环对几个表写数据:
insert into tt1 select 1,1;
insert into tt2 select 1,1;
insert into tt3 select 1,1;
insert into tt1 select 2,2;
insert into tt2 select 2,2;
insert into tt3 select 2,2;

查看 tn1 - tn3 表里的数据(这里由于innodb_ruby工具解析的结果不准确,所以我改用hexdump来分析):

tn1
000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000  ...infimum......
000c070: 7375 7072 656d 756d 0000 1000 2000 0000  supremum.... ...
000c080: 0003 1200 0000 003d f6aa 0000 01d9 0110  .......=........
000c090: 0000 0001 0000 0001 0000 18ff d300 0000  ................
000c0a0: 0003 1500 0000 003d f9ad 0000 01da 0110  .......=........
000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000  ................

tn2
000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000  ...infimum......
000c070: 7375 7072 656d 756d 0000 1000 2000 0000  supremum.... ...
000c080: 0003 1300 0000 003d f7ab 0000 0122 0110  .......=....."..
000c090: 0000 0001 0000 0001 0000 18ff d300 0000  ................
000c0a0: 0003 1600 0000 003d feb0 0000 01db 0110  .......=........
000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000  ................

tn3
000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000  ...infimum......
000c070: 7375 7072 656d 756d 0000 1000 2000 0000  supremum.... ...
000c080: 0003 1400 0000 003d f8ac 0000 0123 0110  .......=.....#..
000c090: 0000 0001 0000 0001 0000 18ff d300 0000  ................
000c0a0: 0003 1700 0000 003e 03b3 0000 012a 0110  .......>.....*..
000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000  ................
其中表示DB_ROW_ID的值分别是:
tn1
0003 12 => (1,1)
0003 15 => (2,2)

tn2
0003 13 => (1,1)
0003 16 => (2,2)

tn3
0003 14 => (1,1)
0003 17 => (2,2)

很明显,内置的DB_ROW_ID的确是在整个实例级别共享自增分配的,而不是每个表独享一个DB_ROW_ID序列

我们可以想象下,如果一个实例中有多个表都用到这个DB_ROW_ID的话,势必会造成并发请求的竞争/等待。此外也可能会造成主从复制环境下,从库上relay log回放时可能会因为数据扫描机制的问题造成严重的复制延迟问题。详情参考 从库数据的查找和参数slave_rows_search_algorithms

优化建议4:自行显示定义可用的聚集索引/主键索引,不要让InnoDB选择内置的DB_ROW_ID作为聚集索引,避免潜在的性能损失

篇幅已经有点大了,本次的浅析工作就先到这里吧,以后再继续。

4、几点总结

最后针对InnoDB引擎表,总结几条建议吧。
  1. 每个表都要有显式主键,最好是自增整型,且没有业务用途
  2. 无论是主键索引,还是辅助索引,都尽可能选择数据类型较小的列
  3. 定义辅助索引时,没必要显式加上主键索引列(针对MySQL 5.6之后)
  4. 行数据越短越好,如果每个列都是固定长的则更好(不是像VARCHAR这样的可变长度类型)
上述测试环境基于Percona Server 5.7.22:
# MySQL的版本是Percona Server 5.7.22-22,我自己下载源码编译的
[root@yejr.me#] mysql -Smysql.sock innodb
...
Server version: 5.7.22-22-log Source distribution
...
[root@yejr.me]> \s
...
Server version:     5.7.22-22-log Source distribution
Enjoy MySQL :)

延伸阅读

  •  
  •  
  • MySQL Manual:Use of Index Extensions

  •  
  • jcole.us:The physical structure of InnoDB index pages

  • jcole.us:B+Tree index structures in InnoDB

  • jcole.us:How does InnoDB behave without a Primary Key?


最后,欢迎扫码订阅《乱弹MySQL》专栏,快人一步获取我最新的MySQL技术分享

MySQL 相关文章推荐
MySQL pt-slave-restart工具的使用简介
Apr 07 MySQL
MySQL的join buffer原理
Apr 29 MySQL
MySQL 分组查询的优化方法
May 12 MySQL
mysql 8.0.24 安装配置方法图文教程
May 12 MySQL
MySQL触发器的使用
May 24 MySQL
mysql timestamp比较查询遇到的坑及解决
Nov 27 MySQL
MySQL慢查询优化解决问题
Mar 17 MySQL
MySQL派生表联表查询实战过程
Mar 20 MySQL
实战 快速定位MySQL的慢SQL
Mar 22 MySQL
Nebula Graph解决风控业务实践
Mar 31 MySQL
mysql使用 not int 子查询隐含陷阱
Apr 12 MySQL
mysql函数之截取字符串的实现
Aug 14 MySQL
MySQL基础(一)
Apr 05 #MySQL
MySQL基础(二)
MySQL学习总结-基础架构概述
MySQL锁机制
MySQL令人咋舌的隐式转换
Apr 05 #MySQL
mysql知识点整理
Apr 05 #MySQL
MySQL入门命令之函数-单行函数-流程控制函数
Apr 05 #MySQL
You might like
Laravel5框架添加自定义辅助函数的方法
2018/08/01 PHP
详解php协程知识点
2018/09/21 PHP
jquery插件jbox使用iframe关闭问题
2009/02/09 Javascript
jQuery 图片切换插件(代码比较少)
2012/05/07 Javascript
jquery 实现checkbox全选,反选,全不选等功能代码(奇数)
2012/10/24 Javascript
扒一扒JavaScript 预解释
2015/01/28 Javascript
探讨JavaScript中的Rest参数和参数默认值
2015/07/29 Javascript
jQuery实现浮动层随浏览器滚动条滚动的方法
2015/09/22 Javascript
Nodejs获取网络数据并生成Excel表格
2020/03/31 NodeJs
Bootstrap编写一个同时适用于PC、平板、手机的登陆页面
2016/06/30 Javascript
浅析Node.js:DNS模块的使用
2016/11/23 Javascript
JS写XSS cookie stealer来窃取密码的步骤详解
2017/11/20 Javascript
深入理解JavaScript 中的执行上下文和执行栈
2018/10/23 Javascript
详解用vue2.x版本+adminLTE开源框架搭建后台应用模版
2019/03/15 Javascript
inquirer.js一个用户与命令行交互的工具详解
2019/05/18 Javascript
vue3.0自定义指令(drectives)知识点总结
2020/12/27 Vue.js
[04:52]第二届DOTA2亚洲邀请赛主赛事第一天比赛集锦:OG娜迦海妖放大配合谜团大中3人
2017/04/02 DOTA
Windows8下安装Python的BeautifulSoup
2015/01/22 Python
Python实现遍历windows所有窗口并输出窗口标题的方法
2015/03/13 Python
Python之父谈Python的未来形式
2016/07/01 Python
Python爬取网页中的图片(搜狗图片)详解
2017/03/23 Python
Python实现将Excel转换成xml的方法示例
2018/08/25 Python
python写程序统计词频的方法
2019/07/29 Python
html5本地存储之localstorage 、本地数据库、sessionStorage简单使用示例
2014/05/08 HTML / CSS
DHC美国官网:日本通信销售第一的化妆品品牌
2017/11/12 全球购物
英国礼品和生活方式品牌:Treat Republic
2020/11/21 全球购物
如何提高JDBC的性能
2013/04/30 面试题
技校毕业生的自我评价
2013/12/27 职场文书
中医专业职业生涯规划书范文
2014/01/04 职场文书
写给爸爸的道歉信
2014/01/15 职场文书
《三袋麦子》教学反思
2014/03/02 职场文书
预备党员2014全国两会学习心得体会
2014/03/10 职场文书
史学专业毕业生求职信
2014/05/09 职场文书
王金山在党的群众路线教育实践活动总结大会上的讲话稿
2014/10/25 职场文书
导游词之沈阳植物园
2019/11/30 职场文书
go:垃圾回收GC触发条件详解
2021/04/24 Golang