目录
1.MyISAM的索引实现
2.InnoDB的索引模型
3.B-Tree和B+Tree分析
4.覆盖索引
5.最左前缀原则
6.唯一索引和普通索引的性能差异
索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,本质上就是用于提高读写效率的数据结构,主要有哈希表、有序数组和搜索树。这里主要讲MyISAM和InnoDB两个存储引擎的索引实现方式。
MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址。下图是MyISAM索引的原理图:
上图是一个MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果我们在Col2上建立一个辅助索引,则此索引的结构如下图所示:
同样也是一颗B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。 MyISAM的索引方式也叫做“非聚集”的,之所以这么称呼是为了与InnoDB的聚集索引区分。
这里补充一下磁盘存储原理:索引一般以文件形式存储在磁盘上,索引检索需要磁盘I/O操作。与主存不同,磁盘I/O存在机械运动耗费,因此磁盘I/O的时间消耗是巨大的。磁盘读取数据靠的是机械运动,当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间,最后便是对读取数据的传输。 所以每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分。其中:寻道时间是磁臂移动到指定磁道所需要的时间,主流磁盘一般在5ms以下。旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,也就是说1秒钟能转120次,旋转延迟就是1/120/2 = 4.17ms。传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在零点几毫秒,相对于前两个时间可以忽略不计。磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据局部性原理:当一个数据被用到时,其附近的数据也通常会马上被使用。程序运行期间所需要的数据通常比较集中。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。预读的长度一般为页(page)的整倍数。主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
先从B-Tree分析,根据B-Tree的定义,可知检索一次最多需要访问h-1个节点(根节点常驻内存)。利用磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。B-Tree中一次检索最多需要h-1次I/O(根节点常驻内存),渐进复杂度为O(h)=O(logdN)。一般实际应用中,出度d是非常大的数字,通常超过100,因此h非常小(通常不超过3)。树的出度d越大,深度h就越小,I/O的次数就越少。B+Tree恰恰可以增加出度d的宽度,因为每个节点大小为一个页大小,所以出度的上限取决于节点内key和data的大小,由于B+Tree内节点去掉了data域,因此可以拥有更大的出度,从而拥有更好的性能。
在InnoDB,根据叶子节点的内容,索引类型分为主键索引和非主键索引。主键索引的叶子节点存的是整行数据。在InnoDB里,主键索引也被称为聚簇索引( clustered index)。非主键索引的叶子节点内容是主键的值。在InnoDB里,非主键索引也被称为二级索引( secondary index)。主键查询方式,则只需要搜索ID这棵B+树;普通索引查询方式,则需要先搜索普通索引树,得到ID的值再到ID索引树搜索一次。这个过程称为回表。也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行ID值为700,则只需要在R5的记录后面插入一个新记录。如果新插入的ID值为400,需要逻辑上挪动后面的数据,空出位置。如果R5所在的数据页已经满了,根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约50%。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。所以,InnoDB建议使用自增主键:NOT NULL PRIMARY KEY AUTO_INCREMENT。插入新记录的时候可以不指定ID的值,系统会获取当前ID最大值加1作为下一条记录的ID值。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。除了考虑性能外,从存储空间的角度来看自增主键的优点。假设表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型( bigint)则是8个字节。 显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。但是,对于典型的KV场景。由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题,就必须用业务逻辑字段做主键。
先以下表为例执行select * from Twhere k between 3 and 5的步骤:
create table T (
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;
insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'jh');
1. 在k索引树上找到k=3的记录, 取得 ID = 300;2. 再到ID索引树查到ID=300对应的R3;3. 在k索引树取下一个值k=5, 取得ID=500;4. 再回到ID索引树查到ID=500对应的R4;5. 在k索引树取下一个值k=6, 不满足条件, 循环结束。在这个过程中, 回到主键索引树搜索的过程, 我们称为回表。 可以看到, 这个查询过程读了k索引树的3条记录( 步骤1、 3和5) , 回表了两次( 步骤2和4) 。
如果执行的语句是select ID from Twhere k between 3 and 5, 这时只需要查ID的值, 而ID的值已经在k索引树上了, 因此可以直接提供查询结果, 不需要回表。 也就是说, 在这个查询里面,索引k已经“覆盖了”我们的查询需求, 我们称为覆盖索引。
我们知道, 身份证号是市民的唯一标识。 也就是说, 如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。 而再建立一个( 身份证号、 姓名) 的联合索引, 是不是浪费空间?如果现在有一个高频请求, 要根据市民的身份证号查询他的姓名, 这个联合索引就有意义了。 它可以在这个高频请求上用到覆盖索引, 不再需要回表查整行记录, 减少语句的执行时间。
B+树这种索引结构,可以利用索引的“最左前缀”, 来定位记录。接上面的例子, 如果现在要按照市民的身份证号去查他的家庭地址, 虽然这个查询需求在业务中出现的概率不高,但总不能让它走全表扫描吧? 反过来说, 单独为一个不频繁的请求创建一个( 身份证号, 地址) 的索引又感觉有点浪费。 应该怎么做呢?通过(name,age)联合索引为例,
索引项是按照索引定义里面出现的字段顺序排序的。当你的逻辑需求是查到所有名字是“张三”的人时, 可以快速定位到ID4, 然后向后遍历得到所有需要的结果。如果要查的是所有名字第一个字是“张”的人, SQL语句的条件是"where name like
‘张%’"。 这时, 你也能够用上这个索引, 查找到第一个符合条件的记录是ID3, 然后向后遍历,直到不满足条件为止。可以看到,不只是索引的全部定义, 只要满足最左前缀, 就可以利用索引来加速检索。 这个最左前缀可以是联合索引的最左N个字段, 也可以是字符串索引的最左M个字符。讨论一个问题: 在建立联合索引的时候, 如何安排索引内的字段顺序。这里我们的评估标准是, 索引的复用能力。因为可以支持最左前缀, 所以当已经有了(a,b)这个联合索引后, 一般就不需要单独在a上建立索引了。 因此, 第一原则是, 如果通过调整顺序, 可以少维护一个索引, 那么这个顺序往往就是需要优先考虑采用的。所以现在这段开头的问题里, 我们要为高频请求创建(身份证号, 姓名) 这个联合索引, 并用这个索引支持“根据身份证号查询地址”的需求。那么, 如果既有联合查询, 又有基于a、 b各自的查询呢? 查询条件里面只有b的语句, 是无法使用(a,b)这个联合索引的, 这时候你不得不维护另外一个索引, 也就是说你需要同时维护(a,b)、(b) 这两个索引。这时候, 我们要考虑的原则就是空间了。 比如上面这个市民表的情况, name字段是比age字段大的 , 那我就建议你创建一个( name,age)的联合索引和一个(age)的单字段索引。索引下推:以市民表的联合索引( name, age) 为例。 如果现在有一个需求: 检索出表中“名字第一个字是张, 而且年龄是10岁的所有男孩”。 那么, SQL语句是这么写的:
select * from tuser where name like '张%' and age=10 and ismale=1;
在MySQL 5.6之前, 只能从ID3开始一个个回表。 到主键索引上找出数据行, 再对比字段值。而MySQL 5.6 引入的索引下推优化( indexcondition pushdown), 可以在索引遍历过程中, 对索引中包含的字段先做判断, 直接过滤掉不满足条件的记录, 减少回表次数。即没有优化的前缀索引是不会判断联合索引的其他字段。
先介绍下change buffer:当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中, 这样就不需要从磁盘中读入这个数据页了。 在下次查询需要访问这个数据页的时候,将数据页读入内存, 然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。需要说明的是, change buffer实际上是可以持久化的数据。 也就是说,change buffer在内存中有拷贝,也会被写入到磁盘上。将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。除了访问这个数据页会触发merge外,系统有后台线程会定期merge。在数据库正常关闭( shutdown) 的过程中,也会执行merge操作。显然, 如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用buffer pool的, 所以这种方式还能够避免占用内存,提高内存利用率
对于唯一索引来说, 所有的更新操作都要先判断这个操作是否违反唯一性约束。 比如, 要插入(4,400)这个记录, 就要先判断现在表中是否已经存在k=4的记录, 而这必须要将数据页读入内存才能判断。 如果都已经读入到内存了, 那直接更新内存会更快, 就没必要使用change buffer了。因此, 唯一索引的更新就不能使用change buffer, 实际上也只有普通索引可以使用。
如果要在这张表中插入一个 新记录(4,400)的话, InnoDB的处理流程如下:
第一种情况是, 这个记录要更新的目标页在内存中。 这时, InnoDB的处理流程如下:
对于唯一索引来说, 找到3和5之间的位置, 判断到没有冲突, 插入这个值, 语句执行结束;
对于普通索引来说, 找到3和5之间的位置, 插入这个值, 语句执行结束。
这样看来, 普通索引和唯一索引对更新语句性能影响的差别, 只是一个判断, 只会耗费微小的CPU时间。
第二种情况是, 这个记录要更新的目标页不在内存中。 这时, InnoDB的处理流程如下:
对于唯一索引来说, 需要将数据页读入内存, 判断到没有冲突, 插入这个值, 语句执行结束;
对于普通索引来说, 则是将更新记录在change buffer, 语句执行就结束了。
将数据从磁盘读入内存涉及随机IO的访问, 是数据库里面成本最高的操作之一。 change buffer因为减少了随机磁盘访问, 所以对更新性能的提升是会很明显的。
change buffer提高性能并不是绝对的:对于写多读少的业务来说, 页面在写完以后马上被访问到的概率比较小, 此时change
buffer的使用效果最好。 这种业务模型常见的就是账单类、 日志类的系统。反过来, 假设一个业务的更新模式是写入之后马上会做查询, 那么即使满足了条件, 将更新先记录在change buffer, 但之后由于马上要访问这个数据页, 会立即触发merge过程。 这样随机访问IO的次数不会减少, 反而增加了change buffer的维护代价。 所以, 对于这种业务模式来说, change buffer反而起到了副作用。
普通索引和唯一索引应该怎么选择。 其实, 这两类索引在查询能力上是没差别的, 主要考虑的是对更新性能的影响。 所以, 我建议你尽量选择普通索引。如果所有的更新后面, 都马上伴随着对这个记录的查询, 那么你应该关闭change buffer。 而在其他情况下, change buffer都能提升更新性能。在实际使用中, 你会发现, 普通索引和change buffer的配合使用, 对于数据量大的表的更新优化还是很明显的。特别地, 在使用机械硬盘时, change buffer这个机制的收效是非常显著的。 所以, 当你有一个类似“历史数据”的库, 并且出于成本考虑用的是机械硬盘时, 那你应该特别关注这些表里的索引, 尽量使用普通索引, 然后把change buffer 尽量开大, 以确保这个“历史数据”表的数据写入速度。
change buffer 和 redo log的区别: redo log 主要节省的是随 机写磁盘的IO消耗( 转成顺序写) , 而change buffer主要节省的则是随机读磁盘的IO消耗。前者主要是把多个更新操作先放入log里然后再通过后台集中写入磁盘(多次随机写变成顺序写),两者是协同工作的。
insert into t(id,k) values(id1,k1),(id2,k2);
这条更新语句, 你会发现它涉及了四个部分:内存、redo log( ib_log_fileX) 、数据表空间( t.ibd) 、系统表空间(ibdata1) 。这条更新语句做了如下的操作( 按照图中的数字顺序):1. Page 1在内存中, 直接更新内存;2. Page 2没有在内存中, 就在内存的change buffer区域, 记录下“我要往Page 2插入一行”这个信息;3. 将上述两个动作记入redo log中( 图中3和4)。做完上面这些, 事务就可以完成了。 所以, 你会看到, 执行这条更新语句的成本很低, 就是写了两处内存, 然后写了一处磁盘( 两次操作合在一起写了一次磁盘) , 而且还是顺序写的。同时, 图中的两个虚线箭头, 是后台操作, 不影响更新的响应时间。
现在要执行 select * from t where k in (k1, k2)。
1. 读Page 1的时候, 直接从内存返回。 2. 要读Page 2的时候, 需要把Page 2从磁盘读入内存中, 然后应用change buffer里面的操作日志, 生成一个正确的版本并返回结果。