MySQL原理深度解剖与应用最佳实践

Posted 淡远文摘

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL原理深度解剖与应用最佳实践相关的知识,希望对你有一定的参考价值。

      本文从mysql的数据结构和设计思路出发剖析了索引、事务、锁等机制的实现原理,并对一些常见的MySQL使用特性进行了原理的佐证,引申出一些实用小技巧,本文零零碎碎耗费了本人两个星期左右时间,总结的很完善了,素材和资料来自互联网,核心论点均来自实际演示demo,文章有点长,可以按章节逐步学习,如需转载请标注出处尊重原创,创作不易,谢谢!--  danyuanblog.com

原文传送门

1 索引详解

1.1 简介

  • 什么是索引

    如果把数据库中一张表比作一本书,那索引就可以看做是书的目录,它提供了一种通过索引即可快速检索出需要的内容的一种手段。

  • MySQL索引有哪些种类

    • HASH  以hash结构存储索引信息

    • BTREE  以一种类似平衡二叉数的二三叉树+链表结构组成,叫B+Tree

    • FULLTEXT  全文索引,可以对字符串类型字段建立该索引,该索引可以对单个字段内的内容进行分词,可以做到like '%xxx%'这种检索效果,但是可以走索引,性能相对来说要高,老版本MySQL的innodb不支持,升级到高版本即可支持。如果数据量大,检索效率要求高,建议使用Lucene做全文检索。

  • MySQL索引分类

    • 主键索引

      新增innodb引擎的数据表后,会为设定为主键的字段建立一个聚簇索引

    • 单值索引

      即一个索引只包含单个列,一个表可以有多个单列索引

    • 唯一索引

      索引列的值必须唯一,但允许有空值

    • 复合索引

      即一个索引包含多个列

      复合索引的检索效率比单值索引所需要的更高

      最左匹配原则

  • 索引的使用场景

    • 表数据量大,利用索引查找信息,提升检索效率

    • 字段内容差异明显,如果都是类似的内容,索引便起不到什么作用,反而浪费空间

    • 查询语句中需要排序的字段需要建立索引

    • 联表查询的关联字段需要建立索引

    • 统计或分组的字段也可以建立索引

  • 不适合建立索引的场景

    • 表数据量很小,而且几乎没啥增长趋势

    • 表的数据更新频繁,不建议建立过多的索引,否则会影响更新操作性能,索引的维护也是需要性能开销的

    • 数据重复且分布平均的字段不建议使用索引,没有太大实际效果

    • 如果字段内容长度很大,则不适合建索引,空间浪费大,性能也低

1.2 原理解析

1.2.1 数据结构

这里就以MySQL的innodb的btree索引为例,工作中基本上也都用它。

在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引,也就是聚簇索引,每张表有且仅有一个聚簇索引。

B-Tree和B+Tree

目前大部分数据库系统及文件系统都采用B-Tree或其变种B+Tree作为索引结构,在本文的下一节会结合存储器原理及计算机存取原理讨论为什么B-Tree和B+Tree在被如此广泛用于索引,这一节先单纯从数据结构角度描述它们。

B-Tree

为了描述B-Tree,首先定义一条数据记录为一个二元组[key, data],key为记录的键值,对于不同数据记录,key是互不相同的;data为数据记录除key外的数据。

m 阶的定义:一个节点能拥有的最大子节点数来表示这颗树的阶数举个例子:如果一个节点最多有 n 个key,那么这个节点最多就会有 n+1 个子节点,这棵树就叫做 n+1(m=n+1)阶树。

B-Tree是满足下列条件的数据结构:

1.每个结点x(假设为x)有如下属性:

  • x.n,表示当前存储在结点x中的关键字个数。

  • x.n的各个关键字本身:x.key1, x.key2, … 以升序存放,使得 x.key1 ≤ x.key2 ≤ …

  • x.leaf,是一个布尔值,如果x是叶子结点,则为TRUE, 如果x为内部结点,则为FALSE。

2.每个'内部结点x'还包含x.n+1个指向它的孩子的指针x.c1, x.c2, … 。叶子结点没有孩子结点,所以他的ci属性没有定义。

  • key和指针互相间隔,节点两端是指针,所以节点中指针比key多一个。

  • 每个指针要么为null,要么指向另外一个节点。

3.关键字x.keyi对存储在各子树中的关键字进行分割:如果ki为任意一个存储在以x.ci为根的子树中的关键字,那么:k1 ≤ x.key1 ≤ k2 x.key2 ≤ … ≤ x.keyx.n ≤ kx.n+1难理解可以这么说:

如果某个指针在节点node最左边且不为null,则其指向节点的所有key小于(key1),其中(key1)为node的第一个key的值。

如果某个指针在节点node最右边且不为null,则其指向节点的所有key大于(keym),其中(keym)为node的最后一个key的值。

如果某个指针在节点node的左右相邻key分别是keyi和keyi+1且不为null,则其指向节点的所有key小于(keyi+1)且大于(keyi)。

4.每个叶子结点具有相同的深度,即树的高度h

5.每个结点所包含的的关键字个数有上界和下界。用一个被称作B树的最小度数的估计整数t(t ≥ 2)来表示这些界:除了根结点以外的每个结点必须至少有t-1个关键字。因此,除了根节点以外的每个内部结点至少有t个孩子。(因为上面说了右x.n+1个指向它的孩子的指针)如果树非空,根结点至少有一个关键字。每个结点最多包含2t-1个关键字。因此,一个内部节点至多可有2t个孩子。当一个结点恰好有2t-1个关键字时,称该结点是满的(full)。

