Mysql的原理解析

文章目录

  • 一、mysql数据结构
  • 二、mysql 三层架构
  • 三、聚集索引和非聚集索引
  • 四、为什么使用索引可以提高查询效率
  • 五、mysql索引失效的场景
  • 六、什么是回表
  • 七、什么是覆盖索引
  • 八、mysql应该基于什么条件来创建索引
  • 九、change buffer
  • 十、mysql性能监控
  • 十一、索引(条件)下推-ICP(index condition pushdown)
  • 十二、MRR(Multi-Range Read Optimization)
  • 十三、索引的优点
  • 十四、如何创建高效的索引
  • 十五、常见的索引失效的场景
  • 十六、mysql执行计划-type类型(const>eq_ref>ref>range>index>all)
  • 十七、MVCC(Mutil Version Concurrency Control 多版本并发控制)
  • 十八、事务隔离级别
  • 十九、ACID是通过什么实现的
  • 二十、mysql日志
  • 二十一、页合并、页分裂
  • 二十二、mysql锁
    • 1. 表级锁
    • 2. 行锁
    • 3. 共享锁(Share Lock)
    • 4. 排它锁(eXclusive Lock)
    • 5. 意向锁
    • 6. 记录锁(Record Locks)
    • 7. 间隙锁(Gap Locks)
    • 8. 临键锁(Next-key Locks)
    • 9. 自增锁

一、mysql数据结构

mysql使用b+tree为底层数据结构,至于为什么使用b+tree而不使用b-tree和红黑树,我们来分析一下。

红黑树:

红黑树存储的数据量大的时候,红黑树的节点层数多,也就是树的高度比较高,查找的底层数据时,查找次数就比较多,即对磁盘IO使用比较频繁,还有就是树的每个节点,存放的数据很少,通过计算本来树的每一层大概需要分配16KB的数据。而红黑树所存的数据远远小于16KB,造成空间的浪费

总结一下就是有两个缺点

  1. 浪费存储空间
  2. 磁盘读取太频繁(我们知道磁盘读取是很慢的,所以要想提高查询效率,就必须要尽可能的减少磁盘IO。)

那么我们可以从以下两点出发进行改进:

  1. 增加树每层的节点数量,这样可以对分配的16KB充分利用,即解决上面的读取浪费的问题
  2. 尽可能的让树的高度减小,使得树显得比较“矮胖”,这样可以减少读取磁盘的次数

那么怎么样才可以实现以上的方法呢?这就需要用到b-tree了。

B-Tree:

一棵m阶的B-Tree有如下特性:

  1. 每个节点最多有m个孩子。
  2. 除了根节点和叶子节点外,其它每个节点至少有Ceil(m/2)个孩子。
  3. 若根节点不是叶子节点,则至少有2个孩子
  4. 所有叶子节点都在同一层,且不包含其它关键字信息
  5. 每个非终端节点包含n个关键字信息(P0,P1,…Pn, k1,…kn)
  6. 关键字的个数n满足:ceil(m/2)-1 <= n <= m-1
  7. ki(i=1,…n)为关键字,且关键字升序排序。
  8. Pi(i=1,…n)为指向子树根节点的指针。P(i-1)指向的子树的所有节点关键字均小于ki,但都大于k(i-1)

当看完上面的特性我是崩溃的,这也太多太复杂了,所以放张图来理解一下。

B-Tree

每个节点占用一个盘块的磁盘空间,一个节点上有两个升序排序的关键字和三个指向子树根节点的指针,指针存储的是子节点所在磁盘块的地址。两个关键词划分成的三个范围域对应三个指针指向的子树的数据的范围域。以根节点为例,关键字为17和35,P1指针指向的子树的数据范围为小于17,P2指针指向的子树的数据范围为17~35,P3指针指向的子树的数据范围为大于35。

模拟查找关键字29的过程:

  1. 根据根节点找到磁盘块1,读入内存。【磁盘I/O操作第1次】

  2. 比较关键字29在区间(17,35),找到磁盘块1的指针P2。

  3. 根据P2指针找到磁盘块3,读入内存。【磁盘I/O操作第2次】

  4. 比较关键字29在区间(26,30),找到磁盘块3的指针P2。

  5. 根据P2指针找到磁盘块8,读入内存。【磁盘I/O操作第3次】

  6. 在磁盘块8中的关键字列表中找到关键字29。

分析上面过程,发现需要3次磁盘I/O操作,和3次内存查找操作。由于内存中的关键字是一个有序表结构,可以利用二分法查找提高效率。而3次磁盘I/O操作是影响整个B-Tree查找效率的决定因素。B-Tree相对于红黑树缩减了节点个数,使每次磁盘I/O取到内存的数据都发挥了作用,从而提高了查询效率。

可以看到B-Tree完美解决了这两个问题,在每个节点都有data数据,且根据特性来维持树的高度。但是每一个页的存储空间是有限的,如果data数据较大会导致每个节点能存储的key数量很小,当数据量很大的时候,同样会导致树的高度增加,从而增加磁盘IO次数,影响查询效率,这是我们不愿意看到的。

B+Tree:

B+Tree是在B-Tree基础上的一种优化,使其更适合实现存储索引结构,InnoDB存储引擎就是用B+Tree实现其索引结构。
在B+Tree中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息,这样可以大大加大每个节点存储的key值数量,降低B+Tree的高度。

B+Tree相对于B-Tree有几点不同:

  1. 非叶子节点只存储键值信息。
  2. 所有叶子节点之间都有一个链指针。
  3. 数据记录都存放在叶子节点中。

由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:

B+Tree

通常在B+Tree上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。
我们可以看到,通过这种巧妙的设计,既减少了树的高度,还存储了更多的数据。

B+Tree 索引为什么可以支持千万级别数据量的查找
分析:
MySQL 官方对非叶子节点(如最上层 h = 1的节点,B+Tree高度为3) 的大小是有限制的,通过执行
SHOW GLOBAL STATUS like 'InnoDB_page_size'
可以得到大小为 16384,即 16k大小。
那么第二层也是16k大小。

假如:B+Tree的表都存满了。索引的节点的类型为BigInt,大小为8B,指针为6B。
最后一层,假如 存放的数据data为1k 大小,那么

  1. 第一层最大节点数为: 16k / (8B + 6B) = 1170 (个);
  2. 第二层最大节点数也应为:1170个;
  3. 第三层最大节点数为:16k / 1k = 16 (个)。

则,一张B+Tree的表最多存放 1170 * 1170 * 16 ≈ 2千万。
所以,通过分析,我们可以得出,B+Tree结构的表可以容纳千万数据量的查询。

