京东资深架构师深度解析《 MySQL 实战 》

基础架构:一条 SQL 查询语句是如何执行的?

MySQL 的基本架构示意图:mysql

clipboard.png

MySQL 能够分为 Server 层和存储引擎层两部分。算法

Server 层包括链接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及全部的内置函数(如日期、时间、数学和加密函数等),全部跨存储引擎的功能都在这一层实现,好比存储过程、触发器、视图等。sql

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。如今最经常使用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。数据库

不一样存储引擎的表数据存取方式不一样,支持的功能也不一样。不一样的存储引擎共用一个Server 层,也就是从链接器到执行器的部分。数组

链接器

链接器负责跟客户端创建链接、获取权限、维持和管理链接。缓存

查询缓存

MySQL 拿到一个查询请求后,会先到查询缓存看看,以前是否是执行过这条语句。以前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。若是你的查询可以直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。安全

可是大多数状况下我会建议你不要使用查询缓存,为何呢?由于查询缓存每每弊大于利。性能优化

查询缓存的失效很是频繁,只要有对一个表的更新,这个表上全部的查询缓存都会被清空。所以极可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来讲,查询缓存的命中率会很是低。除非你的业务就是有一张静态表,很长时间才会更新一次。好比,一个系统配置表,那这张表上的查询才适合使用查询缓存。session

MySQL 8.0 版本直接将查询缓存的整块功能删掉了,也就是说 8.0 开始完全没有这个功能了。数据结构

分析器

若是没有命中查询缓存,就要开始真正执行语句了。

分析器先会作“词法分析”。作完了这些识别之后,就要作“语法分析”。

优化器

优化器是在表里面有多个索引的时候,决定使用哪一个索引;或者在一个语句有多表关联(join)的时候,决定各个表的链接顺序。

优化器阶段完成后,这个语句的执行方案就肯定下来了,而后进入执行器阶段。

执行器

打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。

你会在数据库的慢查询日志中看到一个 rows_examined 的字段,表示这个语句执行过程当中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。

在有些场景下,执行器调用一次,在引擎内部则扫描了多行,所以引擎扫描行数跟 rows_examined 并非彻底相同的。

我给你留一个问题吧,若是表 T 中没有字段 k,而你执行了这个语句 select * from T where k=1, 那确定是会报“不存在这个列”的错误: “Unknown column ‘k’ in ‘where clause’”。你以为这个错误是在咱们上面提到的哪一个阶段报出来的呢?

答案是分析器。


日志系统:一条 SQL 更新语句是如何执行的?

与查询流程不同的是,更新流程还涉及两个重要的日志模块,它们正是咱们今天要讨论的主角:redo log(重作日志)和 binlog(归档日志)。

重要的日志模块:redo log

MySQL 里常常说到的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。

具体来讲,当有一条记录须要更新的时候,InnoDB 引擎就会先把记录写到 redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操做记录更新到磁盘里面,而这个更新每每是在系统比较空闲的时候作。

与此相似,InnoDB 的 redo log 是固定大小的,好比能够配置为一组 4 个文件,每一个文件的大小是 1GB,那么这块“粉板”总共就能够记录 4GB 的操做。从头开始写,写到末尾就又回到开头循环写。

clipboard.png

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

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

有了 redo log,InnoDB 就能够保证即便数据库发生异常重启,以前提交的记录都不会丢失,这个能力称为crash-safe。

重要的日志模块:binlog

redo log 是 InnoDB 引擎特有的日志,而 Server 层也有本身的日志,称为 binlog(归档日志)。

为何会有两份日志呢?

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

这两种日志有如下三点不一样。

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

有了对这两个日志的概念性理解,咱们再来看执行器和 InnoDB 引擎在执行这个简单的 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)状态,更新完成。

update 语句执行流程:

clipboard.png

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

两阶段提交

前面咱们说过了,binlog 会记录全部的逻辑操做,而且是采用“追加写”的形式。