下面是一个3阶的B-Tree示意图。

可以很容易看的出几个特点:

  1. m阶树有m个子节点,每个节点最多只能有m-1个数据值

  2. 数据值从左至右,按增序排列。

  3. 同一个父节点下的相邻子节点均类似于二叉树,适合做二分查找。

由于B-Tree的特性,在B-Tree中按key检索数据的算法非常直观:以m阶B-Tree为例,首先从根节点数据值数组进行二分查找,如果找到则返回data,否则对相应区间的指针指向的节点递归进行查找,直到找到节点或找到null指针,前者查找成功,后者查找失败。

关于B-Tree有一系列有趣的性质,例如一个度为d的B-Tree,设其索引N个key,则其树高h的上限为logd((N+1)/2),检索一个key,其查找节点个数的渐进复杂度为O(logdN)。从这点可以看出,B-Tree是一个非常有效率的索引数据结构。另外,由于插入删除新的数据记录会破坏B-Tree的性质,因此在插入删除时,需要对树进行一个分裂、合并、转移等操作以保持B-Tree特性,频繁的数据插入更新操作会有性能问题。再者,由于数据量非常大时数的高度会变大,导致查询效率下降。因此MySQL选择了B+Tree。

B+Tree

B-Tree有许多变种,其中最常见的是B+Tree,例如MySQL就普遍使用B+Tree实现其索引结构。

与B-Tree相比,B+Tree有以下不同点:

  1. 每个节点的指针上限为2d而不是2d+1。

  2. 内节点不存储data,只存储key;叶子节点不存储指针。

下面是一个简单的B+Tree+链表结构的示意图。

MySQL原理深度解剖与应用最佳实践

红黑树等数据结构也可以用来实现索引,但是文件系统及数据库系统普遍采用B-/+Tree作为索引结构。红黑树这种结构,树高h明显要深的多。由于逻辑上很近的节点(父子)物理上可能很远,无法利用局部性,所以红黑树的I/O渐进复杂度也为O(h),效率明显比B-Tree差很多。B+Tree更适合外存索引,原因和内节点出度d有关。

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。下面先介绍内存和磁盘存取原理,然后再结合这些原理分析B-/+Tree作为索引的效率。

1.2.2 MySQL索引分析

  • 聚簇索引  (innodb数据表的数据存储方式)

    MySQL原理深度解剖与应用最佳实践

    使用innodb存储引擎的数据表,默认会为主键字段建立聚簇索引,如果没有主键,默认会以隐含的row_id信息建立一个聚簇索引,用来存储表数据。

    索引的叶子节点存储的就是数据表每行的数据内容。

  • 非聚簇索引(也称之为:辅助索引)

    • 单列索引

      索引叶子节点data域存储相应记录主键的值,如果没有主键,就存储的是row_id值。所以mysql辅助索引查找后,需要再次使用聚簇索引再次查询才能获取到表中的非索引字段信息。

    • 联合索引

      联合索引会把索引包含的字段值按照索引字段顺序进行存储,查找时先查找最左边的,然后依次查找其他字段是否匹配,所以我们在编写SQL时需要遵从最左匹配原则才能走联合索引进行查询。

  • 覆盖索引

    当一个索引包含(或者说是覆盖)需要查询的所有字段的值时,我们称之为覆盖索引。也就是我们查询的结果只需要索引里包含的字段,这样的SQL语句就不需要进行回表查询。

  • 前缀索引和索引选择性

    在对一个比较长的字符串进行索引时,可以仅索引开始的一部分字符,这样可以大大的节约索引空间,从而提高索引效率.但是这样也会降低索引的选择性。

1.2.2 MySQL索引特点

  • 索引需要额外的存储空间

  • 索引会影响数据的插入更新操作性能

  • 查询最左前缀原则

  • 联合索引查询最左匹配原则

  • 查询非索引内字段会导致回表查询,影响性能

  • 大字符串字段建立索引,查询时会导致回表查询,而且存储空间很浪费,性能不佳

1.2.3 索引重建

索引在普遍意义上能够给数据库带来带来提升,但索引的额外开销也是不容小视的,而索引的重建也是维护索引的重要工作之一。

当你对InnoDB进行修改操作时,例如删除一些行,这些行只是被标记为“已删除”,而不是真的从索引中物理删除了,因而空间也没有真的被释放回收。

InnoDB的Purge线程会异步的来清理这些没用的索引键和行,但是依然没有把这些释放出来的空间还给操作系统重新使用,因而会导致页面中存在很多无效的数据块。