二、mysql 三层架构

  • 客户端(client层)

  • 服务端(server层)

    • 连接器(管理连接和一些权限验证)

    • 分析器(语法分析、词法分析;验证sql语句是否正确。)

    • 优化器(优化sql语句,规定执行流程;优化后会形成执行计划)

    • 执行器(取到由优化器制作好的执行计划,跟存储引擎层进行交互)

  • 存储引擎(引擎层)

    • innodb:磁盘

    • myisam:磁盘

    • memory:内存

三、聚集索引和非聚集索引

  • 聚集索引:数据和索引一起存放

    • 主键索引:所有数据都存放在叶子节点

    • 非主键索引:叶子节点只存放主键

  • 非聚集索引:数据和索引分开存放,从索引文件查找到数据后,会拿该数据再去数据文件中进行查找,叶子节点只存放索引列数据

四、为什么使用索引可以提高查询效率

  1. mysql有两个存储引擎,innodb和myisam,innodb使用的是聚集索引,聚集索引就是数据和索引存放在一起,以b+tree的数据结构存放

    • 主键索引:叶子节点存放主键数据和其他数据

    • 非主键索引:叶子节点只存放主键数据

    myisam使用的是非聚集索引,非聚集索引就是数据和索引分开存放,索引文件采用b+tree的数据结构存放,先从索引文件查找数据,再根据查找后的数据去数据文件进行查找,而且叶子节点只存放索引列数据

  2. 使用索引可以提高查询效率,全依赖于b+tree的这种数据结构,也就是通过这种数据结构,可以更快的查询到目标数据。

五、mysql索引失效的场景

  • where语句中包含or时,可能会导致索引失效,or的前后必须都是索引列

  • 使用负向查询,比如,not、!=、<>、!<、!>、not in、not like等

  • 使用内置函数的时候

  • 隐式类型转换的时候,比如user_id是varchar类型,但是sql里面是where user_id = 12,12是数字12没有加引号

  • 关联查询时,两个字段的编码不一致

  • 对索引列进行计算的时候

  • like查询以%开头时,会导致索引失效

  • 联合索引中,违背最左匹配原则,一定会导致索引失效

六、什么是回表

拿innodb举例,innodb有两大类索引,聚集索引和普通索引;聚集索引可以直接获取表数据,查询一遍即可,普通索引树的叶子结点存放的是主键ID,所以普通索引会先查询普通索引树,然后获取到主键ID,再去主键索引树查询一遍,这个过程,称之为回表。

七、什么是覆盖索引

意思就是只需要在一颗索引树上就能获取sql所需的所有列数据,无需回表,速度更快,通俗的说就是给需要查询的字段加上索引,通常使用联合索引。

八、mysql应该基于什么条件来创建索引

  • 在经常搜索的列上创建索引

  • 在经常被用在连接的列上创建索引

  • 在经常使用where子句的列上创建索引

  • 唯一性太差的列不适合单独创建索引,即使会频繁的作为查询条件

  • 更新非常频繁的字段不适合创建索引

九、change buffer

在mysql中数据分为内存和磁盘两个部分;在buffer pool(缓冲池)中缓存热点数据页和索引页,减少磁盘IO;通过changeBuffer就是为了缓解磁盘写的一种手段。

changeBuffer就是在“非唯一索引页”不在buffer pool中时,对页进行了写操作的情况下,先将记录变更缓冲,等未来数据被读取时,再将changeBuffer中的操作merge到原数据页的技术,在mysql5.5之前,叫插入缓冲(insert buffer),只针对insert做了优化;在之后的版本对delete和update也有效了,叫做写缓冲(change buffer)

将changeBuffer中的操作合并到数据页(持久化),得到最新结果的过程称之为merge(合并),以下情况会触发merge

  • 访问这个数据页

  • 后台master线程会定期merage

  • 数据库缓冲池(buffer pool)不够用时

  • 数据库正常关闭时

  • redolog写满时

changeBuffer为什么只针对“非唯一索引页”呢

  1. 唯一索引:

    所有的更新操作都要判断这个操作是否违反唯一性约束,这个操作就要把数据页读入内存才能判断;既然都已经读入内存了,那么直接更新内存会更快,根本没有必要使用change buffer。

  2. 普通索引:

    不需要判断唯一性,在“非唯一普通索引页”不在缓冲池(buffer pool)中,才使用change buffer。

十、mysql性能监控

  1. show profile(需要开启,set profiling=1;)(已经被弃用了,高版本会被废弃掉)

  2. performance schema替代show profile。

    默认是开启的,且5.5版本后才有
    用于监控mysql server在一个较低级别的运行过程中的资源消耗、资源等待等情况。

  3. show processlist:查询连接的线程个数,来观察是查看大量线程处于不正常的状态或者其他不正常的特征

    1. 数据库连接池:

      1. dbcp

      2. c3p0

      3. druid

      4. HiKariCP(spring boot默认推荐的数据库连接池,目前最快)

    2. id:session id

    3. user:操作的用户

    4. host:操作的主机

    5. db:数据库

    6. command:当前状态

      1. sleep:线程正在等待客户端发送新的请求

      2. query:线程正在执行查询或正在将结果发送给客户端

      3. locked:在mysql的服务层,该线程正在等待表锁

      4. analyzing and statistics:线程正在手机存储引擎的统计信息,并生成查询的执行计划

      5. copying to tmp table:线程正在执行查询,并且将其结果集都复制到一个临时表中

      6. starting result:线程正在对结果集进行排序

      7. sending data:线程可能在多个状态之间传送数据,或者在生成结果集或者向客户端返回数据

    7. info:详细的sql语句

    8. time:相应命令执行时间

    9. state:命令执行状态

十一、索引(条件)下推-ICP(index condition pushdown)

  • 索引下推(index condition pushdown )简称ICP,在Mysql5.6的版本上推出,用于优化查询。

  • 在不使用ICP的情况下,在使用非主键索引(又叫普通索引或者二级索引)进行查询时,存储引擎通过索引检索到数据,然后返回给MySQL服务器,服务器然后判断数据是否符合条件 。

  • 在使用ICP的情况下,如果存在某些被索引的列的判断条件时,MySQL服务器将这一部分判断条件传递给存储引擎,然后由存储引擎通过判断索引是否符合MySQL服务器传递的条件,只有当索引符合条件时才会将数据检索出来返回给MySQL服务器 。

  • 索引条件下推优化可以减少存储引擎查询基础表的次数,也可以减少MySQL服务器从存储引擎接收数据的次数。

  • 说白了,就是把本来应该server层做的判断交给了引擎层去做。