因为 redo log 和 binlog 是两个独立的逻辑若是不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

小结

redo log 用于保证 crash-safe 能力。innodbflushlogattrx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样能够保证 MySQL 异常重启以后数据不丢失。

sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样能够保证 MySQL 异常重启以后 binlog 不丢失。


事务隔离:为何你改了我还看不见?

简单来讲,事务就是要保证一组数据库操做,要么所有成功,要么所有失败。在 MySQL 中,事务支持是在引擎层实现的。你如今知道,MySQL 是一个支持多引擎的系统,但并非全部的引擎都支持事务。好比 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要缘由之一。

隔离性与隔离级别

在谈隔离级别以前,你首先要知道,你隔离得越严实,效率就会越低。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。SQL 标准的事务隔离级别包括:

  • 读未提交是指,一个事务还没提交时,它作的变动就能被别的事务看到。
  • 读提交是指,一个事务提交以后,它作的变动才会被其余事务看到。
  • 可重复读是指,一个事务执行过程当中看到的数据,老是跟这个事务在启动时看到的数据是一致的。固然在可重复读隔离级别下,未提交变动对其余事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

clipboard.png

  • 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然尚未提交,可是结果已经被 A 看到了。所以,V二、V3 也都是2。
  • 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。因此, V3 的值也是 2。
  • 若隔离级别是“可重复读”,则 V一、V2 是 1,V3 是 2。之因此 V2 仍是1,遵循的就是这个要求:事务在执行期间看到的数据先后必须是一致的。
  • 若隔离级别是“串行化”,则在事务 B 执行“将 1 改为 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才能够继续执行。因此从 A的角度看,V一、V2 值是 1,V3 的值是 2。

在实现上,数据库里面会建立一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时建立的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每一个 SQL 语句开始执行的时候建立的。这里须要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

Oracle 数据库的默认隔离级别其实就是“读提交”。

事务启动时的视图能够认为是静态的,不受其余事务更新的影响。

事务隔离的实现

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操做。记录上的最新值,经过回滚操做,均可以获得前一个状态的值。

同一条记录在系统中能够存在多个版本,就是数据库的多版本并发控制(MVCC)。

你必定会问,回滚日志总不能一直保留吧,何时删除呢?答案是,在不须要的时候才删除。也就是说,系统会判断,当没有事务再须要用到这些回滚日志时,回滚日志会被删除。

何时才不须要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。

长事务意味着系统里面会存在很老的事务视图。因为这些事务随时可能访问数据库里面的任何数据,因此这个事务提交以前,数据库里面它可能用到的回滚记录都必须保留,这就会致使大量占用存储空间。

在 MySQL 5.5 及之前的版本,回滚日志是跟数据字典一块儿放在 ibdata 文件里的,即便长事务最终提交,回滚段被清理,文件也不会变小。

除了对回滚段的影响,长事务还占用锁资源。

事务的启动方式

MySQL 的事务启动方式有如下几种:

  • 1 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
  • 2 set autocommit=0,这个命令会将这个线程的自动提交关掉。

有些客户端链接框架会默认链接成功后先执行一个 set autocommit=0 的命令。这就致使接下来的查询都在事务中,若是是长链接,就致使了意外的长事务。

所以,我会建议你老是使用 set autocommit=1, 经过显式语句的方式来启动事务。

可是有的开发同窗会纠结“多一次交互”的问题。对于一个须要频繁使用事务的业务,第二种方式每一个事务在开始时都不须要主动执行一次 “begin”,减小了语句的交互次数。


深刻浅出索引

索引的出现其实就是为了提升数据查询的效率,就像书的目录同样。

索引的常见模型

索引的出现是为了提升查询效率,可是实现索引的方式却有不少种,因此这里也就引入了索引模型的概念。简单的数据结构,它们分别是哈希表、有序数组和搜索树。

