经常听到别人讲数据库就像书的目录一样,是为了提高查询效率,那么索引实现和书的目录区别又是什么?

一、索引的常见模型

  1. 哈希表
  2. 有序数组
  3. 搜索树(InnoDB采用的是N叉B+树InnoDB引擎使用的数据结构后边重点介绍
    img

二、各模型分析

1. 哈希表模型图解

img

如图所示:跟java中的hashMap数据结构一致

  1. 图中,User2 和 User4 根据身份证号算出来的值都是 N,但没关系,后面还跟了一个链表。假设,这时候你要查 ID_card_n2 对应的名字是什么,处理步骤就是:首先,将 ID_card_n2 通过哈希函数算出 N;然后,按顺序遍历,找到 User2。
  2. 需要注意的是,图中四个 ID_card_n 的值并不是递增的,这样做的好处是增加新的 User 时速度会很快,只需要往后追加。但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。
  3. 你可以设想下,如果你现在要找身份证号在 [ID_card_X, ID_card_Y] 这个区间的所有用户,就必须全部扫描一遍了。
    由上可以推断:哈希表这种结构适用于只有等值查询的场景,比如 Memcached 及其他一些 NoSQL 引擎。
2. 有序数组模型图解(等值查询和区间查询效率都很高)

img

优点:

  1. 这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查 ID_card_n2 对应的名字,用二分法就可以快速得到,这个时间复杂度是 O(log(N))。
  2. 同时很显然,这个索引结构支持范围查询。你要查身份证号在 [ID_card_X, ID_card_Y] 区间的 User,可以先用二分法找到 ID_card_X(如果不存在 ID_card_X,就找到大于 ID_card_X 的第一个 User),然后向右遍历,直到查到第一个大于 ID_card_Y 的身份证号,退出循环。如果仅仅看查询效率,有序数组就是最好的数据结构了。
    缺点:
    但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。有序数组索引只适用于静态存储引擎。
3. 二叉搜索树模型图解(等值查询和区间查询效率都很高)

img

优点:

  1. 二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。这样如果你要查 ID_card_n2 的话,按照图中的搜索顺序就是按照 UserA -> UserC -> UserF -> User2 这个路径得到。这个时间复杂度是 O(log(N))。
  2. 当然为了维持 O(log(N)) 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log(N))。树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的

缺点:
但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。你可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。

4. N叉B+树(N差不多是1200)
  1. 每一个索引在InnoDB中都是一棵B+树
  2. 这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。考虑到树根的数据块总是在内存中的,一个 10 亿行的表上一个整数字段的索引,查找一个值最多只需要访问 3 次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

三、InnoDB索引模型案例分析

假设,我们有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引。

1
2
3
4
5
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;

表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。
img

  1. 主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)
  2. 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)
    基于主键索引和普通索引的查询有什么区别?
  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表
    也就是说,基于非主键索引的查询需要多扫描一棵索引树(回表)。因此,我们在应用中应该尽量使用主键查询

四、InnoDB索引维护

  1. B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。
  2. 而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。
  3. 除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
  4. 当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
  5. 你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该?
  1. 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。
  2. 插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。
  3. 也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
  4. 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
  5. 除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?
  6. 由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。
  7. 显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
  8. 所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

6.有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
只有一个索引;该索引必须是唯一索引。
由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

Comment and share

一. ACID解释

A: Atomicity 原子性
C: Consistencey 一致性
I: Isolation 一致性
D: Durability 持久性

二. 事务隔离级别
  1. 读未提交(read uncommited)一个事务还未提交,它的更改可以被其他事务读到。
  2. 读提交(read commited)只有一个事务提交了后,它的更改才可以被其他事务读到。
  3. 可重复读(repeatable read)一个事务执行过程中看到的数据,总是跟这个事务启动前看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  4. 串行化(serializable)对于同一行记录,读会加读锁,写会加写锁。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成。

知识点 读提交和可重复读的区别是:有两个事务A,B。读提交是:如果A事务在开启过程中,B事务对记录进行了更改并且提交了,A是可以读到的B事务更改后的记录。可重复读则是:就算A事务在开启过程中B事务对记录进行了更改并且提交了,A也是读不到B更改后的记录。A事务仍然读到的事它开启时记录最初的状态。只有当A事务进行提交后才能读到B更改后的记录。

Oracle默认的隔离级别是读提交
配置的方式是,将启动参数 transaction-isolation 的值设置成READ-COMMITTED。你可以用 show variables 来查看当前的值。

可重复读的应用场景

假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。

三. 事务隔离实现为什么要避免大量的大事务

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

img

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

当系统里没有比这个回滚日志更早的 read-view 的时候才删除日志。
长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。
在工作中有的公司代码中可能采用的AOP来进行事务管理,根据service层入口的方法名前缀来判断是否开启事务,经常能看到有的开发者为了不必要的麻烦所有都采用了开启事务,这是不合理的。

问题:如何避免长事务对业务的影响?

首先,从应用开发端来看:

  1. 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  2. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  3. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

阅读《MySQL实战45讲》

InnoDB的undo log文件存储在MySQL数据目录下的ibdata文件中,这个文件包含了多种不同的数据结构和信息,其中包括了InnoDB的undo log。

具体来说,每个InnoDB表都有一个undo log,用于记录对该表进行的事务操作,如INSERT、UPDATE和DELETE。当需要回滚一个事务时,InnoDB会使用undo log中的信息来撤消该事务所做的更改。

在默认情况下,InnoDB的undo log被存储在ibdata文件的系统表空间中。如果使用了多个独立的表空间,每个表空间也会包含一个undo段,其中包含与该表空间关联的所有表的undo log。

值得注意的是,如果启用了innodb_undo_tablespaces选项,每个InnoDB表将会有一个独立的undo表空间文件,这些文件将会存储在指定的目录中,而不是在ibdata文件中。

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China