十二、MRR(Multi-Range Read Optimization)

  1. MRR技术是MySQL5.6版本开始引入的,当一个表很大并且没有缓存在buffer pool中时,由于二级索引和主键的排列顺序一般情况下是不一样的,在二级索引上使用范围扫描回表读取行数据时会导致产生大量的随机I/O,通过MRR优化,MySQL会通过索引扫描收集相关行数据的主键,将主键值的集合存储到read_rnd_buffer中,然后在buffer中对主键进行排序,最后利用排好序的主键再回表查询。同时,如果缓冲池不够大的话,频繁的离散读还会导致缓存中的页频繁的被替换出缓冲池,然后又不断的被读入缓冲池,若按照主键顺序进行访问的话,可以减少数据页的读取,降低数据页被频繁替换出入缓冲池的情况。

  2. 很明显,对于只返回一行数据的查询,是没有必要 MRR 的,而如果你把 mrr_cost_based 设为 off,那优化器就会通通使用 MRR,这在有些情况下是很 stupid 的,所以建议这个配置还是设为 on,毕竟优化器在绝大多数情况下都是正确的。另外还有一个配置 read_rnd_buffer_size ,是用来设置用于给 rowid 排序的内存的大小。

  3. 显然,MRR 在本质上是一种用空间换时间的算法。MySQL 不可能给你无限的内存来进行排序,如果 read_rnd_buffer 满了,就会先把满了的 rowid 排好序去磁盘读取,接着清空,然后再往里面继续放 rowid,直到 read_rnd_buffer 又达到 read_rnd_buffe 配置的上限,如此循环。

  4. MRR优化的目的就是为了减少磁盘的随机访问,并将随机I/O转化顺序I/O,降低查询过程中的I/O开销,同时减少缓冲池中数据页被替换的频次。

  5. 好处:

    1. 磁盘和磁头不再需要来回做机械运动;

    2. 可以充分利用磁盘预读,比如在客户端请求一页的数据时,可以把后面几页的数据也一起返回,放到数据缓冲池中,这样如果下次刚好需要下一页的数据,就不再需要到磁盘读取。这样做的理论依据是计算机科学中著名的局部性原理:“当一个数据被用到时,其附近的数据也通常会马上被使用。”

    3. 在一次查询中,每一页的数据只会从磁盘读取一次

    MySQL 从磁盘读取页的数据后,会把数据放到数据缓冲池,下次如果还用到这个页,就不需要去磁盘读取,直接从内存读。

    但是如果不排序,可能你在读取了第 1 页的数据后,会去读取第2、3、4页数据,接着你又要去读取第 1 页的数据,这时你发现第 1 页的数据,已经从缓存中被剔除了,于是又得再去磁盘读取第 1 页的数据。
    而转化为顺序读后,你会连续的使用第 1 页的数据,这时候按照 MySQL 的缓存剔除机制,这一页的缓存是不会失效的,直到你利用完这一页的数据,由于是顺序读,在这次查询的余下过程中,你确信不会再用到这一页的数据,可以和这一页数据说告辞了。

十三、索引的优点

  • 减少磁盘扫描,提高检索效率,避免了全表扫描。

  • 提高排序和分组的效率。

  • 将随机IO转化为顺序IO。

  • 提高部分聚合函数的效率,比如min(),max()等。

十四、如何创建高效的索引

  • 在经常用于排序和分组查询的字段上建立索引,可以避免了内存排序和随机I/O。

  • 在选择性较高的字段上建立索引,查看选择性公式select count(distinct a)/count(*) from t1,越接近1越好,一般超过33%就算是比较高效的索引了。

  • 如果没有强烈的业务需求,建议建立自增主键,这样的主键占用空间小,顺序写入,减少页分裂。

  • 利用较短的键值作为索引性能比较好,可能的话尽量使用整数类型。

  • 对于where条件中涉及多个字段时可以考虑建立联合索引,建议将选择性高的列放到 索引最左列,SQL查询时满足最左原则。

  • 对于select后面经常用到的字段可以考虑创建索引,查询时使用覆盖索引查询,避免回表。

  • 索引字段尽量设置为NOT NULL,NULL值会更加运算的复杂度。

  • 如果有 order by 的场景,尽量利用索引的有序性,避免出现using filesort 的情况,影响查询性能。

  • SQL投产前查看执行计划,SQL 性能优化的目标:至少要达到 range 级别,要求是 ref 级别,最好是 consts级别。

  • SQL语句中尽量避免使用左模糊或者全模糊查询,无法利用B+Tree 最左前缀匹配特性。

  • 考虑针对较长字符串型列使前缀索引,区分度可以使用 count(distinct left(列名, 索引长度))/count(*)来确定,请参看上一章的前缀索引部分。

  • 业务上具有唯一特性的字段,即使是组合字段,也建议建成唯一索引,数据库层面避免了脏数据的产生,对insert的影响可以忽略(阿里巴巴开发手册要求)。

  • 在表查询中,建议明确字段,不要使用 * 作为查询的字段列表。

  • 索引不宜过多,一般建议不超过6个,由于索引的创建和维护是有代价的,所以请不要创建不必要的索引。

十五、常见的索引失效的场景

  • 通过索引扫描的行数超过全表的20%-30%时,引擎会认为走全表扫描更有效。

  • 使用联合索引时没有遵循最左原则。

  • where后面出现 or 条件 ,且没有建立单列索引会导致失效。

  • 对索引使用了函数计算。

  • 统计信息不真实(严重不真实),导致执行计划错误。

  • 访问小表时,更倾向于全表扫描。

  • Where条件中对索引列使用左模糊或者全模糊查询。

十六、mysql执行计划-type类型(const>eq_ref>ref>range>index>all)

  • const:查询索引字段,并且表中最多只有一行匹配(好像只有主键查询只匹配一行才会是const,有些情况唯一索引匹配一行会是ref)

  • eq_ref:主键或者唯一索引

  • ref:非唯一索引(主键也是唯一索引)

  • range:索引的范围查询

  • index: (type=index extra = using index 代表索引覆盖,即不需要回表)

  • all:全表扫描(通常没有建索引的列)

十七、MVCC(Mutil Version Concurrency Control 多版本并发控制)

mvcc是为了实现快照读,也就是以乐观锁的形式进行读操作,通过版本链的方式,实现了读-写,写-读的并发执行,提升了系统的性能。

当前读:读取的是数据的最新版本(总是读取到最新的数据)

  • select ...... lock in share mode(共享锁)

  • select ...... for update(排它锁)

  • update

  • delete

  • insert

快照读:读取的是历史版本的记录

  • select ......