哈希表是一种以键 - 值(key-value)存储数据的结构,咱们只要输入待查找的值即 key,就能够找到其对应的值即 Value。

不可避免地,多个 key 值通过哈希函数的换算,会出现同一个值的状况。处理这种状况的一种方法是,拉出一个链表。

clipboard.png

有序数组索引只适用于静态存储引擎。

clipboard.png

二叉搜索树的特色是:每一个节点的左儿子小于父节点,父节点又小于右儿子。

固然为了维持 O(log(N)) 的查询复杂度,你就须要保持这棵树是平衡二叉树。为了作这个保证,更新的时间复杂度也是 O(log(N))。

树能够有二叉,也能够有多叉。多叉树就是每一个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,可是实际上大多数的数据库存储却并不使用二叉树。其缘由是,索引不止存在内存中,还要写到磁盘上。

为了让一个查询尽可能少地读磁盘,就必须让查询过程访问尽可能少的数据块。那么,咱们就不该该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。

以 InnoDB 的一个整数字段索引为例,这个 N 差很少是 1200。这棵树高是 4 的时候,就能够存 1200 的 3 次方个值,这已经 17 亿了。

N 叉树因为在读写上的性能优势,以及适配磁盘的访问模式,已经被普遍应用在数据库引擎中了。

在 MySQL 中,索引是在存储引擎层实现的,因此并无统一的索引标准,即不一样存储引擎的索引的工做方式并不同。而即便多个存储引擎支持同一种类型的索引,其底层的实现也可能不一样。

InnoDB 的索引模型

在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。InnoDB 使用了 B+ 树索引模型,因此数据都是存储在 B+ 树中的。

每个索引在 InnoDB 里面对应一棵 B+ 树。索引类型分为主键索引和非主键索引。

主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。

非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。

基于主键索引和普通索引的查询有什么区别?

  • 若是语句是 select * from T where ID=500,即主键查询方式,则只须要搜索 ID 这棵 B+ 树;
  • 若是语句是 select * from T where k=5,即普通索引查询方式,则须要先搜索 k 索引树,获得 ID 的值为
    500,再到 ID 索引树搜索一次。这个过程称为回表。

也就是说,基于非主键索引的查询须要多扫描一棵索引树。

索引维护

而更糟的状况是,若是 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候须要申请一个新的数据页,而后挪动部分数据过去。这个过程称为页分裂。

当相邻两个页因为删除了数据,利用率很低以后,会将数据页作合并。合并的过程,能够认为是分裂过程的逆过程。

假设你的表中确实有一个惟一字段,好比字符串类型的身份证号,那应该用身份证号作主键,仍是用自增字段作主键呢?

因为每一个非主键索引的叶子节点上都是主键的值。若是用身份证号作主键,那么每一个二级索引的叶子节点占用约 20 个字节,而若是用整型作主键,则只要 4 个字节,若是是长整型(bigint)则是 8 个字节。

显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

有没有什么场景适合用业务字段直接作主键的呢?仍是有的。好比,有些业务的场景需求是这样的:

  • 1 只有一个索引;
  • 2 该索引必须是惟一索引。

你必定看出来了,这就是典型的 KV 场景。

因为没有其余索引,因此也就不用考虑其余索引的叶子节点大小的问题。

回到主键索引树搜索的过程,咱们称为回表。

覆盖索引

若是执行的语句是 select ID from T where k between 3 and 5,这时只须要查 ID 的值,而 ID 的值已经在 k 索引树上了,所以能够直接提供查询结果,不须要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”咱们的查询需求,咱们称为覆盖索引。

因为覆盖索引能够减小树的搜索次数,显著提高查询性能,因此使用覆盖索引是一个经常使用的性能优化手段。

最左前缀原则

B+ 树这种索引结构,能够利用索引的“最左前缀”,来定位记录。索引项是按照索引定义里面出现的字段顺序排序的。

