MySQL系列-终于搞明白了事务和锁了

一、MySQL中的事务

1.1.MySQL中的存储引擎

在Mysql中,是否支持事务是由存储引擎决定的,以下是Mysql官网关于部分存储引擎特点的摘抄

FeatureMyISAMMemoryInnoDBArchiveNDB
TransactionsNoNoYesNoYes
Locking granularityTableTableRowRowRow
MVCCNoNoYesNoNo

从表中可以看出

  1. InnoDB和NDB是支持事务的
  2. 锁粒度:InnoDB是支持行级别的锁,MyISAM和Memory只支持表级别锁
  3. MVCC:多版本控制,只有InnoDB支持.

本篇将主要讲解InnoDB,MySQL 在 5.1 之前版本默认存储引擎是 MyISAM,5.1 之后版本默认存储引擎是 InnoDB.

InnoDB的官方描述

InnoDB: The default storage engine in MySQL 8.0. InnoDB is a transaction-safe (ACID compliant) storage engine for MySQL that has commit, rollback, and crash-recovery capabilities to protect user data. InnoDB row-level locking (without escalation to coarser granularity locks) and Oracle-style consistent nonlocking reads increase multi-user concurrency and performance. InnoDB stores user data in clustered indexes to reduce I/O for common queries based on primary keys. To maintain data integrity, InnoDB also supports FOREIGN KEY referential-integrity constraints. For more information about InnoDB, see Chapter 15, The InnoDB Storage Engine.

特点如下:

  1. 事务安全,保证事务ACID特性
  2. 支持事务提交commit、事务回滚rollback、崩溃恢复
  3. 行级别锁
  4. 支持一致性的非锁定读,提高了用户并发性和效率
  5. 用户数据存储在聚簇索引
    1. 根据主键查找IO比较少
  6. 支持外键,通过完整性约束,保证用户数据完整性,
MyISAM的官方描述

These tables have a small footprint. Table-level locking limits the performance in read/write workloads, so it is often used in read-only or read-mostly workloads in Web and data warehousing configurations.

特点:

  1. 表的体积小
  2. 支持表级别锁定,这样也限制了读写效率
  3. 经常被用到只读或者读多写少的web应用或数仓配置

1.2.事务的特性-ACID

数据库事务必须保存ACID特性,ACID是指:

  1. A: Atomicity 原子性,一个事务中的操作要不全部成功,要不全部失败
  2. C:Consistency一致性,事务保证将数据库从一种一致性状态转换到下一种一致性状态,不破环数据的完整性
  3. I: Isolation 隔离性 ,事务的隔离性要求每个读写事务的对象对其他事务的操作对象能相互隔离.
  4. D: Durability 持久性,事务一旦提交,其结果就是永久的,即使数据库发生突然崩溃宕机,数据库也能恢复数据.

接下来看在MySQL中是如何实现事务的.

1.3.MySQL中的事务模型

MySQL官方对InnoDB事务的描述:

The InnoDB transaction model aims to combine the best properties of a multi-versioning database with traditional two-phase locking. InnoDB performs locking at the row level and runs queries as nonlocking consistent reads by default, in the style of Oracle. The lock information in InnoDB is stored space-efficiently so that lock escalation is not needed. Typically, several users are permitted to lock every row in InnoDB tables, or any random subset of the rows, without causing InnoDB memory exhaustion.

  1. InnoDB事务模型旨在将多版本数据库的最佳特性和两阶段锁相结合. InnoDB是在行级别执行锁定并且默认情况下查询是一致性的非锁定读,类似Oracle的模式.
  2. InnoDB中的锁信息存储在特定的空间中,所以不需要锁升级.
  3. 通常,允许多个用户可以同时锁定表中的行,或者多行,而不需要多余的内存消耗

我们从中可以提取几个关键字:

  1. 多版本数据
  2. 两阶段锁定
  3. 非锁定一致性读
  4. 一个表可以允许多个锁
  5. 锁作用在记录上

那事务到底是怎么回事?

假设你已经知道了MySQL的一些知识,如binlog,redo log, undo log这些.

如图:

在这里插入图片描述

