目录
开篇之前,首先我们说一下 聚簇索引
聚簇索引
聚簇索引并不是一种单独的索引类型,而是一种数据存储方式。具体的细节依赖于其实现方式,但InnoDB的聚簇索引实际上在同一个结构中保存了 B-Tree 索引和数据行。
当表有聚簇索引时,它的数据行实际上存放在索引的叶子页 (leaf page)中。术语“聚簇”表示数据行和相邻的键值紧凑地存储在一起。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索 。
因为是存储引擎负责实现索引,因此不是所有的存储引擎都支持聚簇索引
聚簇索引的存放记录如下图所示,叶子页包含了数据行的的全部数据,但是节点页只包含了索引列
InnoDB 将通过主键聚集数据,这也就是说上图中的“被索引的列”就是主键列。
如果没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnDB 只聚集在同一个页面中的记录。包含相邻键值的页面可能会相距甚远
聚簇主键可能对性能有帮助,但也可能导致严重的性能问题。所以需要仔细地考虑聚簇索引,尤其是将表的存储引擎从 ImnoDB 改成其他引擎的时候 (反过来也一样)。
优点 :
- 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID 来聚集数据这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘 I/O。
- 数据访问更快。聚簇索引将索引和数据保存在同一个 B-Tree 中,因此从聚簇索引中获取数据通常比在非聚簇索引中查找要快。
- 使用覆盖索引扫描的查询可以直接使用页节点中的主键值
缺点 :
- 聚簇数据最大限度地提高了 I/0 密集型应用的性能,但如果数据部都放在内存中则访问的顺序就没那么重要了,聚簇索引也就没什么优势了。
- 插入速度严重依赖于插入顺序。按照主键的顺序插入是加载数据到InnoDB表中速度最快的方式。但如果不是按照主键顺序加载数据,那么在加载完成后最好使用OPTIMIZETABLE命令重新组织一下表。
- 更新聚簇索引列的代价很高,因为会强制InnoDB 将每个被更新的行移动到新的位置。
- 基于聚簇索引的表在插入新行,或者主键被更新导致需要移动行的时候,可能面临“页分裂(page split)”的问题。当行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,这就是一次页分裂操作页分裂会导致表占用更多的磁盘空间。
- 聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候。
- 二级索引(非聚簇索引) 可能比想象的要更大,因为在二级索引的叶子节点包含了引用行的主键列。
- 二级索引访问需要两次索引查找,而不是一次
二级索引
主键索引是InnoDB存储引擎默认给我们创建的一套索引结构,我们表里的数据也是直接放在主键索引里,作为叶子节点的数据页。但我们在开发的过程中,往往会根据业务需要在不同的字段上建立索引,这些索引就是二级索引
比如,你给name字段加了一个索引,你插入数据的时候,就会重新搞一棵B+树,B+树的叶子节点,也是数据页,但是这个数据页里仅仅放了主键字段和name字段。
叶子节点的数据页的name值,跟主键索引一样的,都是按照大小排序的。同一个数据页里的name字段值都是大于上一个数据页里的name字段值。
name字段的B+树也会构建多层索引页,这个索引页里放的是下一层的页号和最小name字段值。就像这样:
上面介绍了聚簇索引的基本概念,接下来说一说,在InnoDB的表中,我们如何定义主键,是使用
AUTO_INCREMENT自增列,还是随机UUID,亦或者是其他方式得到id
AUTO_INCREMENT自增列和随机UUID的对比
单机下的对比
如果正在使用InnoDB 表并且没有什么数据需要聚集,那么可以定义一个代理键surrogate key) 作为主键,这种主键的数据应该和应用无关,最简单的方法是使用,还是AUTO_INCREMENT自增列。这样可以保证数据行是按顺序写入,对于根据主键做关联操作的性能也会更好
最好避免随机的 (不连续且值的分布范围非常大) 聚簇索引,特别是对于I/0 密集型的应用。例如,从性能的角度考虑,使用 UUID 来作为聚簇索引则会很糟糕:它使得聚簇索引的插入变得完全随机,这是最坏的情况,使得数据没有任何聚集特性
UUID 主键插入行不仅花费的时间更长,而且索引占用的空间也更大。这一方面是由于主键字段更长,另一方面毫无疑问是由于页分裂和碎片导致的,为什么会产生这样的问题?下图显示了插满一个页面后继续插入相邻的下一个页面的场景
向聚族索引插入顺序的索引值
如上图 所示,因为主键的值是顺序的,所以InnoDB 把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时 (InnoDB 默认的最大填充因子是页大小的15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满,这也正是所期望的结果(然而,二级索页可能是不一样的)。
向聚族索引中插入无序的值
上图是向使用了UUID聚簇索引的表插入数据,因为新行的主键值不一定比之前插人的大,所以InnDB 无法简单地总是把新行插人到索引的最后,而是需要为新的行寻找合适的位置一一通常是已有数据的中间位置一一并且分配空间。这会增加很多的额外工作,并导致数据分布不够优化。
- 写入的目标页可能已经刷到磁盘上并从缓存中移除,或者是还没有被加载到缓存中InnoDB 在插人之前不得不先找到并从磁盘读取目标页到内存中。这将导致大量的随机 I/0。
- 因为写入是乱序的,InnoDB 不得不频繁地做页分操作,以便为新的行分配空间页分裂会导致移动大量数据,一次插入最少需要修改三个页而不是一个页。
- 由于频繁的页分裂,页会变得稀疏并被不规则地填充,所以最终数据会有碎片。
上述讨论了单机情况下使用自增id和uuid的对比情况,接下来讨论一下分布式情况下,该如何使用
分布式情况下的对比
在现在的大型高并发分布式系统中,因为不同的数据库会部署到不同的机器上,一般都是多主实例,而且再加上高并发的话,就会有重复ID的情况,此时又该如何定义主键id呢。
分布式ID生成规则硬性要求
-
全局唯一:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
-
趋势递增:MySQL中InnoDB引擎使用的是聚集索引。多数RDBMS使用Btree的数据结构来存储索引数据,在主键的选择上尽量选择有序的主键保证写入性能。
-
单调递增:保证下一个ID号一定大于上一个。
-
保证安全:ID号需要无规则性,不能让别人根据ID号猜出我们的信息和业务数据量,增加恶意用户扒取数据的难度。
-
含时间戳
生成主键方案
UUID。
优点:性能非常高,JDK自带本地生成,无网络消耗。
缺点:
- 只保证了唯一性,趋势递增。
- 无序,无法预测他的生成规则,不能生成递增有序的数字。
- mysql官方推荐主键越短越好,UUID包含32个16位进制的字母数字,每一个都很长。
- B+树索引的分裂。主键是包含索引的,mysql的索引是通过B+树来实现的,每一次新的UUID数据插入,为了查询优化,因为UUID是无序的,都会对索引底层的B+树进行修改。插入无序,不但会导致一些中间节点产生分裂,也会白白创造很多不饱和的节点,大大降低了数据库插入的性能。
数据库自增主键。
优点:简单方便易用。
缺点:
- 要设置增长步长,系统水平扩展比较困难。
- 每次获取ID都得读写一次数据库,数据库压力大,非常影响性能,不符合分布式ID里低延迟和高QPS的规则。
基于Redis生成全局ID策略。
优点:满足分布式ID生成要求,并且已有最佳实践案例。
缺点:
- 要设置增长步长,同时key一定要设置有效期。
- 为了一个分布式ID,要搞一个Redis集群,维护成本大,当然若本身系统中有在使用Redis集群,生成ID只是顺带着还是比较方便的。
雪花算法,Twitter的分布式自增ID算snowflake。
优点:
- 经测试snowflake每秒能生成26万个自增可排序的ID。
- snowflake生成的ID结果是一个64bit大小的整数,为一个Long型 (转换成字符串后长度最多19)。
- 分布式系统内不会产生ID碰撞(datacenter和workerId作区分)并且效率高。
缺点:严重依赖服务器时间,所以当发生服务器时钟回拨的问题是会导致可能产生重复的id。
百度UidGenerator算法(基于雪花算法实现自定义时间戳)。
美团Leaf算法(依赖于数据库,ZK)。
也可以根据自己业务需求,自己定义主键
InnoDB中的页合并与分裂
在InnoDB中,数据即索引
文件表(File-Table)结构
假设你已经装好了MySQL最新的5.7版本(译注:文章发布于17年4月),并且你创建了一个windmills库(schema)和wmills表。在文件目录(通常是/var/lib/mysql/)你会看到以下内容:
data/
windmills/
wmills.ibd
wmills.frm
这是因为从MySQL 5.6版本开始innodb_file_per_table参数默认设置为1。该配置下你的每一个表都会单独作为一个文件存储(如果有分区也可能有多个文件)。
目录下要注意的是这个叫wmills.ibd的文件。这个文件由多个段(segments)组成,每个段和一个索引相关。
文件的结构是不会随着数据行的删除而变化的,但段则会跟着构成它的更小一级单位——区的变化而变化。区仅存在于段内,并且每个区都是固定的1MB大小(页体积默认的情况下)。页则是区的下一级构成单位,默认体积为16KB。
按这样算,一个区可以容纳最多64个页,一个页可以容纳2-N个行。行的数量取决于它的大小,由你的表结构定义。InnoDB要求页至少要有两个行,因此可以算出行的大小最多为8000 bytes。
听起来就像俄罗斯娃娃(Matryoshka dolls)一样是么,没错!下面这张图能帮助你理解:
根,分支与叶子
每个页(逻辑上讲即叶子节点)是包含了2-N行数据,根据主键排列。树有着特殊的页区管理不同的分支,即内部节点(INodes)。
上图仅为示例,后文才是真实的结构描述。
具体来看一下:
ROOT NODE #3: 4 records, 68 bytes
NODE POINTER RECORD ≥ (id=2) → #197
INTERNAL NODE #197: 464 records, 7888 bytes
NODE POINTER RECORD ≥ (id=2) → #5
LEAF NODE #5: 57 records, 7524 bytes
RECORD: (id=2) → (uuid="884e471c-0e82-11e7-8bf6-08002734ed50", millid=139, kwatts_s=1956, date="2017-05-01", location="For beauty's pattern to succeeding men.Yet do thy", active=1, time="2017-03-21 22:05:45", strrecordtype="Wit")
下面是表结构:
CREATE TABLE `wmills` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`uuid` char(36) COLLATE utf8_bin NOT NULL,
`millid` smallint(6) NOT NULL,
`kwatts_s` int(11) NOT NULL,
`date` date NOT NULL,
`location` varchar(50) COLLATE utf8_bin DEFAULT NULL,
`active` tinyint(2) NOT NULL DEFAULT '1',
`time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`strrecordtype` char(3) COLLATE utf8_bin NOT NULL,
PRIMARY KEY (`id`),
KEY `IDX_millid` (`millid`)
) ENGINE=InnoDB;
所有的B树都有着一个入口,也就是根节点,在上图中#3就是根节点。根节点(页)包含了如索引ID、INodes数量等信息。INode页包含了关于页本身的信息、值的范围等。最后还有叶子节点,也就是我们数据实际所在的位置。在示例中,我们可以看到叶子节点#5有57行记录,共7524 bytes。在这行信息后是具体的记录,可以看到数据行内容。
这里想引出的概念是当你使用InnoDB管理表和行,InnoDB会将他们会以分支、页和记录的形式组织起来。InnoDB不是按行的来操作的,它可操作的最小粒度是页,页加载进内存后才会通过扫描页来获取行/记录。
页的内部原理
页可以空或者填充满(100%),行记录会按照主键顺序来排列。例如在使用AUTO_INCREMENT时,你会有顺序的ID 1、2、3、4等。
页还有另一个重要的属性:MERGE_THRESHOLD。该参数的默认值是50%页的大小,它在InnoDB的合并操作中扮演了很重要的角色。
当你插入数据时,如果数据(大小)能够放的进页中的话,那他们是按顺序将页填满的。
若当前页满,则下一行记录会被插入下一页(NEXT)中。
根据B树的特性,它可以自顶向下遍历,但也可以在各叶子节点水平遍历。因为每个叶子节点都有着一个指向包含下一条(顺序)记录的页的指针。
例如,页#5有指向页#6的指针,页#6有指向前一页(#5)的指针和后一页(#7)的指针。
这种机制下可以做到快速的顺序扫描(如范围扫描)。之前提到过,这就是当你基于自增主键进行插入的情况。但如果你不仅插入还进行删除呢?
页合并
当你删了一行记录时,实际上记录并没有被物理删除,记录被标记(flaged)为删除并且它的空间变得允许被其他记录声明使用。
当页中删除的记录达到MERGE_THRESHOLD(默认页体积的50%),InnoDB会开始寻找最靠近的页(前或后)看看是否可以将两个页合并以优化空间使用。
在示例中,页#6使用了不到一半的空间,页#5又有足够的删除数量,现在同样处于50%使用以下。从InnoDB的角度来看,它们能够进行合并。
合并操作使得页#5保留它之前的数据,并且容纳来自页#6的数据。页#6变成一个空页,可以接纳新数据。
如果我们在UPDATE操作中让页中数据体积达到类似的阈值点,InnoDB也会进行一样的操作。
规则就是:页合并发生在删除或更新操作中,关联到当前页的相邻页。如果页合并成功,在INFOMATION_SCHEMA.INNODB_METRICS中的index_page_merge_successful将会增加。
页分裂
前面提到,页可能填充至100%,在页填满了之后,下一页会继续接管新的记录。但如果有下面这种情况呢?
页#10没有足够空间去容纳新(或更新)的记录。根据“下一页”的逻辑,记录应该由页#11负责。然而:
页#11也同样满了,数据也不可能不按顺序地插入。怎么办?
还记得之前说的链表吗(译注:指B+树的每一层都是双向链表)?页#10有指向页#9和页#11的指针。
InnoDB的做法是(简化版):
- 创建新页
- 判断当前页(页#10)可以从哪里进行分裂(记录行层面)
- 移动记录行
- 重新定义页之间的关系
新的页#12被创建:
页#11保持原样,只有页之间的关系发生了改变:
- 页#10相邻的前一页为页#9,后一页为页#12
- 页#12相邻的前一页为页#10,后一页为页#11
- 页#11相邻的前一页为页#10,后一页为页#13
(译注:页#13可能本来就有,这里意思为页#10与页#11之间插入了页#12)
这样B树水平方向的一致性仍然满足,因为满足原定的顺序排列逻辑。然而从物理存储上讲页是乱序的,而且大概率会落到不同的区。
规律总结:页分裂会发生在插入或更新,并且造成页的错位(dislocation,落入不同的区)
InnoDB用INFORMATION_SCHEMA.INNODB_METRICS表来跟踪页的分裂数。可以查看其中的index_page_splits和index_page_reorg_attempts/successful统计。
一旦创建分裂的页,唯一(译注:实则仍有其他方法,见下文)将原先顺序恢复的办法就是新分裂出来的页因为低于合并阈值(merge threshold)被删掉。这时候InnoDB用页合并将数据合并回来。
另一种方式就是用OPTIMIZE重新整理表。这可能是个很重量级和耗时的过程,但可能是唯一将大量分布在不同区的页理顺的方法。
另一方面,要记住在合并和分裂的过程,InnoDB会在索引树上加写锁(x-latch)。在操作频繁的系统中这可能会是个隐患。它可能会导致索引的锁争用(index latch contention)。如果表中没有合并和分裂(也就是写操作)的操作,称为“乐观”更新,只需要使用读锁(S)。带有合并也分裂操作则称为“悲观”更新,使用写锁(X)。
参考:
https://zhuanlan.zhihu.com/p/98818611