在创建联合索引的时候,如何安排索引内的字段顺序。

第一原则是,若是经过调整顺序,能够少维护一个索引,那么这个顺序每每就是须要优先考虑采用的。

索引下推

在 MySQL 5.6 以前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。

MySQL 5.6 引入的索引下推优化(index condition pushdown), 能够在索引遍历过程当中,对索引中包含的字段先作判断,直接过滤掉不知足条件的记录,减小回表次数。

clipboard.png

图3

clipboard.png

图4

在图 3 和 4 这两个图里面,每个虚线箭头表示回表一次。

图 3 中,在 (name,age) 索引里面我特地去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。所以,须要回表 4 次。

图 4 跟图 3 的区别是,InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在咱们的这个例子中,只须要对 ID四、ID5 这两条记录回表取数据判断,就只须要回表 2 次。


全局锁和表锁:给表加个字段怎么有这么多阻碍?

数据库锁设计的初衷是处理并发问题。当出现并发访问的时候,数据库须要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。

根据加锁的范围,MySQL 里面的锁大体能够分红全局锁、表级锁和行锁三类。

全局锁

顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你须要让整个库处于只读状态的时候,可使用这个命令,以后其余线程的如下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,作全库逻辑备份。

可是让整库都只读,听上去就很危险:

  • 若是你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
  • 若是你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会致使主从延迟。

一致性读是好,但前提是引擎要支持这个隔离级别。好比,对于 MyISAM 这种不支持事务的引擎,若是备份过程当中有更新,老是只能取到最新的数据,那么就破坏了备份的一致性。这时,咱们就须要使用 FTWRL 命令了。

若是有的表使用了不支持事务的引擎,那么备份就只能经过 FTWRL 方法。这每每是 DBA 要求业务开发人员使用 InnoDB 替代 MyISAM 的缘由之一。

业务的更新不仅是增删改数据(DML),还有多是加字段等修改表结构的操做(DDL)。不管是哪一种方法,一个库被全局锁上之后,你要对里面任何一个表作加字段操做,都是会被锁住的。

表级锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。与 FTWRL 相似,能够用 unlock tables 主动释放锁,也能够在客户端断开的时候自动释放。须要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操做对象。

举个例子, 若是在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其余线程写 t一、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 以前,也只能执行读 t一、读写 t2 的操做。连写 t1 都不容许,天然也不能访问其余表。

而对于 InnoDB 这种支持行锁的引擎,通常不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面仍是太大。

另外一类表级的锁是 MDL(metadata lock)。MDL 不须要显式使用,在访问一个表的时候会被自动加上。MDL 的做用是,保证读写的正确性。你能够想象一下,若是一个查询正在遍历一个表中的数据,而执行期间另外一个线程对这个表结构作变动,删了一列,那么查询线程拿到的结果跟表结构对不上,确定是不行的。

所以,在 MySQL 5.5 版本中引入了 MDL,当对一个表作增删改查操做的时候,加 MDL 读锁;当要对表作结构变动操做的时候,加 MDL 写锁。

  • 读锁之间不互斥,所以你能够有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变动表结构操做的安全性。所以,若是有两个线程要同时给一个表加字段,其中一个要等另外一个执行完才能开始执行。

你确定知道,给一个表加字段,或者修改字段,或者加索引,须要扫描全表的数据。在对大表操做的时候,你确定会特别当心,以避免对线上服务形成影响。

clipboard.png

咱们能够看到 session A 先启动,这时候会对表 t 加一个 MDL 读锁。因为 session B 须要的也是 MDL 读锁,所以能够正常执行。

以后 session C 会被 blocked,是由于 session A 的 MDL 读锁尚未释放,而 session C 须要 MDL 写锁,所以只能被阻塞。

若是只有 session C 本身被阻塞还没什么关系,可是以后全部要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞。前面咱们说了,全部对表的增删改查操做都须要先申请 MDL 读锁,就都被锁住,等于这个表如今彻底不可读写了。