当客户端开启了一个事务:

  1. 用户提交到MySQL服务一组sql,当前这组sql可能这样,这个过程可能不是一次提交,
    1. 用户先从MySQL的某一张表读取一条或多条记录
    2. 用户服务,进行数据运算
    3. 用户可能新增、更新、删除一条或者多条记录
  2. MySQL服务层收到每次的sql操作后会做如下操作
    1. 解析sql
    2. 优化sql
    3. 根据执行计划成本分析,选择最合适的执行逻辑
    4. 选择存储引起,调用存储引擎SQL接口
  3. 存储引擎
    1. 执行SQL逻辑
    2. 如果内存池中包含当前操作的数据,直接操作,否则,从磁盘中将数据加载到内存池
    3. 如果是修改数据操作,会生成redo log 和undo log日志,保存到对应的内存池中
    4. 返回执行结果
  4. 服务层生成本次操作的binlog记录,并返回用户结果
  5. 用户提交事务
  6. redo log进行刷盘操作.

那么这里有几个问题需要思考一下?

  1. 用户事务操作本质是对MySQL内存数据的读写

    1. 如何正确的读取数据,这个问题体现在两个方面
      1. 在当前事务中,自己读取的数据是否会被别的事务修改
      2. 在当前事务中,是否能够读取到别的事务更新的数据
    2. 假如我现在操作的数据,不想被别人修改、或则读取怎么操作
    3. 如何保证对数据修改的持久性问题
      1. MySQL对数据的修改都是发生在内存,那么突然崩溃时,如何保证修改后的数据落盘
    4. 如何保证数据修改的原子性问题,
      1. 假如一个事务,修改了三张表的数据,如何保证这三张表同时修改成功和失败时同时回滚
  2. 由于MySQL对数据的操作都是由存储引擎处理,但是MySQL的主从复制是通过binlog来实现的

    1. 那么如何保证这两个操作的一致性

接下来,我们逐个分析这些问题.

1.3.1.隔离级别

本节我们先回答1.1这个问题,如何正确的读取数据.

1.3.1.1.什么是隔离级别

摘抄SQL:1992中的描述.

The isolation level of an SQL-transaction defines the degree to which the operations on SQL-data or schemas in that SQL-transaction are affected by the effects of and can affect operations on SQL-data or schemas in concurrent SQL-transactions.

一个SQL 事务的隔离级别定义了:

在并发SQL 事务环境下,一个SQL 事务中操作的 SQL data或数据库schema受其他SQL事务操作SQL data或schema影响的程度.

大白话就是:在并发SQL 事务的情况下,当前事务操作的数据 或者数据库schema受其事务的影响.

我们先来看下SQL:1992中定义了哪些隔离级别?

地址:https://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt

         __Table_9-SQL-transaction_isolation_levels_and_the_three_phenomena_
         _Level__________________P1______P2_______P3________________________

        | READ UNCOMMITTED     | Possib|e Possib|e Possible                |
        |                      |       |        |                          |
        | READ COMMITTED       | Not   | Possibl| Possible                 |
                                 Possible

        | REPEATABLE READ      | Not   | Not    | Possible                 |
        |                      | Possib|e Possib|e                         |
        |                      |       |        |                          |
        | SERIALIZABLE         | Not   | Not    | Not Possible             |
        |______________________|_Possib|e_Possib|e_________________________|
        
The isolation level specifies the kind of phenomena that can occur
         during the execution of concurrent SQL-transactions. The following
         phenomena are possible:

         1) P1 ("Dirty read"): SQL-transaction T1 modifies a row. SQL-
            transaction T2 then reads that row before T1 performs a COMMIT.
            If T1 then performs a ROLLBACK, T2 will have read a row that was
            never committed and that may thus be considered to have never
            existed.

         2) P2 ("Non-repeatable read"): SQL-transaction T1 reads a row. SQL-
            transaction T2 then modifies or deletes that row and performs
            a COMMIT. If T1 then attempts to reread the row, it may receive
            the modified value or discover that the row has been deleted.

         3) P3 ("Phantom"): SQL-transaction T1 reads the set of rows N
            that satisfy some <search condition>. SQL-transaction T2 then
            executes SQL-statements that generate one or more rows that
            satisfy the <search condition> used by SQL-transaction T1. If
            SQL-transaction T1 then repeats the initial read with the same
            <search condition>, it obtains a different collection of rows.
        
        

在SQL:1992中定义了四种隔离级别:

  1. READ UNCOMMITTED 读未提交
  2. READ COMMITTED 读已提交
  3. REPEATABLE READ 可重复读
  4. SERIALIZABLE 串行读

在上表中说明了在并发操作sql的情况下,每个隔离状态可能会发生的异常情况,主要分为以下三种情况:

  1. Dirty read(脏读) : 当前事务读取到了其他未提交事务的数据
  2. No-Repeatable read(不可重复读) : 当前事务操作的数据,被其他已提交的事务修改(删除、更新)
  3. Phantom(幻读):当前事务操作存在范围查询,正好查询到了其他已提交事务新插入的数据. 所以幻读是指读取到了已提交事务的新增数据.