如果表结构中包含动态长度字段,那么这些无效的数据块甚至可能不能被InnoDB重新用来存新的行,因为空间长度不足。

  • 为什么要重建索引?

    • 删除的空间没有重用,导致索引出现碎片

    • 删除大量的表数据后,空间没有重用,导致索引树"虚高"

    • 导致查询效率低,浪费存储空间

  • 如何重建索引?

    • 修复索引

      #修复索引
      mysql> REPAIR TABLE tbl_name QUICK;
      #优化索引,重新整理索引及数据
      mysql> optimize table tbl_name;

      对于不支持此命令的存储引擎来说,可以通过一条无意义的alter语句来触发整理,比如:将表的存储引擎更换为当前的引擎,alter table tbl_name engine=innodb

    • 查看索引

      mysql> SHOW INDEX FROM|IN `table_name`;
    • 删除索引

      #方式一
      mysql>DROP index `index_name` ON `table_name` (column list);
      #方式二
      mysql>ALTER TABLE tbl_name DROP [UNIQUE|PRIMARY KEY|INDEX] index_name (column list);
    • 建立索引

      #方式一
      mysql>ALTER TABLE `table_name` ADD INDEX|UNIQUE|PRIMARY KEY|FULLTEXT `index_name` (column list);
      #方式二
      mysql>CREATE [UNIQUE/FULLTEXT] INDEX `index_name` ON `table_name` (column_list);


1.3 MySQL查询与索引相关注意事项

1.3.1 宏观原则

  • 查询的信息越精确越好

  • 查询的范围越小越好

  • 查询的数据量越小越好

  • 能避免回表查询就尽量避免

  • 大数据量表查询必须使用索引

  • 更新频繁的列不适合使用索引

  • 索引不适合建在有大量重复数据的字段上,如性别这类型数据库字段

  • 同一张表不能建立过多的索引,一般 6 个以内

  • 内容过长的字段不适合建立索引

  • 使用 explain 分析你 SQL 的计划

  • 联表查询,先查小表,再查大表

1.3.2 MySQL主键的设计

  • 不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。

  • 在InnoDB中不要用非单调的字段作为主键。因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。

1.3.3 索引的选择

  • 优先使用主键自带的聚簇索引进行查询

  • 查询的字段尽量包含在索引内,避免回表查询

  • 使用联合索引时,注意索引列的顺序,一般遵循最左匹配原则

1.3.4 一些索引失效的场景

  • 比较运算符能用 “=”就不用“<>”和“!=”,“=”增加了索引的使用几率。

  • 应尽量避免在 where 子句中使用 or 来连接条件。使用 or 可能会使索引失效,从而全表扫描。MySQL检查到某个字段扫描的数据太多就会直接扫描全表。

  • 应避免使用字段类型转换,这样会引起索引失效。

  • 应避免在索引列上使用 MySQL 的内置函数(索引值是可以使用内置函数的,比如:time > Date_ADD(NOW(),INTERVAL - 7 DAY)),这样会引起索引失效。

  • 尽量避免对索引列进行运算(列值可以使用,比如:age > 10+1),会引起索引失效。

  • in和 not in 也要慎用,否则会导致全表扫描,如:select id from t where num in(1,2,3) 对于连续的数值,能用 between 就不要用 in 了:select id from t where num between 1 and 3。

  • like '%xxx'也会导致索引失效。

  • 尽量避免大事务操作,减少资源锁定时间,提高系统并发能力。

1.3.5 其他查询技巧

  • 如果明确知道需要查询某些字段,不建议使用 select *。因为它会进行回表查询(查询聚簇索引的叶子节点),增大了数据库服务器的负担,以及它与应用程序客户端之间的网络IO开销。

  • 明知只有一条查询结果,那请使用 “LIMIT 1”。

  • 为列选择合适的数据类型,能用TINYINT就不用SMALLINT,能用SMALLINT就不用INT,道理你懂的,磁盘和内存消耗越小越好嘛。

  • 控制查询范围,将大的DELETE,UPDATE or INSERT 查询变成多个小查询。

  • 如果结果集允许重复,使用UNION ALL 代替 UNION。

  • inner join 、left join、right join,优先使用 Inner join,如果是 left join,左边表结果尽量小

  • 为WHERE、JOIN、ORDER BY中使用到的字段添加索引。

  • 如果插入数据过多,考虑批量插入。

  • distinct 关键字一般用来过滤重复记录,以返回不重复的记录。在查询一个字段或者很少字段的情况下使用时,给查询带来优化效果。但是在字段很多的时候使用,却会大大降低查询效率。

  • 删除冗余和重复索引,重复的索引需要维护,并且优化器在优化查询的时候也需要逐个地进行考虑,这会影响性能的。

  • 可以为空的表字段都默认赋予一个初始值,where 子句中考虑使用默认值代替 null。

  • 不要有超过 3 个以上的表连接,连表越多,编译的时间和开销也就越大。把连接表拆开成较小的几个执行,可读性更高。

  • in和exist的选择,in先子查询再主查询,exist先主查询再子查询。

  • 尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型。

  • 尽量避免使用游标,因为游标的效率较差,如果游标操作的数据超过1万行,那么就应该考虑改写。

2 事务详解