事务中的 MDL 锁,在语句执行开始时申请,可是语句结束后并不会立刻释放,而会等到整个事务提交后再释放。

如何安全地给小表加字段?

首先咱们要解决长事务,事务不提交,就会一直占着 MDL 锁。

小结

全局锁主要用在逻辑备份过程当中。对于所有是 InnoDB 引擎的库,我建议你选择使用–single-transaction 参数,对应用会更友好。

表锁通常是在数据库引擎不支持行锁的时候才会被用到的。若是你发现你的应用程序里有 lock tables 这样的语句,你须要追查一下,比较可能的状况是:

  • 要么是你的系统如今还在用 MyISAM 这类不支持事务的引擎,那要安排升级换引擎;
  • 要么是你的引擎升级了,可是代码还没升级。我见过这样的状况,最后业务开发就是把 lock tables 和 unlock tables 改为
    begin 和 commit,问题就解决了。

MDL 会直到事务提交才释放,在作表结构变动的时候,你必定要当心不要致使锁住线上查询和更新。


我给你留一个问题吧,备份通常都会在备库上执行,你在用–single-transaction 方法作逻辑备份的过程当中,若是主库上的一个小表作了一个 DDL,好比给一个表上加了一列。这时候,从备库上会看到什么现象呢?

Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION  WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */

参考答案以下:

若是在 Q4 语句执行以前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。

若是在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;

若是在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。

从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。


行锁功过:怎么减小行锁对性能的影响?

MySQL 的行锁是在引擎层由各个引擎本身实现的。但并非全部的引擎都支持行锁,好比 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要缘由之一。(innodb行级锁是经过锁索引记录实现的。)

顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,好比事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操做完成后才能进行更新。

从两阶段锁提及

在下面的操做序列中,事务 B 的 update 语句执行时会是什么现象呢?假设字段 id 是表 t 的主键。

clipboard.png

你能够验证一下:实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 以后,事务 B 才能继续执行。

知道了这个答案,你必定知道了事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。

也就是说,在 InnoDB 事务中,行锁是在须要的时候才加上的,但并非不须要了就马上释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

死锁和死锁检测

当并发系统中不一样线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会致使这几个线程都进入无限等待的状态,称为死锁。

clipboard.png

这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁之后,有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间能够经过参数 innodblockwait_timeout 来设置。
  • 另外一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其余事务得以继续执行。将参数
    innodbdeadlockdetect 设置为 on,表示开启这个逻辑。

在 InnoDB 中,innodblockwait_timeout 的默认值是 50s,意味着若是采用第一个策略,当出现死锁之后,第一个被锁住的线程要过 50s 才会超时退出,而后其余线程才有可能继续执行。对于在线服务来讲,这个等待时间每每是没法接受的。

因此,超时时间设置过短的话,会出现不少误伤。

因此,正常状况下咱们仍是要采用第二种策略,即:主动死锁检测,并且 innodbdeadlockdetect 的默认值自己就是 on。主动死锁检测在发生死锁的时候,是可以快速发现并进行处理的,可是它也是有额外负担的。

每一个新来的被堵住的线程,都要判断会不会因为本身的加入致使了死锁,这是一个时间复杂度是 O(n) 的操做。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操做就是 100 万这个量级的。虽然最终检测的结果是没有死锁,可是这期间要消耗大量的 CPU 资源。

问题的症结在于,死锁检测要耗费大量的 CPU 资源。

一种头痛医头的方法,就是若是你能确保这个业务必定不会出现死锁,能够临时把死锁检测关掉。

另外一个思路是控制并发度。根据上面的分析,你会发现若是并发可以控制住,好比同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端作并发控制。可是,你会很快发现这个方法不太可行,由于客户端不少。我见过一个应用,有 600 个客户端,这样即便每一个客户端控制到只有 5 个并发线程,汇总到数据库服务端之后,峰值并发数也可能要达到 3000。