那么在MySQL的InnoDB引擎中的隔离级别如何?

InnoDB offers all four transaction isolation levels described by the SQL:1992 standard: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE. The default isolation level for InnoDB is REPEATABLE READ. 来自MySQL官网.

在MySQL的InnoDB存储引擎中提供了四种SQL:1992规定的事务隔离级别:

  1. READ UNCOMMITTED 读未提交
  2. READ COMMITTED 读已提交
  3. REPEATABLE READ 可重复读
  4. SERIALIZABLE 串行读

InnoDB默认的隔离级别是REPEATABLE READ.

在SQL:1992定义中,REPEATABLE READ 可重复读这一隔离级别还存在幻读的问题,但是在MySQL InnoDB存储引擎中通过gap lock 解决了幻读这个问题,后面会说到.

那么在MySQL中是如何实现这些隔离级别的?

在MySQL中

  1. READ COMMITTED 和REPEATABLE READ通过MVCC和undo log 来实现.其中undo log还有一个重要功能,就是回滚,当事务失败时,通过undo log恢复到以前的数据.
  2. 对于READ UNCOMMITTED来说,是直接操作对应的数据,未加任何手段,所以会出现脏读、不可重复读、幻读问题.
  3. 对于SERIALIZABLE来说,是将并行改串行,将不会出现脏读、不可重复读、幻读问题,但是就不支持并发了,非常不可取.
1.3.1.2.MVCC

在这里插入图片描述

先说下一条记录的结构,记录中一定包含以下三个属性:

  1. row_id: 该记录的唯一id
  2. trx_id: 生成、更新该条记录的事务id
  3. roll_pointer: 指向历史版本记录位置的指针,这个就是记录的版本链,版本链记录中会记录该记录是被哪个事务修改的,即会记录事务的trx_id

有了数据的版本链,那么要想实现不同隔离级别下数据的可见性,主要分为以下三种:

  1. READ UNCOMMITTED : 直接读取记录的最新版本
  2. SERIALIZABLE: 通过对记录加锁,来访问记录,在操作当前记录前加锁,阻塞其他操作
  3. 对于READ COMMITTED 和REPEATABLE READ级别是通过ReadView,一种根据当前事务id和版本链来查找可见数据的方式来实现
ReadView
概念

一个ReadView中主要包括以下四部分内容:

  1. m_ids :表示在生成ReadView时,当前系统中活跃的读写事务的id列表
  2. min_trx_id : m_ids中的最小值
  3. max_trx_id: 在生成ReadView时,应分配给下一个事务的id值
  4. creator_trx_id : 当前生成ReadView的事务id.

在访问某一条记录时,需要按照以下规则判断当前记录的版本链中那一条数据是对当前事务可见的:

  1. 如果版本链记录的trx_id与当前ReadView 的creator_trx_id相等,说明是当前事务修改了数据,所以可见
  2. 如果版本链记录的trx_id小于min_trx_id,说明在生成ReadView时,该版本的事务已经提交,可见
  3. 如果版本链记录的trx_id大于max_trx_id,说明该版本是在ReadView生成之后的事务中发生了修改,所以不可见
  4. 如果版本链记录的trx_id 在min_trx_id 和max_trx_id之间 ,就要判断该trx_id 是否在m_ids列表中,如果在说明当前版本所属的事务还未提交,所以不可见,否则可见.

那么基于ReadView怎么实现READ COMMITTED 和REPEATABLE READ这两种隔离级别?

它们两个的主要区别是,当前事务是否能读取到,刚提交事务的数据(当前事务开始时,该事务还未提交).

所以在两个隔离级别下通过ReadView的生成时机,来实现这两种隔离级别.

READ COMMITTED实现

READ COMMITTED 是在每次读取数据时,生成一次ReadVIew,那么其中m_ids会包括当前正在活跃的事务id.

例如当前事务trx_id=100,

第一次查询:m_ids [91,92,98]

第二次查询前,92事务提交,并且新生成了两个事务,trx_id = 110,120,

那么新生成的ReadView中m_ids为:[91,98,110,120].

所以刚提交的92事务的数据,根据ReadView访问规则,对当前事务是可见的.

REPEATABLE READ实现

REPEATABLE READ在事务第一次读取数据时生成ReadView,一直到事务提交,该事务的ReadView中的m_ids、min_trx_id,max_trx_id都不会变.

但是当前ReadView的creator_trx_id为0,因为事务的id生成必须满足以下条件之一:

  1. 如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下: 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。
  2. 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务 id的。