事务隔离级别

  • 读未提交(READ UNCOMMITTED)

  • 读已提交(READ COMMITTED)

  • 可重复读(REPEATABLE READ)

  • 可串行化(SERIALIZABLE)

mvcc的实现原理主要依赖于记录中的三个隐藏字段以及undolog、read view来实现的。

隐藏字段:

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID等字段。

  • DB_TRX_ID:6字节,最近修改的事务ID,记录创建这条记录或者最后一次修改该记录的事务ID

  • DB_ROLL_PTR:7字节,回滚指针,指向这条记录的上一个版本,用于配合undolog,指向上一个旧版本。

  • DB_ROW_ID:6字节, 隐藏主键,如果数据表没有主键,那么innodb会自动生成一个6字节的row_id。

undolog:

undolog 被称为回滚日志,表示在进行insert、delete、update操作的时候产生的方便回滚的日志。
当进行insert操作的时候,产生的undolog只在事务回滚的时候需要,并且在事务提交之后可以被立刻丢弃。

当进行update和delete操作的时候,产生的undolog不仅仅在事务回滚的时候需要,在快照读的时候也需要,所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除(当数据发生更新和删除操作的时候都只是设置一下老记录的deleted_bit,并不是真正的将过时的记录删除,因为为了节省磁盘空间,innodb有专门的purge线程来清除deleted_bit为true的记录,如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view 可见,那么这条记录一定是可以被清除的)

不同事务或者相同事务的对同一条记录的修改,会导致该记录的undolog生成一条记录版本线性表,即链表,undolog的链首就是最新的旧记录,链尾就是最早的旧记录。

Read View:

Read View是事务进行快照读操作的时候生成的读视图,在该事务执行快照读的那一刻,会生成一个数据系统当前的快照,记录并维护系统当前活跃事务的ID,事务的ID是递增的。

其实Read View的最大作用是用来做可见性判断的,也就是说当某个事务在执行快照读的时候,对该记录创建一个Read View的视图,把它当做条件去判断当前事务能够看到哪个版本的数据,有可能读取到的是最新的数据,也有可能读取的是当前行记录的undolog中某个版本的数据。

Read View遵循的可见性算法主要是将要被修改的数据的最新记录中的DB_TRX_ID(当前事务ID)取出来,与系统当前其他活跃事务的ID去对比,如果DB_TRX_ID跟Read View的属性做了比较,不符合可见性,那么就通过DB_ROLL_PTR回滚指针去取出undolog中的BD_TRX_ID做比较,即遍历链表中的DB_TRX_ID,直到找到满足条件的DB_TRX_ID,这个DB_TRX_ID所在的旧记录就是当前事务能看到的最新老版本数据。

Read View的可见性规则如下所示:

首先要知道Read View中的三个全局属性:
trx_list:一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
up_limit_id:记录trx_list列表中事务ID最小的ID
low_limit_id:Read View生成时刻系统尚未分配的下一个事务ID。

具体的比较规则如下:

  1. 首先比较DB_TRX_ID < up_limit_id,如果小于,则当前事务能看到DB_TRX_ID所在的记录,如果大于等于则进入下一个判断。
  2. 接下来判断DB_TRX_ID >= low_limit_id,如果大于等于则代表DB_TRX_ID所在的记录在Read View生成后才出现,那么对于当前事务肯定不可见,如果小于,则进入下一个判断。
  3. 判断DB_TRX_ID是否在活跃事务中,如果在,则代表在Read View生成时刻,这个事务还是活跃状态,还没有commit,修改的数据,当前事务也是看不到,如果不在,则说明这个事务在Read View生成之前就已经开始commit,那么修改的结果是能够看见的。

RC、RR级别下的innodb快照读有什么不同:
因为Read View生成的时机不同,从而造成RC、RR级别下的快照读的结果不同。

总结:在RC隔离级别下,是每个快照读都会生成并获取最新的Read View,而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View。

在RR隔离级别下,只靠 MVCC 实现,可以保证可重复读,还能防止幻读,但并不是完全防止。

在“快照读”的情况下,是可以防止幻读的,但是在“当前读”的情况下,只能依靠临键锁(next-key locks)。

比如事务A开始后,执行普通select语句,创建了快照;之后事务B执行insert语句;然后事务A再执行普通select语句,得到的还是之前B没有insert过的数据,因为这时候A读的数据是符合read view可见性条件的数据。这就防止了幻读,此时事务A是快照读

但是,如果事务A执行的不是普通select语句,而是select ... for update等语句,这时候,事务A是当前读,每次语句执行的时候都是获取的最新数据。在未使用临键锁(next-key locks)时,A先执行 select ... where ID between 1 and 10 … for update;然后事务B再执行 insert … ID = 5 …;然后 A 再执行 select ... where ID between 1 and 10 … for update,就会发现,多了一条B insert进去的记录。这就产生幻读了,所以单独靠MVCC并不能完全防止幻读。

十八、事务隔离级别

READ UNCOMMITTED 读取未提交内容

在这个隔离级别,所有事务都可以"看到"未提交事务的执行结果。在这种级别上,可能会产生很多问题,除非用户真的知道自己在做什么,并有很好的理由选择这样做。本隔离级别很少用于实际应用,因为它的性能也不必其他性能好多少,而别的隔离级别还有其他更多的优点。读取未提交数据,也被称为"脏读"。

READ COMMITTED 读取已提交内容

大多数数据库系统的默认隔离级别(但是不是MySQL的默认隔离级别),满足了隔离的早先简单定义:一个事务开始时,只能“看见" 已经提交事务所做的改变,一个事务从开始到提交前,所做的任何数据改变都是不可见的,除非已经提交。这种隔离级别也支持所谓的"不可重复读"。这意味着用户运行同一个语句两次,看到的结果可能是不同的。

REPEATABLE READ 可重复读

MySQL数据库默认的隔离级别。该级别解决了READ UNCOMMITTED隔离级别导致的问题。它保证同一事务的多个实例在并发读取事务时,会“看到同样的“数据行。不过,这会导致另外一个棘手问题"幻读"。InnoDB和Falcon存储引擎通过多版本并发控制机制解决了幻读问题。

SERIALIZABLE 可串行化