所以,这个并发控制要作在数据库服务端。

小结

调整语句顺序并不能彻底避免死锁。因此咱们引入了死锁和死锁检测的概念,以及提供了三个方案,来减小死锁对数据库的影响。减小死锁的主要方向,就是控制访问相同资源的并发事务量。


我给你留一个问题吧,若是你要删除一个表里面的前 10000 行数据,有如下三种方法能够作到:

  • 第一种,直接执行 delete from T limit 10000;
  • 第二种,在一个链接中循环执行 20 次 delete from T limit 500;
  • 第三种,在 20 个链接中同时执行 delete from T limit 500。

你会选择哪种方法呢?为何呢?

确实是这样的,第二种方式是相对较好的。

第一种方式(即:直接执行 delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;并且大事务还会致使主从延迟。

第三种方式(即:在 20 个链接中同时执行 delete from T limit 500),会人为形成锁冲突。


事务究竟是隔离的仍是不隔离的?

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

clipboard.png

begin/start transaction 命令并非一个事务的起点,在执行到它们以后的第一个操做 InnoDB 表的语句,事务才真正启动。若是你想要立刻启动一个事务,可使用 start transaction with consistent snapshot 这个命令。

在这个例子中,事务 C 没有显式地使用 begin/commit,表示这个 update 语句自己就是一个事务,语句完成的时候会自动提交。事务 B 在更新了行以后查询 ; 事务 A 在一个只读事务中查询,而且时间顺序上是在事务 B 的查询以后。

这时,若是我告诉你事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1。

在 MySQL 里,有两个“视图”的概念:

  • 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。建立视图的语法是 create view …
    ,而它的查询方法与表同样。
  • 另外一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read
    Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

它没有物理结构,做用是事务执行期间用来定义“我能看到什么数据”。

“快照”在 MVCC 里是怎么工做的?

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

InnoDB 里面每一个事务有一个惟一的事务 ID,叫做 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,而且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,而且在新的数据版本中,可以有信息能够直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每一个版本有本身的 row trx_id。

clipboard.png

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,所以它的 row trx_id 也是 25。

实际上,图 2 中的三个虚线箭头,就是 undo log;而 V一、V二、V3 并非物理上真实存在的,而是每次须要的时候根据当前版本和 undo log 计算出来的。好比,须要 V2 的时候,就是经过 V4 依次执行 U三、U2 算出来。

按照可重复读的定义,一个事务启动的时候,可以看到全部已经提交的事务结果。可是以后,这个事务执行期间,其余事务的更新对它不可见。

所以,一个事务只须要在启动的时候声明说,“以我启动的时刻为准,若是一个数据版本是在我启动以前生成的,就认;若是是我启动之后才生成的,我就不认,我必需要找到它的上一个版本”。

固然,若是“上一个版本”也不可见,那就得继续往前找。

在实现上, InnoDB 为每一个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的全部事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经建立过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果获得的。

clipboard.png

这样,对于当前事务的启动瞬间来讲,一个数据版本的 row trx_id,有如下几种可能:

若是落在绿色部分,表示这个版本是已提交的事务或者是当前事务本身生成的,这个数据是可见的;

若是落在红色部分,表示这个版本是由未来启动的事务生成的,是确定不可见的;

若是落在黄色部分,那就包括两种状况:

  • a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
  • b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

由于以后的更新,生成的版本必定属于上面的 2 或者 3(a) 的状况,而对它来讲,这些新的数据版本是不存在的,因此这个事务的快照,就是“静态”的了。

因此你如今知道了,InnoDB 利用了“全部数据都有多个版本”的这个特性,实现了“秒级建立快照”的能力。

这里,咱们不妨作以下假设:

  • 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
  • 事务 A、B、C 的版本号分别是 100、10一、102,且当前系统里只有这四个事务;
  • 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]。