小贴士:

我们前边说过对某个查询语句执行EXPLAIN分析它的查询计划时,有时候在Extra列会看到Using temporary的提示,这个表明在执行该查 询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE创建的用户临时表并不一样,在事务回滚时并 不需要把执行SELECT语句过程中用到的内部临时表也回滚,在执行SELECT语句用到内部临时表时并不会为它分配事务id。

有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。

1.3.1.3.undo log

在这里插入图片描述

undo log日志主要是为事务回滚和MVCC中的版本链服务.

上图为MySLQ内存中,undo log相关的数据结构:

在Mysql中会存在128个回滚段rollback segment,每一个回滚段对应就是一个Rollback Segment Header类型的页面,该页面中记录了两个重要的信息:

  1. undo log slots,每个slots有1024个slot
    1. 每个slot对应一个事务的undo log 页面链,每个undo log页里面保存了当前事务设计的undo log 记录
  2. history链表
    1. purge阶段使用.

在MySQL中对数据的操作主要分为两种insert操作和update操作,另外delete操作只是在记录上修改delete flag标识.

数据主要分为两种,一是普通的用户表,而是在操作用户表的过程中,MySQL自己可能创建临时表,并对其进行insert\update\delete操作.

所以一个事务操作最多可能有四个undo log链:

  1. 普通表
    1. Insert 数据
    2. update数据
  2. 临时表
    1. insert数据
    2. update数据

这也是为什么将回滚段分为三部分的原因

  1. 第0个回滚段,必须在系统表空间(page =5 的页面)做为基地址,因为回滚段之间,也可以像普通的MySQL段一样,形成链表,所以必须有一个基地址,用于查找
  2. 第33-127是用于存放普通用户表操作生成的undo log 日志页的链表
  3. 第1-32用于管理临时表的undo log

下面梳理一下,事务开始时,undo log日志如何生成?

  1. 当事务修改第一条记录时,从系统表空间第0个回滚段,开始寻找可用的slot,
  2. 找到slot后,为当前事务申请一个segment,用于分配undo log 页
  3. 从segment中分配undo log页后,将该页的作为undo log first页插入到slot中,后续的undo log 页插入到undo log first页后面,形成链表
    1. first页与其他的区别是,它标记了当前链的相关属性
      1. 如当前链是update 链还是insert 链
      2. 当前链所属事务的状态
      3. 当前链表中的页属于哪一个段
  4. MySQL生成undo log记录,插入到当前的undo log 页中
  5. 当事务提交时,会将事务相关属性(如trx_id,该事务指向的undo log链表等)包装成一个对象插入到history链表中,
    1. history链表的顺序,越先提交的事务,最往后
  6. Purge进行回收history
    1. 从后往前遍历,清理当前事务下的undo log 记录所在的页面
      1. 清理过程是按页进行的,如果当前事务下的记录在当前页面已经清理完成,还会清理其他事务的记录,前提该记录不被任何事务所引用
        1. 这里没搞明白如何判断是否被引用
      2. 如果当前页已经完成清理,返回history链表,继续向前遍历

这里需要说明一下,在生成undo log日志的过程,

  1. 对于普通用户表的回滚段,是生成redo log日志
  2. 对于临时表的回滚段,不生成redo log日志
用户自定义设置
history设置:

当innodb更新压力大时,purge操作不能高效进行history list就会变的很长.

innodb_max_purge_lag:用于设置history链表长度,默认为0,不做限制.

如果做了限制,当history长度达到后,就会延缓DML操作,延缓算法为:

Delay = ( (length(history_list) -innodb_max_purge_lag)*10)-5

delay单位为毫秒. delay的对象是行,而不是DML操作. 当一个update 操作假如需要更新5行数据,那么总延迟时间为5*delay .

innodb1.2引入全局动态参数innodb_max_purge_lag_delay用来设置最大延迟时间,当delay大于该值后,使用该值

回滚段设置

系统默认设置使用128个回滚段,但是用户可以配置,但是临时表回滚段数量不会改变一直为32

参数: innodb_rollback_segements ,

  1. 该值小于等于32,总回滚段= innodb_rollback_segements(用户表回滚段)+32(临时表回滚段)
  2. 该值大于32,那么普通表回滚段为:innodb_rollback_segements-32,临时表为32.
回滚空间

我们上面说到,普通表回滚段可以在系统表空间,也可以在回滚表空间.

如果设置,参数如下:

  1. innodb_undo_directory :设置undo表空间所在的目录,默认就是数据目录
  2. innodb_undo_tablespaces: 定义undo表空间的数量,默认为0,不创建
