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

一、索引的常见模型

  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

概述

一句update的语句:Update T set C=c+1 where id = 2;

和查询语句一样会走一遍如下的流程:

img点击并拖拽以移动

与查询语句不一样的是,更新语句设计上有两个重要的模块:redo log 和 binlog

一、重要日志模块: redo log InnoDB引擎特有的日志

Write-Ahead Logging(WAL技术)它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。

  1. 当有一条记录需要更新的时候,InnoDB引擎就会先把记录写入到redo log(粉板)理。

  2. 进行内存的更新。

    以上两步操作后更新就算完成了。

  3. 同时InnoDB引擎会在适当的时候将这个操作记录更新到磁盘里面。而这个更新往往是系统比较空闲的时候。类比掌柜下班后将粉板上的赊账记录誊写到账本上,

但是:如果今天赊账的不多,掌柜可以等打烊后再整理。但如果某天赊账的特别多,粉板写满了,又怎么办呢?这个时候掌柜只好放下手中的活儿,把粉板中的一部分赊账记录更新到账本中,然后把这些记录从粉板上擦掉,为记新账腾出空间。

与此类似,InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么这块“粉板”总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

img点击并拖拽以移动

  1. write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

  2. write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

  3. crash-safe:有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失。

二、重要日志模块: binlog server层归档日志

因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。

三、redo log 和 binlog的差异:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

四、update语句执行流程:

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。

  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。

  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。

  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。

  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

img点击并拖拽以移动

将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是”两阶段提交”.

五、两阶段提交:

怎样让数据库恢复到半个月内任意一秒的状态?

  1. 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;

  2. 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。

    简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

>阅读《MySQL实战45讲》

Comment and share

MySQL的架构示意:

img

MySQL大体分为两层:Server 层和存储引擎层

  1. server层: 连接器,查询缓存,分析器,优化器等,涵盖MySQL的大多数核心服务功能,一级所有内置函数(如日期,时间,数学和加密函数等),所有夸存储引起的功能都在这一层实现,比如:存过,触发器,视图等。
  2. 存储引擎负责数据的存储和提取:innoDB,MyISAM,Memory等 MySql5.5.5版本开始默认为InnoDB

个层次分工:

  1. 连接器:顾名思义连接器负责跟客户端建立连接、获取权限、维持和管理连接
    你可以在 show processlist 命令中看到它。Command列显示为Sleep则表示该连接为空闲链接。
  2. 查询缓存:连接建立完成后,你就可以执行 select 语句了。执行逻辑第二步查询缓存。
    优势:提高查询效率,适合表数据不经常做更新的。
    劣势:一张表有更新机会清空缓存,命中率会很低。
    使用参数 query_cache_type 设置成 DEMAND这样对应默认的SQL语句是不适用查询缓存的,显示指定的时候才会查询缓存如下:
    1
    select SQL_CACHE * FROM T WHERE ID = 10;
  3. 分析器:如果没有命中缓存则开始对SQL语句进行解析,生成解析树。
  4. 经过了分析器,MySQL 就知道你要做什么了。在开始执行之前得经过优化器的处理,包括表里有多个索引时决定使用哪个索引;一个语句有多表关联的时候决定各个表的连接顺序;
    比如:

    mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20;

    1. 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于20。
    2. 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是是否等于 10。

后边仔细分析对索引的选择
5. 执行器:MySql通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了之情器阶段开始执行语句。
如以下语句的执行过程:

1
2
mysql> select * from T where ID=10;

执行器会根据表定义的引擎取调用这个引擎所提供的接口。比如我们例句中提供的表T,ID无索引则会:

  1. 调用InnoDB引擎接口取这个表的第一行,判断ID是否为10,如果不是则跳过,如果是则将这行存在结果集中;
  2. 调用引擎接口取“下一行”,重复相同的判断逻辑,指导取到这个表的最后一行。
  3. 执行器将上述遍历过程中所有满足的条件行组成记录集作为结果返回给客户端。

至此这个语句执行就完成了。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“满足条件得第一行“这个接口,之后循环取“满足条件的下一行”这个接口。 这些接口都是存储殷勤中定义好的。
** rows_examined **:表示语句扫描了多少行,这个值就是执行器每次调用引擎获取数据行时累加的。
在某些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟rows_examined(调用次数可能小于扫描行数)并不是完全相同的

阅读《MySQL实战45讲》

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China