clipboard.png

从图中能够看到,第一个有效更新是事务 C,把数据从 (1,1) 改为了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。

第二个有效更新是事务 B,把数据从 (1,2) 改为了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。

你可能注意到了,在事务 A 查询的时候,其实事务 B 尚未提交,可是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,不然就变成脏读了。

好,如今事务 A 要来读数据了,它的视图数组是 [99,100]。固然了,读数据都是从当前版本读起的。因此,事务 A 查询语句的读数据流程是这样的:

  • 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,可是事务 A 不论在何时查询,看到这行数据的结果都是一致的,因此咱们称之为一致性读。

一个数据版本,对于一个事务视图来讲,除了本身的更新老是可见之外,有三种状况:

  • 版本未提交,不可见;
  • 版本已提交,可是是在视图建立后提交的,不可见;
  • 版本已提交,并且是在视图建立前提交的,可见。

如今,咱们用这个规则来判断图 4 中的查询结果,事务 A 的查询语句的视图数组是在事务 A 启动的时候生成的,这时候:

  • (1,3) 还没提交,属于状况 1,不可见;
  • (1,2) 虽然提交了,可是是在视图数组建立以后提交的,属于状况 2,不可见;
  • (1,1) 是在视图数组建立以前提交的,可见。

更新逻辑

你看图 5 中,事务 B 的视图数组是先生成的,以后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

clipboard.png

是的,若是事务 B 在更新以前查询一次数据,这个查询返回的 k 的值确实是 1。

可是,当它要去更新数据的时候,就不能再在历史版本上更新了,不然事务 C 的更新就丢失了。所以,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操做。

因此,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

所以,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。

其实,除了 update 语句外,select 语句若是加锁,也是当前读。

再往前一步,假设事务 C 不是立刻提交的,而是变成了下面的事务 C’,会怎么样呢?

clipboard.png

事务 C’的不一样是,更新后并无立刻提交,在它提交前,事务 B 的更新语句先发起了。前面说过了,虽然事务 C’还没提交,可是 (1,2) 这个版本也已经生成了,而且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?

事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必需要读最新版本,并且必须加锁,所以就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。

clipboard.png

事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。若是当前的记录的行锁被其余事务占用的话,就须要进入锁等待。

而读提交的逻辑和可重复读的逻辑相似,它们最主要的区别是:

  • 在可重复读隔离级别下,只须要在事务开始的时候建立一致性视图,以后事务里的其余查询都共用这个一致性视图;
  • 在读提交隔离级别下,每个语句执行前都会从新算出一个新的视图。

那么,咱们再看一下,在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?

下面是读提交时的状态图,能够看到这两个查询语句的建立视图数组的时机发生了变化,就是图中的 read view 框。

clipboard.png

这时,事务 A 的查询语句的视图数组是在执行这个语句的时候建立的,时序上 (1,2)、(1,3) 的生成时间都在建立这个视图数组的时刻以前。可是,在这个时刻:

  • (1,3) 还没提交,属于状况 1,不可见;
  • (1,2) 提交了,属于状况 3,可见。

因此,这时候事务 A 查询语句返回的是 k=2。

显然地,事务 B 查询结果 k=3。

小结

InnoDB 的行数据有多个版本,每一个数据版本有本身的 row trxid,每一个事务或者语句有本身的一致性视图。普通查询语句是一致性读,一致性读会根据 row trxid 和一致性视图肯定数据版本的可见性。

  • 对于可重复读,查询只认可在事务启动前就已经提交完成的数据;
  • 对于读提交,查询只认可在语句启动前就已经提交完成的数据;

而当前读,老是读取已经提交完成的最新版本。

固然,MySQL 8.0 已经能够把表结构放在 InnoDB 字典里了,也许之后会支持表结构的可重复读。

clipboard.png

相关文章
相关标签/搜索