2.1 事务的原理

  • 简介

    数据库事务( transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。

  • 事务的特性 ACID

    • 原子性(Atomicity):对事务中操作的整体要求,事务中的全部操作在数据库中是不可中断的,要么全部完成,要么全部不执行。

    • 一致性(Consistency):对事务间的要求,几个并行执行的事务,其执行结果必须与按预定顺序串行执行的结果相一致。

    • 隔离性(Isolation):事务间的可见性和可操作性要求,事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务必须是透明的。

    • 持久性(Durability): 对结果的存储要求,对于任意已提交事务,系统必须保证该事务对数据库的改变不被丢失,即使数据库出现故障。

  • 大白话讲解

    一个数据库服务能并行提供N个数据库连接,每个数据库连接中可以做一系列数据库操作(增删改查),很可能有多个数据库连接正在操作同一条数据库记录,各个连接对该共享资源的访问就存在竞争。如果每个连接中都是一些查询操作,也不会出现什么问题,如果都存在更新操作,很可能会出现最终结果的不确定性,无法得到准确的结果。

    连接间会互相影响,有可能会出现下面的这些场景:

    几乎大部分业务场景,需要的结果一定是确定的,而且是需要不受其他业务干扰的,操作结束后需要将这种结果需要记录下来。

    • 连接读取的数据可能是旧值,然后基于旧值做了修改,并覆盖了其他连接修改后的结果;

    • 连接可能读取到了其他连接的中间结果;

  • 示例讲解

    • 转账连接001中的操作:事务A账户向B账户转账100元,拆分为以下几个数据库操作,检查A账户余额是否大于100,大于100则扣款成功,否则余额不足转账失败;成功后账户B余额增加100;

    • 转账连接002中的操作:事务A账户向C账户转账50元,拆分为以下几个数据库操作,检查A账户余额是否大于50,大于50则扣款成功,否则余额不足转账失败;成功后账户C余额增加50;

    • 最简单的一种方式就是,让001和002串行执行,这样就一定不会出错,但是这样虽然严格保证了数据的安全和准确性,但如果这个账户是公司账户,账户需要很高频的转账服务,这种串行化的机制,就无法提供高性能的并行转账服务了。

    • 有没有更好的方式,既能保证数据的准确性,又能提升并发性能呢?接下来请看事务的隔离级别讲解。

    • 用户转账场景示例,A账户余额100元

      如果001和002连接间不具备任何协调机制,就存在账户A的余额被透支的风险。

      如何保证账户A的余额安全?

2.2 事务的隔离级别介绍

  • 事务的并发问题

    一般来说,脏读是肯定不允许的,这样会导致程序的执行结果的不确定性,不可重复读是强制要求对已有的数据的更新操作必须同步,不允许并行修改同一条数据。但是不可重复读并不能保证幻读,因为无法对新增的数据进行同步限制,需要避免幻读。

    • 脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

    • 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

    • 幻读:事务A删除了一条数据,然后事务B刚好新增了一条一模一样的数据,事务A操作后发现该条数据依然存在,就好像发生了幻觉一样。

  • 事务隔离级别说明

    常用的的事务隔离级别有如下四种,能解决的事务并发问题场景如下所示

    隔离级别 脏读 不可重复读 幻读
    读未提交(read-uncommitted)
    读已提交(read-committed)
    可重复读(repeatable-read)
    串行化(serializable)

    事务隔离级别越高,数据安全性、一致性更高,但性能越低,一般的业务场景使用read-committed级别就足够了。

  • binlog的格式

    eg. SET SESSION binlog_format = 'ROW';

    • STATEMENT  基于SQL语句的复制

    • ROW  基于行的复制

    • MIXED  混合模式复制

  • MySQL的事务隔离级别

    • MySQL的默认事务隔离级别为: repeatable-read,默认的binlog_format是STATEMENT

    • 如果需要使用read-committed,需要修改binlog的格式为row

    • 可以修改整体的事务隔离级别,也可以修改当前会话的事务隔离级别

    • MySQL默认会自动为你的单条更新操作添加事务,并自动提交

2.3 MySQL事务隔离级别的使用示例和场景说明

以下示例用来演示使用不同事务隔离级别时,数据的可见性和互斥性

表结构:

CREATE TABLE `dept` (
`deptno` int(11) NOT NULL,
`dname` varchar(50) DEFAULT NULL,
`loc` varchar(50) DEFAULT NULL,
PRIMARY KEY (`deptno`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.3.1 读未提交(read-uncommitted)

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;start TRANSACTION;
执行select loc from dept where dname='销售部';   结果:广西 开启事务set SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;start TRANSACTION;

执行更新操作update dept set loc='云南' where dname='销售部';
再次执行select loc from dept where dname='销售部';   结果:云南
commit; commit;

可以读取其他事务内还未提交的更新结果,会导致脏读的现象发生。

2.3.2 读已提交(read-committed)  

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;start TRANSACTION;
执行select loc from dept where dname='销售部';   结果:广西 开启事务set SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;start TRANSACTION;

执行更新操作并提交update dept set loc='云南' where dname='销售部';
再次执行select loc from dept where dname='销售部';   结果:广西

commit;
再次执行select loc from dept where dname='销售部';   结果:云南
commit;

只能读取到其他事务已经提交成功后的更新数据,但是可以发现同一事务中两次查询出来的值是不一样的,读已提交不能解决可重复读的问题。

2.3.3 可重复读(repeatable-read)  

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;
执行select loc from dept where dname='销售部';   结果:广西 开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

执行更新操作并提交update dept set loc='云南' where dname='销售部';
再次执行select loc from dept where dname='销售部';   结果:广西

commit;
再次执行select loc from dept where dname='销售部';   结果:广西
commit;

可以看到即使别的事务对记录做的修改已经提交,当前事务还是只能读取之前的旧值,保证了数据的一致性,可重复读的隔离级别下使用了MVCC机制,select操作不会更新版本号,是快照读(历史版本)。无法避免幻读,因为无法掌控不存在的记录。

2.3.4 串行化(serializable)  

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;start TRANSACTION;
执行select loc from dept where dname='销售部';   结果:广西 开启事务set SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;start TRANSACTION;

执行select loc from dept where dname='销售部';   结果:广西

执行更新操作update dept set loc='云南' where dname='销售部'; 结果直接被阻塞了
再次执行select loc from dept where dname='销售部';   结果:广西 阻塞中。。。。。。(show OPEN TABLES where In_use > 0; 结果:dept表被锁了)
commit; 阻塞中。。。。。。

执行更新操作成功(如果等太长时间,会失败)
再开启事务set SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;start TRANSACTION;
执行select loc from dept where dname='销售部'; 结果被阻塞了
阻塞中。。。。。。 commit;
打印结果:云南  (只有等待更新操作的事务提交后才能解除阻塞状态)

事务隔离级别为serializable时,数据库更新操作会锁表(如果都是查询操作的事务,不会锁表),会阻塞所有操作,包括查询操作也会被阻塞,因此不会出现幻读的情况,这种隔离级别并发性极低,开发中很少会用到。

2.3.5 什么是MVVC

  • 说明

MVVC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)是一种基于多版本的并发控制协议,只有在InnoDB引擎下存在。MVCC是为了实现事务的隔离性,通过版本号,避免同一数据在不同事务间的竞争,你可以把它当成基于多版本号的一种乐观锁。当然,这种乐观锁只在事务级别提交读和可重复读有效。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能。

不仅是MySQL,包括Oracle,PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。

可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。

MVCC的实现方式有多种,典型的有乐观(optimistic)并发控制 和 悲观(pessimistic)并发控制。

MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作。其他两个隔离级别和MVCC不兼容,因为 READ UNCOMMITTED 总是读取最新的数据行,而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

  • MVVC小结

    • 记录类型只有增加和更新操作,删除了的记录只是修改一下标记,如果删除id=xxx的记录再新增,其实也是在原来的记录上进行修改。

    • 对数据的读分为两种,快照读和当前读。快照读指读特定版本的记录副本信息;当前读指读记录的最新信息(已提交成功);

      UPDATE、DELETE、INSERT、SELECT …  LOCK IN SHARE MODE、SELECT … FOR UPDATE是当前读。

      可重复读就是通过快照读来实现的,读指定版本的记录副本。

    • 在RR级别下,快照读是通过MVVC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。

  • 可重复读级别下使用MVVC后的特点

    • 为不同事务对同一记录的更新操作各自创建一份副本

    • 同一事务中的查询会保存记录的初始版本和修改后的版本

    • 在某事务执行的过程中,如果其他事务新增一条主键未曾使用过的记录并提交后,可能会导致幻读的发生,因为其他事务可以查询到它的存在,并能正常对其进行删除、修改等操作。

2.3.6 总结

  1. MySQL默认的隔离级别为可重复读(repeatable-read),我们平时工作中一般也使用这种事务隔离级别

  2. 事务隔离级别为读已提交时,写数据只会锁住相应的行。

  3. 事务隔离级别为可重复读(repeatable-read)时,如果检索条件有索引(包括主键索引)的时候,默认加锁方式是next-key 锁;如果检索条件没有索引,更新数据时会锁住整张表。一个间隙被事务加了锁,其他事务是不能在这个间隙插入记录的,这样可以防止幻读。

  4. 事务隔离级别为串行化(serializable)时,数据库更新操作会锁表,其他事务的查询、更新操作都会被阻塞,一般实际情况中不会使用此级别

  5. 事务隔离级别为读未提交(read-uncommitted)时,各事务中操作同一资源时会互相影响,导致脏读发生,所以工作中一般不使用。

  6. 隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。

  7. 事务可以嵌套

3 锁详解

锁的作用简介

  • 为什么需要加锁呢?

    前面我们分析了多个事务并发可能带来的几个问题,如:脏读、不可重复读、幻读等异常情况发生。

    而不同的事务控制级别可以防止某些异常的发生,那需要具体是如何实现的呢,这里就需要使用锁的特性了。

  • 锁的作用

    锁是一种多线程下访问统一共享资源时,用于控制线程访问共享资源的权限。锁的种类有:互斥锁(写锁)、共享锁(读锁)等等。

    • 互斥锁即同一时间段有且仅有一个线程同时占用锁,其他线程被阻塞,知道该线程操作完成释放锁后,别的线程才能被唤醒继续使用,一般用在多线程更新共享资源的场景下,one by one access,在查询语句后面增加LOCK IN SHARE MODE,MySQL 就会对查询结果中的每行都加共享锁;

    • 共享锁是读取操作创建的锁,其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁, multi read thread access,pause the update operate。

  • 锁的应用场景

    • 多线程环境下,需要控制不同线程对共享资源同一时间段的占用权。

    • 多线程间的同步可以使用锁。

    • 多线程环境下需要保证记录的一致性时。

  • 加锁原则

    • 尽量追求无锁化设计

    • 锁的粒度要细

    • 锁的时间越短越好

    • 持有的锁越少越好

3.1 锁类型简介

从对共享资源的访问方式(读、写)角度把特定场景下使用的锁进行分类,就有如下几种锁:

  • 读锁(共享锁)

    允许不同事务同时查询同一记录信息,但会阻塞同一时刻想要更新该记录的事务,直到所有的查询事务均提交后,更新记录的事务才能继续执行。

    • 读读ok

    • 读写互斥

    • 写写ok

  • 写锁(排它锁、独占锁)

    同一时间段只允许一个事务访问某共享资源,其他事务只能等其提交事务后才能依次访问,但是并不会阻塞其他事务的查询操作。

    • 读读ok

    • 读写ok

    • 写写互斥

  • 读写锁

    其实就是读锁和写锁一起组成了读写锁,两把锁协同工作,MySQL的表锁就是类似这种,读读可以并行,读写或者写写都只能串行。

    • 读读ok

    • 读写互斥

    • 写写互斥

3.2 行锁

MySQL可以对某条记录加锁,称之行锁,可以加互斥锁或共享锁。锁的持有都以事务为单位,同一个事务可以持有多个行锁。

特点:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

最大程度的支持并发,同时也带来了最大的锁开销。

加锁方式如下:

  • 更新记录时,会自动对需要修改的行添加互斥锁

  • 可以显示的使用SELECT …  LOCK IN SHARE MODE为某些记录添加读锁(共享锁)

  1. 更新记录添加互斥锁示例:

    事务A 事务B 事务C
    开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

    执行select * from dept where deptno = 40 lock in share mode;  成功返回结果 开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION; 开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

    执行update dept set loc='湖南' where deptno=40; 阻塞中。。。 执行select * from dept where deptno = 40 lock in share mode;  成功返回结果
    commit; 阻塞中。。。

    阻塞中。。。 commit;
    再次开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION; 执行成功
    执行select * from dept where deptno = 40 lock in share mode; 阻塞中。。。


    commit;
    执行成功

    commit;

    可以看到当我们修改同一条记录时,先修改的事务可以顺利执行,后修改的事务只能等到先修改的事务提交后才能继续执行,等待过久还会因为超时而失败。

  2. 显示的添加读锁示例

    事务A 事务B
    开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;
    执行update dept set loc='湖南' where deptno=40;  成功 开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

    执行update dept set loc='湖南' where deptno=40; 阻塞中。。。
    commit; 阻塞中。。。

    执行成功

    commit;

    可以看到读读之间可以互不影响,读写之间是互斥的,同一时刻只能有一种在执行。

  3. 需要注意的是:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁!

    只有执行计划真正使用了索引,才能使用行锁:即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划(可以通过 explain 检查 SQL 的执行计划),以确认是否真正使用了索引。

    由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然多个session是访问不同行的记录, 但是如果是使用相同的索引键, 是会出现锁冲突的(后使用这些索引的session需要等待先使用索引的session释放锁后,才能获取锁)。应用设计的时候要注意这一点。

3.3 表锁

MySQL里面表级别的锁有两种,一种是表锁,一种是元数据锁(MDL),元数据锁也不止用于表,也用于其他资源,后续会详细介绍。

表锁的特点:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。

表锁也分为两种:写锁、读锁,加锁方式如下:

  • 为表加读锁 lock tables xxx read,解锁unlock table命令用于接触当前连接加的所有表锁

  • 为表加读锁 lock tables xxx write,解锁unlock tables

需要注意的是:

为表加读锁后,所有事务均无法对表数据做任何更新操作,都只允许查询数据。

为表加写锁后,为独占锁,只有当前事务能对表做读写操作,其他事务的所有操作均会阻塞。

3.4 全局锁

全局锁,是指对整个MySQL数据库加锁,对应的命令是flush tables with read lock;(以下简称FTWRL)

当你需要让整个库处于只读模式的时候,可以使用这个语法,它的应用场景,一般是在全库逻辑备份的时候。我们知道MySQL自带的mysqldump逻辑备份工具可以使用--single-transaction参数来进行备份,因为Innodb存储引擎支持事务和MVCC的原理,所以该备份方法没有问题。但是,在你使用MyISAM等存储引擎时,该语句可以保证在备份期间的数据一致性。而

--single-transaction方法只适用于所有的表使用事务引擎的库;

3.5 元数据锁

MDL元数据锁是指在对一个表做增删改查的时候,MySQL会对该表加MDL读锁,防止另外一个线程对该表结构做变更操作,当对一个表做表结构变更的时候,会对该表加MDL写锁。MDL锁不需要显式使用,在访问一个表的时候会被自动加上。

MDL出现的初衷就是为了保护一个处于事务中的表的结构不被修改。MDL是事务级别的,只有在事务结束后才会释放。在此之前,其实也有类似的保护机制,只不过是语句级别的。

需要注意的是,MDL不仅仅适用于表,同样也适用于其它对象,如:事件、触发器、存储过程等等资源

3.5.1 MDL的锁类型

锁名称 锁类型 说明 适用语句
MDL_INTENTION_EXCLUSIVE 共享锁 意向锁,锁住一个范围 任何语句都会获取MDL意向锁,然后再获取更强级别的MDL锁。
MDL_SHARED 共享锁,表示只访问表结构

MDL_SHARED_HIGH_PRIO 共享锁,只访问表结构 show create table 等只访问INFORMATION_SCHEMA的语句
MDL_SHARED_READ 访问表结构并且读表数据 select语句LOCK TABLE ... READ
MDL_SHARED_WRITE 访问表结构并且写表数据 SELECT ... FOR UPDATEDML语句
MDL_SHARED_UPGRADABLE 可升级锁,访问表结构并且读写表数据 Alter语句中间过程会使用
MDL_SHARED_NO_WRITE 可升级锁,访问表结构并且读写表数据,并且禁止其它事务写。 Alter语句中间过程会使用
MDL_SHARED_NO_READ_WRITE 可升级锁,访问表结构并且读写表数据,并且禁止其它事务读写。 LOCK TABLES ... WRITE
MDL_EXCLUSIVE 写锁 禁止其它事务读写。 CREATE/DROP/RENAME TABLE等DDL语句。


3.5.2 几种典型语句的加(释放)锁流程

  1. select语句操作MDL锁流程

    1)Opening tables阶段,加共享锁

       a)  加对象级别的MDL_SHARED_READ锁

    2)事务提交阶段,释放MDL锁

       a)  释放MDL_SHARED_READ锁

  2. DML语句操作MDL锁流程

    1)Opening tables阶段,加共享锁

    a)  加global类型的MDL_INTENTION_EXCLUSIVE锁

    b)  加对象级别MDL_SHARED_WRITE锁   2)事务提交阶段,释放MDL锁

      a)  释放MDL_INTENTION_EXCLUSIVE锁

      b)  释放MDL_SHARED_WRITE锁

  1. alter操作MDL锁流程

    1)Opening tables阶段,加共享锁

       a)  加global类型的MDL_INTENTION_EXCLUSIVE锁

       b)  加对象级别的MDL_SHARED_UPGRADABLE锁,升级到MDL_SHARED_NO_WRITE锁

    2)操作数据,copy data,流程如下:

       a)  创建临时表tmp,重定义tmp为修改后的表结构

       b)  从原表读取数据插入到tmp表

    3)将MDL_SHARED_NO_WRITE读锁升级到MDL_EXCLUSIVE锁

       a)  删除原表,将tmp重命名为原表名

    4)事务提交阶段,释放MDL锁

       a)  释放MDL_INTENTION_EXCLUSIVE锁

       b)  释放MDL_EXCLUSIVE锁