1.3.1.4.Redo log

redo log成为重做日志,是用来实现事务持久性的.

它主要有两部分构成:

  1. 内存中的重做日志缓冲redo log buffer
  2. 磁盘上的重做日志文件 redo log file

Redo log 是WAL技术的体现,下面对WAL技术做一个简单介绍.

WAL:write ahead log 预写式日志.

WAL要求一个页操作在写入到持久存储设备时,首先必须将其内存中的日志写入到持久化存储. 就是说,我想将一个数据从内存刷到磁盘上,我必须先记录日志,在mysql中,我的理解是,对一个数据的操作,在从内存刷到磁盘前,必须先写到日志,保证系统突然崩溃,能够恢复,内存中的数据.

Redo log整体流程如下:

在这里插入图片描述

当用户开启事务操作数据过程中,innodb做了以下内容:

  1. 将磁盘数据读取到buffer pool
  2. 将当前事务分为多个Mini-Transaction
    1. 每个Mini-Transaction是对底层页面的一次访问的原子操作,负责数据修改、一组redo log日志生成
  3. 将生成的redo log记录,插入到redo log buffer的某一个连续的区域,空闲位置
  4. 将修改的内存页,加入flush链表
    1. flush链表的顺序,是按当前页的最早修改时间来排序
    2. 每个flush节点包含两个LSN属性
      1. oldest_modification:如果某个页面被加载到Buffer Pool后进行第一次修改,那么就将修改该页面的mtr开始时对应的lsn值写入这个属性。
      2. newest_modification:每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn值。
  5. 事务提交时将当前redo log buffer内容刷盘

在整个过程,有下面几个问题需要思考:

  1. 如何保证binlog和redo log的一致性问题
  2. Redo log何时从redo log buffer刷新到redo log file
    1. 因为MySQL中的数据页是16KB,而linux系统中的数据页是4KB,如何保证刷盘的一致性.
  3. Redo log的checkponint机制
    1. 如何复用redo log 日志
    2. 何时将buffer pool中的数据页刷新到磁盘

接下来,就来回答这几个问题.

1.3.1.4.1.binlog和redo log的一致性问题

在MySQL中引入了XA事务,来保证binlog和redo log的一致性问题.

两阶段操作如下:

  1. prepare阶段:redo log 的write /sync操作
  2. commit阶段: bingo 的write/sync操作,写入commit标志

可能发生的情况如下:

  1. 如果在redo log写完后发生crash ,恢复数据时,直接执行回滚操作.
  2. 如果在binlog写成功后,commit未写入,这是不需回滚,还需要将事务提交.
1.3.1.4.2.redo log的刷盘

对于redo log,MySQL做了以下两个优化:

  1. 为redo log创建单独的redo log buffer,一块连续的区域,redo log数据写入磁盘文件前先写入buffer中
  2. Double Write 机制
优化一:redo log buffer

redo log buffer的存在,提高了redo log记录写入的效率.MySQL不会对每条记录,进行刷盘操作,而是等到事务提交时,将该事务的redo log刷到磁盘.

还有以下几种情况,会将redo log刷到磁盘:

  1. log buffer空间不足时
  2. 后台线程将redo log刷新到磁盘
    1. 后台有专门的线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。
  3. 正常关闭服务器时
  4. checkpoint时,下面会说

innodb_flush_log_at_trx_commit: 该参数用来控制redo log的刷盘策略,它有以下几种选择:

  1. 1: 默认值,表示事务提交时必须调用一次fsync操作
  2. 0: 事务提交时不进行写入redo log,刷新redo log 交由master thread处理,master thread会每秒进行一次fsync操作
  3. 2:表示事务提交时,将redo log写入磁盘文件,不进行fsync ,只是写入文件系统的缓存中
优化二:Double Write机制

由于在linux系统中一个数据页最小时4Kb,而MySQL默认的数据页是16Kb,为了保证每次刷新时一个MySQL数据页的一致性,MySQL提供了Double Write机制.

在每次刷盘开始时,对与每个MySQL数据页,MySQL会将该数据页数据写入到double write 数据区,然后再将MySQL的redo log数据页刷新到磁盘,这样就算再redo log写入磁盘文件时发生崩溃,也能从double write区域恢复当前数据.

1.3.1.4.3.checkpoint机制

Mysql的redo log文件是会被循环使用的,

1.2.1.4.3.1.redo log 文件组

MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir’查看)下默认有两个名为ib_logfile0和ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个