该级别是最高级别的隔离级。它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简而言之,SERIALIZABLE是在每个读的数据行上加锁。在这个级别,可能导致大量的超时Timeout和锁竞争Lock Contention现象,实际应用中很少使用到这个级别,但如果用户的应用为了数据的稳定性,需要强制减少并发的话,也可以选择这种隔离级。

  • 脏读

    脏读是指一个事务读取了未提交事务执行过程中的数据。当一个事务的操作正在多次修改数据,而在事务还未提交的时候,另外一个并发事务来读取了数据,就会导致读取到的数据并非是最终持久化之后的数据,这个数据就是脏读的数据。

  • 不可重复读

    不可重复读是指对于数据库中的某个数据,一个事务执行过程中多次查询返回不同查询结果,这就是在事务执行过程中,数据被其他事务提交修改了。
    不可重复读同脏读的区别在于,脏读是一个事务读取了另一未完成的事务执行过程中的数据,而不可重复读是一个事务执行过程中,另一事务提交并修改了当前事务正在读取的数据。

  • 虚读(幻读)

    幻读是事务非独立执行时发生的一种现象,例如事务A批量对一个表中某一列列值为1的数据修改为2的变更,但是在这时,事务B对这张表插入了一条列值为1的数据,并完成提交。此时,如果事务A查看刚刚完成操作的数据,发现还有一条列值为1的数据没有进行修改,而这条数据其实是B刚刚提交插入的,这就是幻读。
    幻读和不可重复读都是读取了另一条已经提交的事务(这点同脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(比如数据的个数)。

十九、ACID是通过什么实现的

A:原子性(通过undolog保证原子性的实现)
C:一致性(是最终的追求,通过AID实现的)
I:隔离性(通过锁)
D:持久性(通过redolog保证持久性的实现)

二十、mysql日志

binlog:
归属于mysql server层,binlog 是逻辑日志,它记录的是操作语句涉及的每一行修改前后的值,在任何存储引擎下都可以使用。
二进制日志,用于主从复制、崩溃恢复,默认不开启。

undolog:
归属于innodb引擎,是Innodb MVCC的重要组成部分,主要用于记录历史版本数据,用于事务回滚。

redolog:
归属于innodb引擎,redolog 是物理日志,它记录的是数据页修改逻辑以及 change buffer 的变更,只能在innodb引擎下使用。

redolog 是搭配缓冲池、change buffer 使用的,缓冲池的作用是缓存磁盘上的数据页,减少磁盘的IO;change buffer 的作用是将写操作先存在内存中,等到下次需要读取这些操作涉及到的数据页时,就把数据页加载到缓冲池中,然后在缓冲池中更新;

事务的持久性是通过redolog实现的(write ahead log(WAL)),即先写日志再写数据;而因为binlog和redolog两种日志属于不同的组件,所以为了保证数据的一致性,要保证binlog和redolog的一致,所以有了二阶段提交的概念。

二阶段提交

详细执行流程:

  1. 执行器先从引擎中找到数据,如果在内存中则直接返回,如果不在内存中,查询后返回。
  2. 执行器拿到数据之后会先修改数据,然后调用引擎接口重新写入数据。
  3. 引擎将数据更新到内存,同时写数据到redolog中,此时处于prepare阶段,并通知执行器执行完成,随时可以操作。
  4. 执行器生成这个操作的binlog日志。
  5. 执行器调用引擎的事务提交接口,引擎把刚刚写完的redolog中的状态改成commit状态,更新完成。

如果不拆分成两个阶段提交:

  • 先写redolog,后写binlog: 假设在redolog写完,binlog还没有写完的时候,mysql进程异常重启。mysql可以把数据恢复回来,但是由于binlog还没写完就崩溃了,这时候binlog里面就没有记录这条数据。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句,所以如果要用这个binlog来恢复临时库或者进行主从复制的时候,就会造成数据与原库不相等。

  • 先写binlog,后写redolog: 如果在binlog写完之后,mysql崩溃,由于redolog还没有写入,mysql重启后这个事务无效,所以这一条数据丢失,但是binlog里面已经记录了这一条数据,所以在之后用binlog进行恢复临时库或者主从复制的时候,就会造成数据与原库不相等。

预写日志 WAL(wite ahead log)

先写日志,再写数据

因为随机读写的效率要低于顺序读写,为了保证数据的一致性,可以先将数据通过顺序读写的方式写到日志文件中,然后再将数据写入到对应的磁盘文件中,这个过程顺序IO的效率要远远高于随机IO,换句话说,如果实际的数据没有写入到磁盘,那么只要日志文件保存成功了,数据就不会丢失,可以根据日志来进行数据的恢复。

二十一、页合并、页分裂

mysql底层的数据结构采用的B+tree,叶子节点中的每一页是由双向链表连接起来的,且顺序排列,一个页默认大小是16kb。

页的内部原理:

  • 页可以空或者填充满(100%),行记录会按照主键顺序来排列。例如在使用AUTO_INCREMENT时,你会有顺序的ID 1、2、3、4等。

  • 页还有另一个重要的属性:MERGE_THRESHOLD。该参数的默认值是50%页的大小,它在InnoDB的合并操作中扮演了很重要的角色

  • 当你插入数据时,如果数据(大小)能够放的进页中的话,那他们是按顺序将页填满的。若当前页满,则下一行记录会被插入下一页(NEXT)中。

  • 根据B+树的特性,它可以自顶向下遍历,但也可以在各叶子节点水平遍历。因为每个叶子节点都有着一个指向包含下一条(顺序)记录的页的指针。例如,页#5有指向页#6的指针,页#6有指向前一页(#5)的指针和后一页(#7)的指针。这种机制下可以做到快速的顺序扫描(如范围扫描)。

页合并:
当你删了一行记录时,实际上记录并没有被物理删除,记录被标记为删除并且它的空间变得允许被其他记录声明使用,当页中删除的记录达到MERGE_THRESHOLD(默认页体积的50%),InnoDB会开始寻找最靠近的页(前或后)看看是否可以将两个页合并以优化空间使用。

页分裂:
页可能填充至100%,在页填满了之后,下一页会继续接管新的记录。但如果存在这种情况呢,插入了一条新数据,新数据应该由页#10进行管理,但是页#10满了,而它的下一页,页#11也满了,数据也不可能不按顺序的插入,这个时候怎么办呢?由于每一页是由双向链表连接起来的

所以mysql的做法是(简化版):

  1. 创建新页
  2. 判断当前页(页#10)可以从哪里进行分裂(记录行层面)
  3. 移动记录行
  4. 重新定义页之间的关系

二十二、mysql锁

按照锁的粒度:

  • 表锁:意向锁、自增锁
  • 行锁:间隙锁、临键锁、记录锁

按照锁的方式:

  • 共享锁:读锁(S)

  • 排它锁:写锁(X)

  • 意向共享锁(IS)

  • 意向排它锁(I)

1. 表级锁

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

  • 表锁

    表锁的语法是lock tables … read/write。可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

    如果在某个线程A中执行lock tables t1 read,t2 wirte;这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许。

  • MDL(meta data lock)元数据锁

    另一类表级的锁是MDL。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL 不需要我们记命令,它是隐式使用的,访问表会自动加上。它的主要作用是防止 DDL(改表结构) 和 DML(CRUD 表数据) 并发的冲突。如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做了变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定不行。

    在MySQL5.5版本引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。

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

给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,需要特别小心,以免对线上服务造成影响。

前提:注意,我这里的事务是手动开启和提交的。而 MDL 锁是语句开始时申请,事务提交才释放。所以,如果是自动提交就不会出现下面的问题。

session A先启动,这时候会对表t加一个MDL读锁。由于session B需要的也是MDL读锁,因此可以正常执行。之后sesession C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。如果只有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。

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

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

    首先要解决长事务(一个事务包括 session A、B、C、D 的操作),事务不提交,就会一直占着MDL 锁。在MySQL的information_schema库的innodb_trx表中,可以查到当前执行的事务。如果要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。

  • 如果要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而又不得不加个字段,该怎么做?

    在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后再通过重试命令重复这个过程。

// N 以秒为单位
alter table tableName wait N add column .....

2. 行锁

mysql 的行索是在引擎实现的,但并不是所有引擎都支持行锁,不支持行锁的引擎只能使用表锁。行锁比较容易理解:行锁就是针对数据表中行记录的锁。比如:事务 A 先更新一行,同时事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。

  • 两阶段锁协议:

    先举个栗子:事务 A 和 B 对 student 中的记录进行操作。

    其中事务 A 先启动,在这个事务中更新两条数据;事务 B 后启动,更新 id = 1 的数据。由于 A 更新的也是 id = 1 的数据,所以事务 B 的 update 语句从事务 A 开始就会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。

    在事务期间,事务 A 实际上持有 id = 1 和 id = 2 这两行的行锁。如果事务 B 更新的是 id = 2 的数据,那么它阻塞的时间就是从 A 更新 id = 2 这行开始(事务 A 更新 id = 1 时,它并没有阻塞),到事务 A 提交结束,比更新 id = 1 数据阻塞的时间要短。 PS:理解这句话很重要。

    在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议,分为加锁阶段和解锁阶段,所有的 lock 操作都在 unlock 操作之后。
    根据这个特性,对于高并发的行记录的操作语句就可以尽可能的安排到最后面,以减少锁等待的时间,提高并发性能。

    假设你负责实现一个电影票在线交易业务,顾客 A 要在影院 B 购买电影票。我们简化一点,这个业务需要涉及到以下操作:

    1. 从用户 A 账户余额中扣除电影票价;
    2. 给影院 B 的账户余额增加这张电影票价;
    3. 记录一条交易日志。

    也就是说,要完成这个交易,需要 update 两条记录,并 insert 一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,怎样安排这三个语句在事务中的顺序呢? 分析一下:

    用户余额是个人的,并发很低;
    影院账户表每个用户都要访问,并发很高;
    交易记录是插入操作问题不大。

    这时将事务步骤安排成 3、1、2 这样的顺序是最佳的。因为此时如果有别的用户买票,它的事务在顺序 1、2 并不会阻塞,而是到了顺序 3 更新影院账户表才会引起阻塞。但它的阻塞时间是最短的,其他操作不需要等待锁。

  • 死锁:

    不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。举个行锁死锁的例子:两个事物相互等待对方持有的锁。

    操作开始:

    • 事务 A 持有 id = 1 的行锁,事务 B 持有 id = 2 的行锁;

    • 事务 A 想更新 id = 2 行数据,不料事务 B 已持有,事务 A 只能等待事务 B 释放 id = 2 的行锁;

    • 同理,事务 B 想更新 id = 1 行数据,不料事务 A 已持有,事务 B 只能等事务 A 释放 id = 1 的行锁。

    两者互相等待,这就是死锁。

    • 如何解决死锁?

      有两个解决策略:

      1. 进入等待,直到超时(加入等待时间

        首先是第一种:直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 设置。 这个参数,默认设置的锁等待时间是 50s在 MySQL 中,像下面这样执行即可:

         // 设置等待时间
         set global innodb_lock_wait_timeout = 500;
        

        上面这个语句表示:当出现死锁以后,第一个被锁住的线程要过 500s 才会超时退出,然后其他线程才有可能继续执行。锁等待时间不能设置过小,有些线程可能并没有发生死锁,只是正常的等待锁。这就会造成本来正常的锁机制出问题,当然也不能太长。

      2. 进行死锁检测,主动回滚某个事务

        再看第二种:死锁检测,主动回滚某个事务。 MySQL 通过设置 innodb_deadlock_detect 的值决定是否开启检测,默认值是 on(开启)。

        主动死锁检测在发生死锁的时候,可以快速发现并进行处理的,但是它也有额外负担。
        什么负担呢?循环依赖检测,过程如下:

        新来的线程 F,被锁了后就要检查锁住 F 的线程(假设为 D)是否被锁,如果没有被锁,则没有死锁,如果被锁了,还要查看锁住线程 D 的是谁,如果是 F,那么肯定死锁了,如果不是 F(假设为 B),那么就要继续判断锁住线程 B 的是谁,一直走知道发现线程没有被锁(无死锁)或者被 F 锁住(死锁)才会终止

        如果大量并发修改同一行数据,死锁检测又会怎样呢?
        假设有 1000 个并发线程同时更新同一行,那么死锁检测操作就是 1000 x 1000 达到 100 万量级的。即便最终检测结果没有死锁,但这期间要消耗大量 CPU 资源。所以,就会出现 CPU 利用率很高,但是每秒却执行不了几个事务的情况。

        解决热点行更新问题:

        那前面两种方案都有弊端,死锁的问题应该怎么解决呢?
        一种比较依赖运气的方法就是:如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。 但是这可能会影响到业务:开启死锁检测,出现死锁就回滚重试,不会影响到业务。如果关闭,可能就会大量超时,严重就会拖垮数据库。

        另一种就是在服务端(消息队列或者数据库服务端)控制并发度: 之所以担心死锁检测会造成额外的负担,是因为并发线程很多的时候,假设我们能在服务端做下限流,比如同一样最多只能允许 10 个线程同时修改。
        一个思想:减少死锁的主要方向,就是控制访问相同资源的并发事务量。

3. 共享锁(Share Lock)

共享锁又称为读锁,简称S锁,顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到最新数据。Mysql会对查询结果中的每行都加共享锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁,否则会被阻塞。

用法:SELECT ... LOCK IN SHARE MODE;

  1. 多个事务的查询语句可以共用一把共享锁;
  2. 如果只有一个事务拿到了共享锁,则该事务可以对数据进行 UPDATE DETELE 等操作;
  3. 如果有多个事务拿到了共享锁,则所有事务都不能对数据进行 UPDATE DETELE 等操作。

使用场景:

  1. 确保某个事务查到最新的数据;
  2. 这个事务不需要对数据进行修改、删除等操作;
  3. 也不允许其它事务对数据进行修改、删除等操作;
  4. 其它事务也能确保查到最新的数据。

对于性能的影响:“虽然共享锁可以给多个事务共享,但一旦有多个事务同时拥有共享锁,则所有事务都不能对数据进行 UPDATE DETELE 等操作,也会导致其它事务的锁等待、锁等待超时、死锁等问题;

4. 排它锁(eXclusive Lock)

排他锁又称为写锁,简称X锁,顾名思义,排它锁不能与其它锁并存,而且只有一个事务能拿到某一数据行的排它锁,其余事务不能再获取该数据行的所有锁。Mysql会对查询结果中的每行都加排他锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请排他锁,否则会被阻塞。

用法:SELECT ... FOR UPDATE;

  1. 只有一个事务能获取该数据的排它锁;
  2. 一旦有一个事务获取了该数据的排它锁之后,其余事务对于该数据的操作将会被阻塞,直至锁释放。

使用场景:

  1. 确保某个事务查到最新的数据;
  2. 并且只有该事务能对数据进行修改、删除等操作。

对于性能的影响: 因为排它锁只允许一个事务获取,所以如果是业务繁忙的情况下,一旦有某个业务不能及时的释放锁,则会导致其它事务的锁等待、锁等待超时、死锁等问题;

5. 意向锁

意向锁意向锁是一种不与行级锁冲突的表级锁,其设计目的主要是为了在一个事务中揭示下一行将要被请求锁的类型。

InnoDB中的两个表锁:

  • 意向共享锁(IS):表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁

  • 意向排他锁(IX):类似上面,表示事务准备给数据行加入排他锁,说明事务在一个数据行加排他锁前必须先取得该表的IX锁。

用户无法操作意向锁,意向锁是由InnoDB自己维护的。说白了,意向锁是帮助InnoDB提高效率的一种手段。

对于insert、update、delete,InnoDB会自动给涉及的数据加排他锁(X);对于一般的select语句,InnoDB不会加任何锁,事务可以通过以下语句加共享锁或排他锁。

  • 共享锁:SELECT ... LOCK IN SHARE MODE;

  • 排他锁:SELECT ... FOR UPDATE;

意向锁解决了什么问题?

假设,事务A获取了某一行的排它锁,尚未提交,此时事务B想要获取表锁时,必须要确认表的每一行都不存在排他锁,很明显效率会很低,引入意向锁之后,效率就会大为改善:

  1. 如果事务A获取了某一行的排它锁,实际此表存在两种锁,表中某一行的排他锁和表上的意向排他锁。
  2. 如果事务B试图在该表级别上加锁时,则受到上一个意向锁的阻塞,它在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。

打个比方,就像有个游乐场,很多小朋友进去玩,看门大爷如果要下班锁门,他必须确保每个角落都要去检查一遍,确保每个小朋友都离开了,才可以锁门。假设锁门是件频繁发生的事情,大爷就会非常崩溃。那大爷想了一个办法,每个小朋友进入,就把自己的名字写在本子上,小朋友离开,就把自己的名字划掉,那大爷就能方便掌握有没有小朋友在游乐场里,不必每个角落都去寻找一遍。例子中的“小本子”,就是意向锁,他记录的信息并不精细,不会记下小明是在玩木马还是在玩蹦床,他只是提醒大爷,小明在游乐场里。

InnoDB行锁模式兼容性列表:

X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容

6. 记录锁(Record Locks)

记录锁是 封锁记录,记录锁也叫行锁,例如:

select * from test where id = 1 for update;
它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。

记录锁、间隙锁、临键锁都是排它锁。

7. 间隙锁(Gap Locks)

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(NEXT-KEY)锁,间隙锁只有在事务隔离级别 RR 中才会产生。

在MySQL中select称为快照读,不需要锁,而insert、update、delete、select for update则称为当前读,需要给数据加锁,幻读中的“读”即是针对当前读。

RR事务隔离级别允许存在幻读,但InnoDB RR级别却通过Gap锁避免了幻读

举个栗子,介绍间隙锁是怎么产生的:
假设有以下表t_student:(其中id为PK,name为非唯一索引)

t_student

这个时候我们发出一条这样的加锁sql语句:
select id,name from t_student where id > 0 and id < 5 for update;
这时候,我们命中的数据为以下着色部分:

t_student

细心的朋友可能就会发现,这里缺少了条id为2的记录,我们的重点就在这里。
select ... for update这条语句,是会对数据记录加锁的,这里因为命中了索引,加的是行锁。从数据记录来看,这里排它锁锁住数据是id为1、3和4的这3条数据。
但是,看看前面我们的介绍——对于键值在条件范围内但不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁。
好了,我们这里,键值在条件范围但是不存在的记录,就是id为2的记录,这里会对id为2数据加上间隙锁。假设这时候如果有id=2的记录insert进来了,是要等到这个事务结束以后才会执行的。

间隙锁的作用:
总的来说,有2个作用,防止幻读和防止数据误删/改。

  1. 防止幻读

如果没有间隙锁,事务A在T1和T4读到的结果是不一样的,有了间隙锁,读的就是一样的了。

  1. 防止数据误删/改

    这个作用比较重要,假设以下场景:

这种情况下,如果没有间隙锁,会出现的问题是:id为2的记录,刚加进去,就被删除了,这种情况有时候对业务,是致命性的打击。加了间隙锁之后,由于insert语句要等待事务A执行完之后释放锁,避免了这种情况。

  1. 使用间隙锁的隐患

    最大的隐患就是性能问题
    前面提到,假设这时候如果有id=2的记录insert进来了,是要等到这个事务结束以后才会执行的,假设是这种场景

这种情况,对插入的性能就有很大影响了,必须等到事务结束才能进行插入,性能大打折扣
更有甚者,如果间隙锁出现死锁的情况下,会更隐晦,更难定位。

总结:

  1. 对于指定查询某一条记录的加锁语句,如果该记录不存在,会产生记录锁和间隙锁,如果记录存在,则只会产生记录锁, 如:WHERE id = 5 FOR UPDATE;
  2. 对于查找某一范围内的查询语句,会产生间隙锁,如:WHERE id BETWEEN 5 AND 7 FOR UPDATE;
  3. 在普通索引列上,不管是何种查询,只要加锁,都会产生间隙锁,这跟唯一索引不一样;
  4. 在普通索引跟唯一索引中,数据间隙的分析,数据行是优先根据普通索引排序,再根据唯一索引排序。

8. 临键锁(Next-key Locks)

临键锁,是记录锁与间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间,即会锁住一段左开右闭区间的数据。

例如查询id1-5的数据,间隙锁只会锁住2、3、4的行数据,而临建锁会锁住1、2、3、4、5这几行数据。

注:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。

9. 自增锁

自增锁是一种比较特殊的表级锁。并且在事务向包含了AUTO_INCREMENT列的表中新增数据时就会去持有自增锁,假设事务 A 正在做这个操作,如果另一个事务 B 尝试执行INSERT语句,事务 B 会被阻塞住,直到事务 A 释放自增锁。
但是上面这种说法也不完全对,因为自增锁的实现方式有多种。

锁模式

  • 传统模式(Traditional)
  • 连续模式(Consecutive)
  • 交叉模式(Interleaved)

分别对应配置项 innodb_autoinc_lock_mode 的值0、1、2。
在这三种模式下,InnoDB 对并发的处理是不一样的,而且具体选择哪种锁模式跟你当前使用的 MySQL 版本还有关系。
在 MySQL 8.0 之前,InnoDB 锁模式默认为连续模式,值为1,而在 MySQL 8.0 之后,默认模式变成了交叉模式。

传统模式

传统模式(Traditional),说白了就是还没有锁模式这个概念时,InnoDB 的自增锁运行的模式。只是后面版本更新,InnoDB 引入了锁模式的概念,然后 InnoDB 给了这种以前默认的模式一个名字,传统模式。
当向包含了 AUTO_INCREMENT 列的表中插入数据时,都会持有这么一个特殊的表锁——自增锁(AUTO-INC),并且当语句执行完之后就会释放。这样一来可以保证单个语句内生成的自增值是连续的。这样一来,传统模式的弊端就自然暴露出来了,如果有多个事务并发的执行 INSERT 操作,AUTO-INC的存在会使得 MySQL 的性能略有下降,因为同时只能执行一条 INSERT 语句。

连续模式

连续模式(Consecutive)是 MySQL 8.0 之前默认的模式,之所以提出这种模式,是因为传统模式存在影响性能的弊端,所以才有了连续模式。
在锁模式处于连续模式下时,如果 INSERT 语句能够提前确定插入的数据量,则可以不用获取自增锁,举个例子,像 INSERT INTO 这种简单的、能提前确认数量的新增语句,就不会使用自增锁,这个很好理解,在自增值上,我可以直接把这个 INSERT 语句所需要的空间流出来,就可以继续执行下一个语句了。
但是如果 INSERT 语句不能提前确认数据量,则还是会去获取自增锁。例如像 INSERT INTO ... SELECT ... 这种语句,INSERT 的值来源于另一个 SELECT 语句。

交叉模式

交叉模式(Interleaved)下,所有的 INSERT 语句,包含 INSERT 和 INSERT INTO ... SELECT ,都不会使用 AUTO-INC 自增锁,而是使用较为轻量的 mutex 锁。这样一来,多条 INSERT 语句可以并发的执行,这也是三种锁模式中扩展性最好的一种。
并发执行所带来的副作用就是单个 INSERT 的自增值并不连续,因为 AUTO_INCREMENT 的值分配会在多个 INSERT 语句中来回交叉的执行。

交叉模式缺点
优点很明确,缺点是在并发的情况下无法保证数据一致性。
要了解缺点具体是什么,还得先了解一下 MySQL 的 Binlog。Binlog 一般用于 MySQL 的数据复制,通俗一点就是用于主从同步。在 MySQL 中 Binlog 的格式有 3 种,分别是:

  • Statement:基于语句,只记录对数据做了修改的SQL语句,能够有效的减少binlog的数据量,提高读取、基于binlog重放的性能。

  • Row:只记录被修改的行,所以Row记录的binlog日志量一般来说会比Statement格式要多。基于Row的binlog日志非常完整、清晰,记录了所有数据的变动,但是缺点也可能会非常多,例如一条update语句,有可能是所有的数据都有修改;再例如alter table之类的,修改了某个字段,同样的每条记录都有改动。

  • Mixed:Statement和Row的结合,怎么个结合法呢。例如像alter table之类的对表结构的修改,采用Statement格式。其余的对数据的修改例如update和delete采用Row格式进行记录。

如果 MySQL 采用的格式为 Statement ,那么 MySQL 的主从同步实际上同步的就是一条一条的 SQL 语句。如果此时我们采用了交叉模式,那么并发情况下 INSERT 语句的执行顺序就无法得到保障。
INSERT 同时交叉执行,并且 AUTO_INCREMENT 交叉分配将会直接导致主从之间同行的数据主键 ID 不同。而这对主从同步来说是灾难性的。

交叉模式

由于insert语句并行执行,所以就会出现上面的情况,两个库生成的主键不一致。
换句话说,如果你的 DB 有主从同步,并且 Binlog 存储格式为 Statement,那么不要将 InnoDB 自增锁模式设置为交叉模式,会有问题。
而后来,MySQL 将日志存储格式从 Statement 变成了 Row,这样一来,主从之间同步的就是真实的行数据了,而且 主键ID 在同步到从库之前已经确定了,就对同步语句的顺序并不敏感,就规避了上面 Statement 的问题。
基于 MySQL 默认 Binlog 格式从 Statement 到 Row 的变更,InnoDB 也将其自增锁的默认实现从连续模式,更换到了效率更高的交叉模式。

鱼和熊掌不可兼得
但是如果你的 MySQL 版本仍然默认使用连续模式,但同时又想要提高性能,该怎么办呢?这个其实得做一些取舍。
如果你可以断定你的系统后续不会使用 Binlog,那么你可以选择将自增锁的锁模式从连续模式改为交叉模式,这样可以提高 MySQL 的并发。并且,没有了主从同步,INSERT 语句在从库乱序执行导致的 AUTO_INCREMENT 值不匹配的问题也就自然不会遇到了。

如何解决自增锁引起的插入性能的问题

  • 自已写一个分布式自增id的发号器,然后把主键上的 AUTO_INCREMENT 去掉;
  • 避免 insert … select … ,这样会导致Bulk inserts(插入的记录行数不能马上确定的),产生表锁;
  • 如果binlog-format是row模式,而且不关心一条bulk-insert的auto值连续(一般不用关心),那么设置innodb_autoinc_lock_mode = 2 可以提高更好的写入性能

tips:
查看引擎状态语句(包括锁、事务相关):
show engine innodb status\G
查看完整描述
set global innodb_status_output_locks=1;

你可能感兴趣的:(Mysql的原理解析)