一般而言,我们关注MDL锁,大部分情况都是线上出现异常了。那么出现异常后,我们如何去判断是MDL锁导致的呢。监视MDL锁主要有两种方法,一种是通过show  processlist命令,判断是否有事务处于“Waiting for table metadata lock”状态,另外就是通过mysql的profile,分析特定语句在每个阶段的耗时时间。

3.5.3 常见元数据锁的争用场景

我们在工作中一般都遇到过,需要不停服对一些已有数据表做表结构更新操作,有下面这些场景:

  • 在线修改表结构,添加、修改、删除字段等等

  • 在线修改表名

可以明确的说,这是一个不好的习惯,我们在设计之初最好就要尽可能把必要的字段预留出来,非必要的字段可以通过其他方式的存储方案。但是在现实场景下确实会有这种需求,对于一些更新频繁的表操作需慎重,最保险就是停服再修改。

alter修改表结构操作在opening阶段会将锁升级到MDL_SHARED_NO_WRITE,rename阶段再将升级为MDL_EXCLUSIVE,由于MDL_SHARED_NO_WRITE与MDL_SHARED_WRITE互斥,所以先执行alter或先执行DML语句,都会导致语句阻塞在opening tables阶段。

MySQL5.6之后便支持online DDL 了,可以错开业务高峰期进行表结构的在线更新。