磁盘文件中。如果我们对默认的redo日志文件不满意,可以通过下边几个启动参数来调节:

  1. innodb_log_group_home_dir 该参数指定了redo日志文件所在的目录,默认值就是当前的数据目录。
  2. innodb_log_file_size 该参数指定了每个redo日志文件的大小,在MySQL 5.7.21这个版本中的默认值为48MB,
  3. innodb_log_files_in_group 该参数指定redo日志文件的个数,默认值为2,最大值为100。

从上边的描述中可以看到,磁盘上的redo日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile_数字(数字可以是0、1、2…)的形式进行命名。 在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。如 果写到最后一个文件该咋办?

那就重新转到ib_logfile0继续写.但是这会造成最后写的redo日志与最开始写的redo日 志追尾.

如何判断能否覆盖,这是就用到了checkpoint机制了

1.2.1.4.3.2.LSN

在说checkpoint之前,需要先说下LSN.

LSN: Log Sequeue Number 日志序列号.

初始值为8704.

LSN代表了在mtr(mini-transaction)过程中产生的redo log信息,保存到buffer pool 是所产生的位移,是递增的.

假如一个mtr 产生的日志数量为1000字节,那么新的LSN = LSN +1000 + (当前页的管理数据).

页的管理数据是指,每个数据页,即redo log buffer 中的数据页,都会有头部/尾部信息,这块也要算上.

总之记住以下两点:

  1. LSN是递增的
  2. 它近似代表了当前mtr 的redo log的数据位移量.
1.2.1.4.3.3.checkpoint

在上面的Redo log整体流程 中,当mtr 修改buffer pool 中的数据页时,会将当前数据页假如到flush列表,

并且记录当前数据页的最早lsn(old_lsn),和最新lsn(new_lsn)

flush列表中的记录是按照最早lsn排列的,末尾的old_lsn最小,所以最后的这个数据页的数据从buffer pool 刷新到磁盘时,这时redo log 中lsn小于 该页old_lsn的日志都是无用的,就可以被覆盖了.

MySQL中定义了一个全局变量checkpoint_lsn来代表当前系统中可以被覆盖 的redo日志总量是多少,这个变量初始值也是8704。

所以将buffer pool 中的数据刷新到磁盘文件,并且更新当前系统的checkpoint_lsn,并将其写入到redo log文件头部里面,的过程称为checkpoint.

主要包括两步:

  1. 计算一下当前系统中可以被覆盖的redo日志对应的lsn值最大是多少。
  2. 步骤二:将checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号(checkpoint_no)写到日志文件的管理信息(就是checkpoint1或者checkpoint2)中。

checkpoint_no每做一次checkpoint,该值就会加1.

当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。

那么什么情况下会将buffer pool中的数据页,刷新到磁盘?

情况有两种:

  1. 系统后台线程,
  2. 当系统修改页面频繁,redo log剧增时,lsn增加较快,等不及系统后台线程刷,那么就无法做checkpoint,这时就会使用用户线程将flush中最早的页刷新到磁盘

二、MySQL中锁

在并发事务的情况下,访问相同记录情况大致分为以下三种:

  1. 读-读
  2. 写-写
  3. 读-写

对于第一种情况读-读:在并发环境下,并不会引起什么问题,所以允许这种情况.

对于第二种情况,如果同时对同一条记录进行修改,会引起脏写的发生,而这是不允许的,所以一般是通过加锁,进行排序.

对于第三种情况,读写,对于这种情况有两种方式:

  1. 通过MVCC读取数据,通过锁对写进行加锁
  2. 对读写都进行加锁.

下面主要分析第三种情况.

2.1.行锁

2.1.1.一致性读和锁定读

2.1.1.1.一致性读

事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读,比方

说:

SELECT * FROM t;
SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2

一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。

2.1.1.2.锁定读

在Mysql中从锁的阻塞情况来看分为以下两种:

  1. 共享锁,又叫读锁(S锁),
  2. 排它锁,又叫写锁(X锁).

兼容性如下:

兼容性XS
X不兼容不兼容
S不兼容兼容

锁定读的方式:

  1. 对读取记录加S锁: select … lock in share mode
  2. 对读取记录加X锁: select … for update

两者的区别:

一个事务对一条记录加了S锁,那么其他事务还可以对该记录加S锁,但会阻塞X锁.

一个事务对一条记录加了X锁,会阻塞其他事务对该记录加的任何锁.

2.1.2.写操作加锁

平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种:

  1. DELETE:
    对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。
  2. UPDATE: 在对一条记录做UPDATE操作时分为三种情况:
  3. 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实 我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。
  4. 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录 彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。
  5. 如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。
  6. INSERT:一般情况下,新插入一条记录的操作并不加锁,MySQL中通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问

2.3.多粒度锁

上面提到的都是对记录加锁.

在MySQL中还提供了两种级别的锁:

  1. 表锁
  2. 意向锁

2.3.1.表锁

表锁,就是给整张表进行加锁,也可以分为共享锁(S锁)和排它锁(X锁).

当一个事务给表加S锁或者X锁,会有以下特点:

  1. 给表加S锁:
    1. 别的事务可以继续获得该表的S锁
    2. 别的事务可以继续获得该表中的某些记录的S锁
    3. 别的事务不可以继续获得该表的X锁
    4. 别的事务不可以继续获得该表中的某些记录的X锁
  2. 给表加X锁:
    1. 别的事务不可以继续获得该表的S锁
    2. 别的事务不可以继续获得该表中的某些记录的S锁
    3. 别的事务不可以继续获得该表的X锁
    4. 别的事务不可以继续获得该表中的某些记录的X锁

那么当给表加S锁或者X锁时,如何判断表中的记录又没又加锁,或者加锁的类型是S锁或X锁哪一种.

这时就需要意向锁了.

2.3.2.意向锁

意向锁分为两种

  1. 意向共享锁(IS):当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
  2. 意向独占锁(IX):当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。

IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是 兼容的,IX锁和IX锁是兼容的

兼容性XIXSIS
X不兼容不兼容不兼容不兼容
IX不兼容兼容不兼容兼容
S不兼容不兼容兼容兼容
IS不兼容兼容兼容兼容

接下来主要分析InnoDB引擎中的锁.

2.4.innoDB中的锁

2.4.1.innoDB中的表锁

2.4.1.1.DDL语句

一个事务在对表执行如alter table 、drop table这类DDL语句时,会对表加X锁,阻塞其他事务的SELECT、INSERT、UPDATE、DELETE操作.

2.4.1.2.手动获取

在系统变 量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写:

LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。

LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。

2.4.1.3.表级别的IS、IX锁

当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。IS锁和IX 锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录.

2.4.1.4.AUTO-INC锁

当为表的某一列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值.在获取递增值时,就会使用该锁.

系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:

  1. 采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。 这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
  2. 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并 不需要等到整个插入语句执行完才释放锁。

需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟我们之前介绍的锁在事务结束时释放是不一样的。

2.4.2.innoDB中的行锁

innoDB中的行锁,顾名思义就是给记录加上锁.

innoDB中的行锁算法有三种:

  1. Record Lock :单行记录上的锁
  2. Gap Lock: 间隙锁,锁定一个范围,单不包含记录本身.
  3. Next-Key Lock: Gap Lock+Record Lock,锁定一个范围,包含记录本身.

那么我们下面来思考两个问题:

  1. 在不同隔离级别下,innoDB对记录的加锁方式相同吗?
  2. 如果当前表存在多个索引,或者索引类别不同,innodb如何进行加锁?

先回答第一个问题:

  1. 在READ UNCOMMITTED模式下,是不考虑加锁的,因为它会读取最新数据.
  2. 在READ COMMITTED模式下,行锁的类型只有单条记录锁.
  3. 在REPEATABLE READ模式下,行锁才有上面三种模式
  4. 在SERIALIZABLE模式,对事务的读写操作进行排序,不用考虑行级别的锁定

第二个问题分析如下:

  1. 加锁过程实际就是扫描过程记录的过程,
  2. MySQL中的索引分为聚簇索引和二级索引

对于第二个问题,我以REPEATABLE READ模式为主分析:

  1. 主键
  2. 唯一索引
  3. 非唯一索引
  4. 无索引模式

这四种情况下加锁的分析,顺带分析READ COMMITTED模式下对应的情况.

2.4.3.加锁分析

我们知道,在innodb中一定会存在聚簇索引的.

  1. 优先选用主键
  2. 没有主键,使用非空唯一索引
  3. 自动生成一个bigint作为主键

背景,表结构:

CREATE TABLE `t_lock` (
  `id` bigint(11) ,
  `name` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

插入数据:

(1,‘name1’),

(10,‘name10’),

(20,‘name20’),

(30,‘name30’),

本文以for update 写锁为例,可以自己加上读锁思考.

2.4.3.1.主键索引(id为主键)

如果id为主键,该主键索引就是聚簇索引.

记录锁
BEGIN ;
SELECT * from t_lock where id =10 for UPDATE;

则只会在聚簇索引上对id=10的记录加锁,

这时别的事务可以操作其他事务,但是阻塞对该记录的读、写.

gap锁
BEGIN ;
SELECT * from t_lock where id =15 for UPDATE;

执行这个操作,就会在(10,20)添加gap锁.它具有以下特性:

  1. 阻止其他事务在该区间的插入
  2. 其他事务可以继续在该区间加gap锁.

这里的语句也可以换成upadte/delete id=15 也会加gap ,insert比较特殊,后面会说.

Next-Key 锁
BEGIN ;
SELECT * from t_lock where id <=15 for UPDATE;
2.4.3.2.唯一索引(id为唯一索引)

当使用id查询或者更新、删除.

在唯一索引上的加锁情况与主键索引相同,不同的时,由于这时唯一索引和聚簇索引是两个索引.

所以还会在聚簇索引对应的记录上加锁.

当唯一索引为非空唯一索引时,就和主键索引一摸一样了,只加一把锁.

2.4.3.3.非唯一索引

id不建立索引,name上建立普通索引.

等值匹配
BEGIN ;
SELECT * from t_lock where name ='name10' for UPDATE;

执行该操作,

  1. 会对所有的name=name10的name索引上记录加记录锁,并且也会对对应的聚簇索引上的记录加锁
  2. 会对name索引上的(‘name10’,‘name20’),(‘name1’,‘name10’)加上两个gap锁,阻塞在这两个区间进行insert.

但是如果你执行:

update t_lock set name = '30' where id=30

发现,也会阻塞.

其原因是:

你在执行该操作时,发现无法使用name索引,然后就会通过聚簇索引遍历整张表,当遍历到加锁的记录,就会阻塞.

不存在记录
BEGIN ;
SELECT * from t_lock where name = 'name11' for UPDATE;

当你执行该操作时,只会对name索引上的(‘name10’,‘name20’)区间加gap锁,阻塞插入.

2.4.3.4.无索引模式

id、name都不建立索引.

BEGIN ;
SELECT * from t_lock_id where id =10 for UPDATE;
-- 或
SELECT * from t_lock_id_name where name = 'name11' for UPDATE;

执行上面的其中一个,都会进行全表扫描,然后锁主全部记录和gap,即表锁,阻塞所有的加锁操作和插入操作,

但是可以执行正常的select.

2.4.3.5.总结
  1. RC 模式和RR模式的区别时,RC模式下没有gap锁,只有记录锁,只能锁住当个记录、多个记录、所有记录
  2. 如果对索引上加了记录锁、gap锁、next-key 锁,也会对聚簇索引上的记录加锁
    1. 所以当不使用索引扫描时,会阻塞响应操作
  3. gap锁的唯一目的就是阻塞插入
2.4.3.6.insert加锁的特殊情况
插入意向锁

个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁(next-key锁也包含gap锁,后边就不强调了),如果有的话,插入操作需要等待,直到拥有gap锁的那个 事务提交。但是设计InnoDB的大叔规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。把这种类型的锁命名 为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。

隐式锁

一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是 不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:

  1. 立即使用SELECT … LOCK IN SHARE MODE语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT … FOR UPDATE语句读取这条事务或者直接修改这条记录,也就是要获取这条 记录的X锁,该咋办?

    1. 如果允许这种情况的发生,那么可能产生脏读问题。
    2. 立即修改这条记录,也就是要获取这条记录的X锁,该咋办? 如果允许这种情况的发生,那么可能产生脏写问题。

我们把聚簇索引和二级索引中的记录分开看一下:

  1. 情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当 前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也 就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
  2. 情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如 果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记 录,然后再重复情景一的做法。

通过上边的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id这个牛逼的东东的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X 锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。

2.5.死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象.

解决死锁最简单的方式就是不要有等待,将任何的等待都转化为回滚,并且事务重新开始.innodb_lock_wait_timeout可以设置超市时间.

但是如果这样,将降低并发性能.

除了超时机制外,当前数据库提供了wait-for graph(等待图)的方式来进行死锁检测,innodb也采用了这种方式,wait-for graph要求数据库保存以下两种信息:

  1. 锁的信息链表
  2. 事务等待链表

通过上述链表可以构造一张图,而在这个图中若存在回路,就代表存在死锁.

一个死锁,简单的例子:

-- 还是上面的表t_lock ,id为主键

-- 事务1                                         |            事务2
begin ;                                          |
select * from t_lock where id = 10 for update;   |          begin;
                                                 |      select * from t_lock where id = 20 for update;
                                                 |            
 select * from t_lock where id = 20 for update;  |            
                                                 |      select * from t_lock where id = 10 for update;

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序猿老徐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值