3.6 间隙锁

3.6.1 说明

对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。间隙锁在InnoDB的唯一作用就是防止其它事务的插入操作,以此来达到防止幻读的发生,所以间隙锁不分什么共享锁与排它锁。

很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。

InnoDB使用间隙锁的目的

  • 防止幻读,以满足相关隔离级别的要求;

  • 满足恢复和复制的需要:

MySQL 通过 BINLOG 录入执行成功的 INSERT、UPDATE、DELETE 等更新数据的 SQL 语句,并由此实现 MySQL 数据库的恢复和主从复制。MySQL 的恢复机制(复制其实就是在 Slave Mysql 不断做基于 BINLOG 的恢复)有以下特点:

  • 一是 MySQL 的恢复是 SQL 语句级的,也就是重新执行 BINLOG 中的 SQL 语句。

  • 二是 MySQL 的 Binlog 是按照事务提交的先后顺序记录的, 恢复也是按这个顺序进行的。

由此可见,MySQL 的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读。

要禁止间隙锁的话,可以把隔离级别降为读已提交,或者开启参数innodb_locks_unsafe_for_binlog。

  • 查看MySQL是否开启了间隙锁

    #查看是否开启间隙锁:
    mysql> show variables like 'innodb_locks_unsafe_for_binlog';
    +--------------------------------+-------+
    | Variable_name                 | Value |
    +--------------------------------+-------+
    | innodb_locks_unsafe_for_binlog | OFF   |
    +--------------------------------+-------+
    1 row in set
  • 关闭间隙锁(gap lock)方法:

    在my.cnf里面的[mysqld]添加

    [mysqld]
    innodb_locks_unsafe_for_binlog = 1

    重启MySQL后生效.

3.6.2 间隙锁使用演示

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

执行更新操作并提交select * from dept where deptno > 50 and deptno < 80 for update;
执行插入操作insert dept values(90,"测试部","江西"); 成功
执行插入操作insert dept values(70,"测试部","江西"); 阻塞中。。。。。。
阻塞中。。。。。。 commit;
插入成功
commit;

可以看到序号在50-80间的不存在记录被某个事务上锁后,其他事务无法插入序号在这个区间内的新记录,这就是间隙锁的作用。

间隙锁一般和行锁一起使用,达到将整个区间的记录同时占用。

需要注意的是,如果锁定的范围太大,锁定区间可能会升级为表锁。

3.7 锁升级

前面讲了很MySQL各种类型的锁,我们已经知道,行锁的粒度细,发生锁冲突的概率最低,并发度也最高,但是加锁的开销高;表锁的粒度粗,发生锁冲突的概率最高,并发度也最低,但是加锁的开销也低。那么哪些场景会使用行锁或者表锁呢?

使用行锁的条件:

  • 查询语句中必须使用索引字段查询,因为行锁加在索引上的

  • 查询语句中覆盖的记录数占该表总数据量比较小,才会使用行锁

哪些情况会使用表锁呢?

  • 未使用索引字段(包括聚簇索引)进行查询加锁的,会直接上表锁

  • 大数据量的加锁也会直接升级为表锁

所以,同一条查询语句,在不同数据量级别和不同数据重复率的场景下有可能会使用行锁,也有可能使用表锁,这取决于MySQL的执行计划选择哪种查询方式,和对加锁开销的预估情况所做的决定。

3.8 死锁现象

3.8.1 说明

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁的产生条件:

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。

  • 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。

  • 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

大白话解释如下:

事务A 事务B
开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;
执行select * from dept where deptno = 40 lock in share mode;  成功返回结果 开启事务set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;start TRANSACTION;

执行select * from dept where deptno = 50 lock in share mode;  成功返回结果

执行select * from dept where deptno = 40 lock in share mode;  失败,因为检测到了死锁
执行select * from dept where deptno = 50 lock in share mode;失败,因为检测到了死锁

可以看到事务A先成功获取到了deptno=40的行锁,事务B也成功获取了deptno=50的行锁,但当事务B或A试图再去获取对方已经占用的行锁资源时,就会形成一个互相等待的现象,两个事务均进入了一个等待对方已持有行锁的状态,这种现象就称之为死锁现象。

3.8.2 死锁的预防

  1. 破坏占有并等待条件:要破坏这个条件,就要求每个进程必须一次性的请求它们所需要的所有资源,若无法全部获取就等待,直到满足为止,也可以采用事务机制,确保可以回滚,即把获取、释放资源做成原子性的。这个方法实现起来可能会比较困难,因为某些情况下,进程并不能事先直到自己需要哪些资源,也有时候并不需要分配到所有资源就可以运行。

  2. 破坏不可剥夺条件:一个已占有资源的进程若要再申请新的资源,它必须先释放已占有的资源。若随后再需要这些资源,需要重新申请。

  3. 破坏循环等待条件:将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序(升序或降序)做操作。

一般我们会使用第三种方式,不同事务对共享资源访问的顺序保持一致便无法产生死锁。

3.8.3 解除死锁的方法

如果发生了死锁情况,MySQL提供了一套自动检测死锁的机制,由于事务内操作不当引起的死锁均能被发现,一旦发现,会对发生死锁的事务进行回滚处理。

但是如果是人为全局加锁导致死锁,MySQL无法检测到死锁的发生。

3.9 查看锁

3.9.1 正在执行的事务,锁定状态检查

  1. 查看当前的事务

    SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX;
  2. 查看当前锁定的事务

    SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
  3. 查看当前等锁的事务

    SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;


3.9.2 检查锁的整体占用情况

  • 查看线程

    #查看所有线程,可以看到用户拥有权限下的所有任务
    SHOW PROCESSLIST
    #停止某一个任务
    KILL 420821;
  • 查看表锁

    show OPEN TABLES where In_use > 0;
  • 查看造成死锁的sql语句,分析索引情况,然后优化sql语句

    • 查看服务器状态

      show status like '%lock%';
    • 查看超时时间

      show variables like '%timeout%';

3.10 总结

  • 从设计上尽量降低锁发生的概率

  • 精心设计索引, 并尽量使用索引访问数据, 使加锁更精确, 从而减少锁冲突的机会

  • 建议使用短事务,降低锁冲突的几率

  • 单一事务内锁定的资源越少越好,可有效降低锁争用的概率

  • 不同业务需要访问多个共享数据时,建议使用相同的顺序进行访问,降低死锁发生的概率

  • 尽量用等值条件访问数据,避免范围访问数据产生的间隙锁对并发插入的影响


附录

参考文章

mysql metadata lock 一

mysql metadata lock 二

MySQL索引背后的数据结构及算法原理

BTree和B+Tree

MySQL之MVVC原理

以上是关于MySQL原理深度解剖与应用最佳实践的主要内容,如果未能解决你的问题,请参考以下文章

MySQL运维内参:MySQLGaleraInception核心原理与最佳实践 完整版pdf 下载

在android中使用底部导航的最佳实践:活动与片段

《卷积神经网络的Python实现》PDF代码+《解析深度学习卷积神经网络原理与视觉实践》PDF分析

《深度学习:原理与应用实践》中文版PDF

分享《深度学习:原理与应用实践》+PDF+张重生

深度解剖HashMap底层原理