MySQL探险-4、事务及锁机制

文章目录

  • 一、概述:
    •   ①ACID
      •     原子性
      •     一致性
      •     隔离性
        •       锁机制
      •     持久性
    •   ②并发带来的问题与解决方法
      •     不可重复读 vs 幻读
    •   ③一次封锁 vs 两段锁
  • 二、事务隔离级别:
    •   ①READ-COMMITTED
    •   ②REPEATABLE-READ
      •     读
        •       “快照读”与“当前读”
      •     写
  • 三、锁:
    •   ①锁分类
      •     从对数据操作的粒度分类:
      •     从对数据操作的类型分类:
      •     从锁的思想分类:
    •   ②表锁 vs 行锁
      •     表锁
      •     行锁
    •   ③自增锁
    •   ④MyISAM 的锁
    •   ⑤InnoDB 的锁
      •     锁模式
        •       记录锁(Record Locks)
        •       间隙锁(Gap Locks)
        •       临键锁(Next-key Locks)
        •       插入意向锁(Insert Intention Lock)
        •       锁规则总结
      •     锁状态查询
    •   ⑥死锁
      •     死锁的产生:
      •     死锁的检测:
      •     死锁的恢复:
      •     外部锁的死锁检测:
      •     死锁影响性能:
      •     MyISAM 避免死锁:
      •     InnoDB避免死锁:
        •       SELECT ... FOR UPDATE
    •   ⑦MVCC
      •     InnoDB 的 MVVC 实现
  • 四、事务日志:
    •   ①事务日志
    •   ②事务的实现
      •     binlog(二进制日志)
        •       binlog 的使用场景
        •       binlog 的刷盘时机
        •       binlog 的日志格式
      •     redo log(重做日志)
        •       redo log 的来由
        •       redo log 的刷盘
        •       redo log 记录形式
        •       Redo Log LSN
        •       Redo log 容灾恢复过程
        •       redo log 与 binlog 的区别
      •     undo log(回滚日志)
        •       有关 MVCC 的进一步说明
        •       ReadView
    •   ③日志的记录顺序
      •     先写入 redo log,后写入 binlog
      •     先写入 binlog,后写入 redo log
  • 五、InnoDB细节详解与总结:
    •   ①InnoDB 的体系架构
      •     InnoDB 内存中的结构
        •       Buffer Pool
        •       Change Buffer
          •         ChangeBuffer vs Redo log
          •         ChangeBuffer 的 merge 过程
        •       Adaptive Hash Index
        •       Log Buffer
      •     InnoDB 磁盘上的结构
        •       InnoDB 表结构
        •       InnoDB 表空间
        •       InnoDB File-Per-Table 表空间
          •         表空间文件结构
          •         索引页分析
          •         索引结构
          •         页目录
        •       InnoDB 系统表空间
          •         Undo 日志
          •         双写缓冲
    •   ②InnoDB 事务隔离级别
    •   ③InnoDB 和 ACID 模型
    •   ④InnoDB 的 MVVC 实现

一、概述:

  事务是 MySQL 区别于 NoSQL 的重要特征,是保证关系型数据库数据一致性的关键技术。数据库使用来支持对共享资源进行并发访问,提供数据的完整性和一致性。此外,数据库事务的隔离性也是通过锁实现的。数据库是个高并发的应用,同一时间会有大量的并发访问,如果加锁过度,会极大的降低并发处理能力。所以对于加锁的处理,可以说就是数据库对于事务处理的精髓所在。

  事务可看作是对数据库操作的基本执行单元,可能包含一个或者多个SQL语句。这些语句在执行时,要么都执行,要么都不执行。

  事务的执行主要包括两个操作:
    ●提交:commit,将事务执行结果写入数据库。
    ●回滚:rollback,回滚所有已经执行的语句,返回修改之前的数据。

  ①ACID

    事务具有4个属性,通常简称ACID:
      ●Atomicity(原子性
      ●Consistency(一致性
      ●Isolation(隔离性
      ●Durability(持久性

    原子性

      整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
      这是事务最核心的特性,事务本身就是以原子性来定义的。实现主要是基于 undo log。当事务需要进行回滚时,InnoDB 引擎就会调用 undo log 进行 SQL 语句的撤销,实现数据的回滚

    一致性

      在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。

    隔离性

      一个事务的执行不能其它事务干扰。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。InnoDB 默认的隔离级别是 RR,RR 的实现主要是基于锁机制、数据的隐藏列、undo log 和类 next-key lock 机制。

      锁机制

        事务之间的隔离,主要是通过锁机制实现的。当一个事务需要对数据库中的某行数据进行修改时,需要先给数据加锁;加了锁的数据,其它事务是不运行操作的,只能等待当前事务提交或回滚将锁释放。

    持久性

      在事务提交以后,该事务所对数据库所作的更改便持久的保存在数据库之中,不会因为宕机等原因导致数据丢失。实现主要是基于 redo log 日志。

  ②并发带来的问题与解决方法

    当数据库被并发访问时,会出现一系列问题,比如:
      ●Lost Update(更新丢失):事务 A 和事务 B 选择同一行,然后基于最初选定的值更新该行时,由于两个事务都不知道彼此的存在,就会发生丢失更新问题。
      ●Dirty Reads(脏读):事务 A 读取了事务 B 更新的数据,然后 B 执行回滚操作,那么 A 读取到的数据是脏数据。比如:
MySQL探险-4、事务及锁机制_第1张图片
      ●Non-Repeatable Reads(不可重复读):事务 A 多次读取同一数据,事务 B 在事务 A 多次读取的过程中,对数据作了更新并提交,导致事务 A 多次读取同一数据时,结果不一致。比如:
MySQL探险-4、事务及锁机制_第2张图片
      ●Phantom Reads(幻读):幻读与不可重复读类似。它发生在一个事务 A 读取了几行数据,接着另一个并发事务 B 插入了一些数据时。在随后的查询中,事务 A 就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。比如:
MySQL探险-4、事务及锁机制_第3张图片

    不可重复读 vs 幻读

      ●不可重复读的重点是修改:在同一事务中,同样的条件,第一次读的数据和第二次读的数据不一样。(因为中间有其他事务提交了修改)
      ●幻读的重点在于新增或者删除:在同一事务中,同样的条件,,第一次和第二次读出来的记录数不一样。(因为中间有其他事务提交了插入/删除)

    要解决并发带来的问题,数据库提供了如下解决方案:
      ●“更新丢失”通常是应该完全避免的。但防止更新丢失,并不能单靠数据库事务控制器来解决,需要应用程序对要更新的数据加必要的锁来解决,因此,防止更新丢失应该是应用的责任。
      ●“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决:
        一种是加锁:在读取数据前,对其加锁,阻止其他事务对数据进行修改。
        另一种是数据多版本并发控制(MultiVersion Concurrency Control,简称 MVCC 或 MCC),也称为多版本数据库:不用加任何锁, 通过一定机制生成一个数据请求时间点的一致性数据快照 (Snapshot), 并用这个快照来提供一定级别 (语句级或事务级) 的一致性读取。从用户的角度来看,好象是数据库可以提供同一数据的多个版本。

  ③一次封锁 vs 两段锁

    当有大量的并发访问时,为了保证程序的正确运行,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用到哪些数据,然后全部锁住,在方法运行之后,再全部解锁。这种方式可以有效的避免循环死锁,但在数据库中却不适用,因为数据库在事务开始阶段,并不知道会用到哪些数据。

    数据库遵循的是两段锁协议(将事务分成两个阶段,加锁阶段和解锁阶段,所以叫两段锁)。
      ●加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得 S 锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁);在进行写操作之前要申请并获得 X 锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
      ●解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。
    这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化的。(串行化很重要,尤其是在数据恢复和备份的时候)


二、事务隔离级别:

  在数据库操作中,为了有效保证并发读取数据的正确性,提出了事务隔离级别。数据库事务的隔离级别有 4 种,由低到高分别为:
    ●READ-UNCOMMITTED(读未提交):最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
    ●READ-COMMITTED(读已提交):允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
    ●REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
    ●SERIALIZABLE(可串行化): 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
MySQL探险-4、事务及锁机制_第4张图片

  可以通过以下语句查询当前数据库的事务隔离级别:

SHOW VARIABLES LIKE 'transaction_isolation'

  数据库的事务隔离越严格,并发副作用越小,但付出的代价就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。

  为了平衡隔离级别和并发之间的关系,在大多数数据库系统中,默认的隔离级别为 READ-COMMITTED(如 Oracle)或者 REPEATABLE-READ(MySQL 的 InnoDB 引擎)。

  注意:与 SQL 标准不同的地方在于 InnoDB 在 REPEATABLE-READ 事务隔离级别下使用的是 Next-Key Lock 算法,因此可以避免幻读的产生,这与其他数据库系统(如 SQL Server)是不同的。所以说 InnoDB 的默认支持的隔离级别 REPEATABLE-READ 已经可以完全保证事务的隔离性要求,即达到了 SQL 标准的 SERIALIZABLE 隔离级别,而且保留了比较好的并发性能。

  READ-UNCOMMITTED 级别,数据库一般都不会用,而且任何操作都不会加锁,这里就不讨论了。SERIALIZABLE 会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用问题。这种事务隔离级别效率低下,比较耗数据库性能,一般不使用。

  因此,这里着重讨论下常用的 READ-COMMITTED 和 REPEATABLE-READ。

  ①READ-COMMITTED

    在 RC 级别中,数据的读取都是不加锁的,但是数据的写入、修改和删除是需要加锁的。

    由于 MySQL 的 InnoDB 默认是使用的 RR 级别,所以先要将该 session 开启成 RC 级别,并且设置 binlog 的模式:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION binlog_format = 'ROW';(或者是 MIXED)

    然后,以一个简单的表结构和数据为例:

CREATE TABLE `student` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `student_name` varchar(50) NOT NULL,
  `student_age` int(20) NOT NULL,
  `student_id` bigint(20) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_student_id` (`student_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

mysql> select * from student;
+----+--------------+-------------+------------+
| id | student_name | student_age | student_id |
+----+--------------+-------------+------------+
|  1 | 张三          |          16 |   20200101 |
|  2 | 李四          |          18 |   20200102 |
|  3 | 王五          |          17 |   20200103 |
+----+--------------+-------------+------------+

    假设对该表执行以下操作:
MySQL探险-4、事务及锁机制_第5张图片

    为了防止并发过程中的修改冲突,事务 A 中 MySQL 给 student_id = 20200103 的数据行加锁,并一直不commit(释放锁),那么事务 B 也就一直拿不到该行锁,wait 直到超时。

    这里注意一个细节,student_id 字段是有索引的,那么在没有索引的情况下,又是怎样的流程呢?

    如果没有索引,此时 MySQL 并不知道哪些数据行是命中条件的,无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由 MySQL Server 层进行过滤。

    但在实际使用过程当中,MySQL 做了一些改进,在 MySQL Server 过滤条件,发现不满足后,会调用 unlock_row 方法,把不满足条件的记录释放锁(违背了前面的二段锁协议的约束)。这样做,保证了最后只会持有满足条件的记录上了锁,但是每条记录的加锁操作还是不能省略的。可见即使是MySQL,为了效率也是可能违反规范的。

    这种情况同样适用于 MySQL 的默认隔离级别 RR。所以对一个数据量很大的表做批量修改的时候,如果无法使用相应的索引,MySQL Server 过滤数据的时候特别慢,就会出现虽然没有修改某些行的数据,但是它们还是被锁住的现象。

  ②REPEATABLE-READ

    这里分“读”和“写”来讨论对应的场景。“读”指的是可重读(同一事务的多个实例在并发读取数据时,会看到同样的数据行),可以猜测这里其实会读到某种类似快照的结果。而“写”相对于快照读,更强调的是“当前”读到的结果。

    读

      同样使用上面的表为例,假设对该表执行以下操作:
MySQL探险-4、事务及锁机制_第6张图片

      尝试执行后发现,在 RC 级别下,事务 A 在 T2 和 T5 两次读取到的数据是不一样的。而在 RR 级别下,T5 读取到的数据和 T2 是一致的。此时就说明它是可重复读的。

      “快照读”与“当前读”

        在 RR 级别中,通过 MVCC 机制实现了可重复读,但读到的数据可能是历史数据(不是数据库当前的数据)。对于这种读取历史数据的方式,称之为快照读(snapshot read);而读取数据库当前版本数据的方式,叫当前读(current read)。在 MVCC 中,二者的场景为:
          ●快照读:就是 select

select * from table ...;

          ●当前读:特殊的读操作、插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。

select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;

      事务的隔离级别实际上都是定义了当前读的级别,MySQL 为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”,就需要另外的手段来解决了。

    写

      同样使用上面的表为例,假设对该表执行以下操作:
MySQL探险-4、事务及锁机制_第7张图片

      为了解决当前读中的幻读问题,MySQL 事务使用了 Next-Key 锁。具体解释放在后续内容中,先来看结果。

      尝试执行后发现,在 RC 级别下,事务 A 在 T2 和 T6 两次读取到的数据是不一样的。而在 RR 级别下,T6 读取到的数据和 T2 是一致的。此时事务 A 在 UPDATE 后加锁,事务 B 无法插入新数据。


三、锁:

  锁是计算机协调多个进程或线程并发访问某一资源的机制。

  在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。数据库锁机制简单来说,就是数据库为了保证数据的一致性,而使各种共享资源在被并发访问变得有序所设计的一种规则。

  ①锁分类

    在 MySQL 中,根据不同的划分标准,可将锁分为不同的种类:

    从对数据操作的粒度分类:

      为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作),因此数据库系统需要在高并发响应和系统性能两方面进行平衡,这样就产生了“Lock granularity(锁粒度)”的概念。

        ●表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低(MyISAM 和 MEMORY 存储引擎采用的是表级锁)。
        ●行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高(InnoDB 存储引擎既支持行级锁也支持表级锁,但默认情况下是采用行级锁)。
        ●页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
MySQL探险-4、事务及锁机制_第8张图片

    从对数据操作的类型分类:

      ●Share locks(共享锁、读锁、S 锁):针对同一份数据,多个读操作可以同时进行,不会互相影响。
      ●Exclusive locks(排他锁、写锁、X 锁):当前写操作没有完成前,它会阻断其他写锁和读锁。

    从锁的思想分类:

      ●悲观锁:对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
      ●乐观锁:悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。乐观锁大多是基于数据版本(Version)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

  ②表锁 vs 行锁

    表锁是指对一整张表加锁,一般是 DDL 处理时使用;而行锁则是锁定某一行或者某几行,或者行与行之间的间隙

    表锁由 MySQL Server 实现,行锁则是存储引擎实现,不同的引擎实现的效果也不同。在 MySQL 的常用引擎中 InnoDB 支持行锁,而 MyISAM 则只能使用 MySQL Server 提供的表锁。

    表锁

      表锁由 MySQL Server 实现,一般在执行 DDL 语句时会对整个表进行加锁,比如说 ALTER TABLE 等操作。在执行 SQL 语句时,也可以明确指定对某个表进行加锁。

      表锁使用的是一次性锁技术,也就是说,在会话开始的地方使用 lock 命令将后续需要用到的表都加上锁,在表释放前,只能访问这些加锁的表,不能访问其他表,直到最后通过 unlock tables 释放所有表锁。
      除了使用 unlock tables 显示释放锁之外,会话持有其他表锁时执行 lock table 语句会释放会话之前持有的锁;会话持有其他表锁时执行 start transaction 或者 begin 开启事务时,也会释放之前持有的锁。

    行锁

      由于不同存储引擎的行锁实现不同,这里行锁主要讨论 InnoDB 实现的行锁。

      后续的讨论可能涉及些许索引知识点,可以参考:MySQL探险-2、索引

      假如根据主键更新数据,使用如下 SQL:

UPDATE employee SET age = 28 WHERE id = 66;

      此时只需要在 id = 66 这个主键索引上加上写锁即可。

      假如根据二级索引更新数据,使用如下 SQL:(假设 employee_name 字段建立了索引)

UPDATE employee SET age = 28 WHERE employee_name = '无双小宝';

      首先会在 employee_name = ‘无双小宝’ 这个索引上加写锁,然后由于使用 InnoDB 二级索引还需再次根据主键索引查询,所以还需要在 id = 66 这个主键索引上加写锁。

      可见,使用主键索引需要加一把锁,使用二级索引需要在二级索引和主键索引上各加一把锁。

      如果更新操作涉及多个行呢?假如使用如下 SQL:

UPDATE employee SET age = 30 WHERE id > 20;

      此时,MySQL Server 会根据 WHERE 条件读取第一条满足条件的记录,然后 InnoDB 引擎会将第一条记录返回并加锁,接着 MySQL Server 发起更新改行记录的 UPDATE 请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有匹配的记录为止。
MySQL探险-4、事务及锁机制_第9张图片

  ③自增锁

    AUTOINC 锁又叫自增锁(一般简写成 AI 锁),是一种表锁。当表中有自增列(AUTOINCREMENT)时出现。当插入表中有自增列时,数据库需要自动生成自增值,它会先为该表加 AUTOINC 锁,阻塞其他事务的插入操作,这样保证生成的自增值肯定是唯一的。

    AUTOINC 锁具有如下特点:
      ●AUTO_INC 锁互不兼容,也就是说同一张表同时只允许有一个自增锁。
      ●自增值一旦分配了就会 +1,如果事务回滚,自增值也不会减回去,所以自增值可能会出现中断的情况。

    显然,AUTOINC 表锁会导致并发插入的效率降低,为了提高插入的并发性,MySQL 从 5.1.22 版本开始,引入了一种可选的轻量级锁(mutex)机制来代替 AUTOINC 锁,可以通过参数 innodb_autoinc_lock_mode 来灵活控制分配自增值时的并发策略。
MySQL探险-4、事务及锁机制_第10张图片

  ④MyISAM 的锁

    MyISAM 的表锁有两种模式:
      ●Table Read Lock(表共享读锁):不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求。
      ●Table Write Lock(表独占写锁):会阻塞其他用户对同一表的读和写操作。

    MyISAM 表的读操作与写操作之间,以及写操作之间是串行的。当一个线程获得对一个表的写锁后, 只有持有锁的线程可以对表进行更新操作。其他线程的读、 写操作都会等待,直到锁被释放为止。

    默认情况下,写锁比读锁具有更高的优先级(当一个锁释放时,这个锁会优先给写锁队列中等候的获取锁请求,然后再给读锁队列中等候的获取锁请求)。

  ⑤InnoDB 的锁

    InnoDB 实现了以下两种类型的行锁:
      ●Share locks(共享锁、读锁、S 锁):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
      ●Exclusive locks(排他锁、写锁、X 锁):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

    为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁
      ●Intent Share Lock(意向共享锁):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
      ●Intent Exclusive Lock(意向排他锁):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

    注意:索引失效会导致行锁变表锁

    总结上述各种表级锁的兼容性如下:
MySQL探险-4、事务及锁机制_第11张图片

    总结文字规则如下:
      ●意向锁之间互不冲突。
      ●S 锁只和 S/IS 锁兼容,和其他锁都冲突。
      ●X 锁和其他所有锁都冲突。
      ●AI 锁只和意向锁兼容。

    锁模式

      不同类型锁的锁定范围大致如下:
MySQL探险-4、事务及锁机制_第12张图片

      记录锁(Record Locks)

        单个行记录上的锁。对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项。

        比如:

SELECT employee_name FROM employee WHERE id = 66;

        会在 id=66 的记录上加上记录锁,以阻止其他事务插入、更新、删除 id=1 这一行。

        在通过主键索引唯一索引对数据行进行 UPDATE 操作时,也会对该行数据加记录锁。比如:

UPDATE employee SET age = 28 WHERE id = 66;
      间隙锁(Gap Locks)

        当使用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB 会给符合条件的已有数据记录的索引项加锁。对于键值在条件范围内但并不存在的记录,叫做“间隙”。InnoDB 也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁。

        对索引项之间的“间隙”加锁,锁定记录的范围(对第一条记录前的间隙或最后一条将记录后的间隙加锁),不包含索引项本身。其他事务不能在锁范围内插入数据,这样就防止了别的事务新增幻影行。

        间隙锁基于非唯一索引,它锁定一段范围内的索引记录。间隙锁基于下面将会提到的 Next-Key Locking 算法。

        注意:使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。比如:

SELECT employee_name FROM employee WHERE id BETWEN 66 AND 88 FOR UPDATE;

        即所有在 (66, 88) 区间内的记录行都会被锁住,所有 id 为 67、68、69……85、86、87 的数据行的插入会被阻塞,但是 66 和 88 两条记录行并不会被锁住。

        GAP 锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。

      临键锁(Next-key Locks)

        Next-key 锁是记录锁间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间。(Next-key 锁的主要目的,也是为了避免幻读。如果把事务的隔离级别降级为 RC,Next-key 锁则会失效)

        Next-Key 可以理解为一种特殊的间隙锁,也可以理解为一种特殊的算法。通过 Next-key 锁可以解决幻读的问题。每个数据行上的非唯一索引列上都会存在一把 Next-key 锁,当某个事务持有该数据行的 Next-key 锁时,会锁住一段左开右闭区间的数据。

        注意:InnoDB 中行级锁是基于索引实现的,Next-key 锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在 Next-key 锁。Next-key 锁只在 REPEATABLE-READ(可重复读)隔离级别下有效。

      插入意向锁(Insert Intention Lock)

        插入意向锁是一种特殊的间隙锁,表示插入的意向,只有在 INSERT 的时候才会有这个锁。(注意:这个锁虽然也叫意向锁,但是和上面介绍的表级意向锁是两个完全不同的概念。)

        插入意向锁和插入意向锁之间互不冲突,所以可以在同一个间隙中有多个事务同时插入不同索引的记录。

        插入意向锁只会和间隙锁或 Next-key 锁冲突,正如上面所说,间隙锁唯一的作用就是防止其他事务插入记录造成幻读,正是由于在执行 INSERT 语句时需要加插入意向锁,而插入意向锁和间隙锁冲突,从而阻止了插入操作的执行。

      锁规则总结

        总结上述各种锁的兼容性如下:
MySQL探险-4、事务及锁机制_第13张图片
        其中,插入意向锁比较特殊,规则为:
          ●插入意向锁不影响其他事务加其他任何锁。也就是说,一个事务已经获取了插入意向锁,对其他事务是没有任何影响的。
          ●插入意向锁与间隙锁和 Next-key 锁冲突。也就是说,一个事务想要获取插入意向锁,如果有其他事务已经加了间隙锁或 Next-key 锁,则会阻塞。

        其他类型的锁的规则为:
          ●间隙锁不和其他锁(不包括插入意向锁)冲突。
          ●记录锁和记录锁冲突,Next-key 锁和 Next-key 锁冲突,记录锁和 Next-key 锁冲突。

        结合索引和上面的锁特性,可以得出 InnoDB 存储引擎的一些规则:
          ●在不通过索引条件查询时,InnoDB 会锁定表中的所有记录。所以,如果考虑性能,WHERE 语句中的条件查询的字段都应该加上索引
          ●InnoDB 通过索引来实现行锁,而不是通过锁住记录。因此,当操作的两条不同记录拥有相同的索引时,也会因为行锁被锁而发生等待。
          ●由于 InnoDB 的索引机制,数据库操作使用了主键索引,InnoDB 会锁住主键索引;使用非主键索引时,InnoDB 会先锁住非主键索引,再锁定主键索引。
          ●当查询的索引是唯一索引(不存在两个数据行具有完全相同的键值)时,InnoDB 存储引擎会将 Next-Key 锁降级为 Record 锁,即只锁住索引本身,而不是范围。
          ●InnoDB 对于辅助索引有特殊的处理,不仅会锁住辅助索引值所在的范围,还会将其下一键值加上 Gap 锁。
          ●InnoDB 使用 Next-Key 锁机制来避免 Phantom Problem(幻读问题)。

    锁状态查询

      用户可以使用 INFOMATION_SCHEMA 库下的 INNODB_TRX、INNODB_LOCKS 和 INNODB_LOCK_WAITS 表来监控当前事务并分析可能出现的锁问题。

      INNODB_TRX 的定义如下表所示,其由以下字段组成:

trx_id:InnoDB存储引擎内部唯一的事务ID。

trx_state:当前事务的状态。

trx_started:事务的开始时间。

trx_request_lock_id:等待事务的锁ID。如果trx_state的状态为LOCK WAIT,那么该字段代表当前事务等待之前事务占用的锁资源ID。

trx_wait_started:事务等待的时间。

trx_weight:事务的权重。反映了一个事务修改和锁住的行数,当发生死锁需要回滚时,会选择该数值最小的进行回滚。

trx_mysql_thread_id:线程ID。SHOW PROCESSLIST 显示的结果。

trx_query:事务运行的SQL语句。

      运行后效果如下:

mysql> SELECT * FROM information_schema.INNODB_TRX;
************** 1.row ***********************
trx_id:  xxxxx
trx_state: LOCK WAIT
trx_started: 2020-01-01 00:00:00
trx_requested_lock_id: xxxxx:xx:xx:xx
trx_wait_started: 2020-01-01 00:00:00
trx_weight: 2
trx_mysql_thread_id: xxxxx
trx_query: select * from parent lock in share mode

      INNODB_TRX 表只能显示当前运行的 InnoDB 事务,并不能直接判断锁的一些情况。

      如果需要查看锁,则需要访问表 INNODB_LOCKS,该表的字段组成如下所示:

lock_id:锁的ID。

lock_trx_id:事务的ID。

lock_mode:锁的模式。

lock_type:锁的类型(表锁还是行锁)。

lock_table:要加锁的表。

lock_index:锁住的索引。

lock_space:锁住的space id。

lock_page:事务锁定页的数量。若是表锁,则该值为NULL。

lock_rec:事务锁定行的数量。如果是表锁,则该值为NULL。

lock_data:锁住的内容。

      运行后效果如下:

mysql> SELECT * FROM information_schema.INNODB_LOCKS;
************** 1.row ***********************
lock_id: xxxxx:xx:xx:xx
lock_trx_id: xxxxx
lock_mode: S
lock_type: RECORD
lock_type: 'temp'.'parent'
lock_index: 'PRIMARY'
lock_space: 96
lock_page: 3
lock_rec: 2
lock_data: 1

      通过表 INNODB_LOCKS 查看每张表上锁的情况后,用户就可以来判断由此引发的等待情况。如果事务量非常大,其中锁和等待也时常发生,这个时候就不那么容易判断。

      通过表 INNODB_LOCK_WAITS,可以很直观的反映当前事务的等待。表 INNODB_LOCK_WAITS 由下列字段组成,如下所示:

requesting_trx_id:申请锁资源的事务ID。

requesting_lock_id:申请的锁的ID。

blocking_trx_id:阻塞的事务ID。

blocking_lock_id:阻塞的锁的ID。

      运行后效果如下:

mysql> SELECT * FROM information_schema.INNODB_LOCK_WAITS;
******************1.row**************************
requesting_trx_id: xxxxx
requesting_lock_id: xxxxx:xx:xx:xx
blocking_trx_id: xxxxx
blocking_lock_id: xxxxx:xx:xx:xx

      通过上述的 SQL 语句,可以清楚直观地看到哪个事务阻塞了另一个事务,然后使用上述的事务 ID 和锁 ID,去 INNODB_TRX 和 INNDOB_LOCKS 表中查看更加详细的信息。

  ⑥死锁

    死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。

    死锁的产生:

      ●当事务试图以不同的顺序锁定资源时,就可能产生死锁。多个事务同时锁定同一个资源时也可能会产生死锁。
      ●锁的行为和顺序和存储引擎相关。以同样的顺序执行语句,有些存储引擎会产生死锁有些则不会。
      因此,死锁有双重原因:真正的数据冲突、存储引擎的实现方式。

    死锁的检测:

      数据库系统实现了各种死锁检测和死锁超时的机制。InnoDB 存储引擎能检测到死锁的循环依赖并立即返回一个错误。

    死锁的恢复:

      死锁发生以后,只有部分或完全回滚其中一个事务,才能打破死锁。InnoDB 目前处理死锁的方法是:将持有最少行级排他锁的事务进行回滚。所以事务型应用程序在设计时必须考虑如何处理死锁,多数情况下只需要重新执行因死锁回滚的事务即可。

    外部锁的死锁检测:

      发生死锁后,InnoDB 一般都能自动检测到,并使一个事务释放锁并回退,另一个事务获得锁,继续完成事务。但在涉及外部锁,或涉及表锁的情况下,InnoDB 并不能完全自动检测到死锁, 这需要通过设置锁等待超时参数 innodb_lock_wait_timeout 来解决。

    死锁影响性能:

      死锁会影响性能而不是会产生严重错误,因为 InnoDB 会自动检测死锁状况并回滚其中一个受影响的事务。在高并发系统上,当许多线程等待同一个锁时,死锁检测可能导致速度变慢。有时当发生死锁时,禁用死锁检测(使用 innodb_deadlock_detect 配置选项)可能会更有效,这时可以依赖 innodb_lock_wait_timeout 设置进行事务回滚。

    MyISAM 避免死锁:

      在自动加锁的情况下,MyISAM 总是一次获得 SQL 语句所需要的全部锁,所以 MyISAM 表不会出现死锁。

    InnoDB避免死锁:

      ●为了在单个 InnoDB 表上执行多个并发写入操作时避免死锁,可以在事务开始时通过为预期要修改的每个元祖(行)使用 SELECT … FOR UPDATE 语句来获取必要的锁,即使这些行的更改语句是在之后才执行的。

      ●在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁。而不应先申请共享锁、更新时再申请排他锁。因为这时候当用户再申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。

      ●如果事务需要修改或锁定多个表,则应在每个事务中以相同的顺序使用加锁语句。在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。

      ●通过 SELECT … LOCK IN SHARE MODE 获取行的读锁后,如果当前事务再需要对该记录进行更新操作,则很有可能造成死锁。

      ●改变事务隔离级别。

      SELECT … FOR UPDATE

        FOR UPDATE 仅适用于 InnoDB,且必须在事务块(BEGIN/COMMIT)中才能生效。在进行事务操作时,通过“FOR UPDATE”语句,MySQL 会对查询结果集中每行数据都添加排他锁,其他线程对该记录的更新与删除操作都会阻塞。排他锁包含行锁、表锁。

        InnoDB 这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。

        假设有个表单表 products ,里面有 id、product_name 二个列,其中,id 是主键。
        明确指定主键,并且有此数据时,使用行锁,比如:

SELECT * FROM products WHERE id = 3 FOR UPDATE;
SELECT * FROM products WHERE id = 3 AND type = 1 FOR UPDATE;

        明确指定主键,但无此数据时,无锁。
        无主键,使用表锁,比如:

SELECT * FROM products WHERE product_name = 'Mouse' FOR UPDATE;

        主键条件不能走索引时,使用表锁,比如:

SELECT * FROM products WHERE id <> 3 FOR UPDATE;
SELECT * FROM products WHERE id LIKE 3 FOR UPDATE;

    如果出现死锁,可以用 show engine innodb status; 命令来确定最后一个死锁产生的原因。返回结果中包括死锁相关事务的详细信息(如:引发死锁的 SQL 语句、事务已经获得的锁、正在等待什么锁 以及 被回滚的事务等)。据此可以分析死锁产生的原因和改进措施。

  ⑦MVCC

    MVCC(Multiversion concurrency control)即为多版本并发控制,是数据库并发访问的并发控制技术。

Multiversion concurrency control (MCC or MVCC), is a concurrency control
method commonly used by database management systems to provide
concurrent access to the database and in programming languages to
implement transactional memory.

    大多数事务型存储引擎实现都不是简单的行级锁。基于提升并发性考虑,一般都同时实现了多版本并发控制(MVCC),包括 MySQL、Oracle、PostgreSQL 等。只是实现机制各不相同。

    数据库的并发控制机制有很多,最为常见的就是锁机制。锁机制一般会给竞争资源加锁,阻塞读或者写操作来解决事务之间的竞争条件,最终保证事务的可串行化。而 MVVC 则引入了另外一种并发控制,它让读写操作互不阻塞,每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回,由此解决了事务的竞争条件。

    可以认为 MVCC 是行级锁的一个变种,但它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只是锁定必要的行。MVCC 的实现是通过保存数据在某个时间点的快照来实现的。也就是说不管需要执行多长时间,每个事物看到的数据都是一致的。

    典型的 MVCC实现方式,分为乐观(optimistic)并发控制和悲观(pressimistic)并发控制。

    InnoDB 的 MVVC 实现

      多版本并发控制仅仅是一种技术概念,并没有统一的实现标准, 其的核心理念就是数据快照,不同的事务访问不同版本的数据快照,从而实现不同的事务隔离级别。虽然字面上是说具有多个版本的数据快照,但这并不意味着数据库必须拷贝数据,保存多份数据文件,这样会浪费大量的存储空间。InnoDB 通过事务的 undo 日志巧妙地实现了多版本的数据快照。(undo log 本身用来在事务中回滚数据,因此快照数据本身是没有额外开销。后续会详细讨论。)

      在 InnoDB 中,会在每行数据后添加两个额外的隐藏的值来实现 MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。 在实际操作中,存储的并不是时间,而是事务的版本号(system version number),每开启一个新事务,事务的版本号就会递增。 在可重读 REPEATABLE-READ 事务隔离级别下:
        ●SELECT 时,读取创建版本号 <= 当前事务版本号,删除版本号为空> 当前事务版本号。
        ●INSERT 时,保存当前事务版本号为行的创建版本号。
        ●DELETE 时,保存当前事务版本号为行的删除版本号。
        ●UPDATE 时,插入一条新记录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行。
      通过 MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用。大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。

      注意:MVCC 只在 COMMITTED-READ(读提交)和 REPEATABLE-READ(可重复读)两种隔离级别下工作。

      MVCC 还有部分实现细节,因为涉及日志相关知识,放在下面一起讨论。


四、事务日志:

  ①事务日志

    InnoDB 使用日志来减少提交事务时的开销。因为日志中已经记录了事务,就无须在每个事务提交时把缓冲池的脏块刷新(flush)到磁盘中。

    事务修改的数据和索引通常会映射到表空间的随机位置,所以刷新这些变更到磁盘需要很多随机 I/O。

    InnoDB 假设使用常规磁盘,随机 I/O 比顺序 I/O 昂贵得多,因为一个 I/O 请求需要时间把磁头移到正确的位置,然后等待磁盘上读出需要的部分,再转到开始位置。

    InnoDB 用日志把随机 I/O 变成顺序 I/O。一旦日志安全写到磁盘,事务就持久化了,即使断电了,InnoDB 也可以重放日志并且恢复已经提交的事务。

    InnoDB 使用一个后台线程智能地刷新这些变更到数据文件。这个线程可以批量组合写入,使得数据顺序写入,以提高效率。

    目前来说,大多数存储引擎都是这样实现的,通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。

  ②事务的实现

    事务的实现是基于数据库的存储引擎。不同的存储引擎对事务的支持程度不一样。MySQL 中支持事务的存储引擎有 InnoDB 和 NDB。

    事务的实现不能只依靠锁,还需要事务日志的配合。总的来说就是:
      ●事务的原子性是通过 undo log 来实现的。
      ●事务的持久性是通过 redo log 来实现的。
      ●事务的隔离性是通过(读写锁 + MVCC)来实现的。
      ●最终事务的一致性是通过原子性、持久性、隔离性来实现的。

    MySQL 日志主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。作为开发人员,需要重点关注的一般是binlog(二进制日志)和事务日志(包括 redo log 和 undo log)。

    binlog(二进制日志)

      binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog 是 MySQL 的逻辑日志,并且由 Server 层进行记录,使用任何存储引擎的 MySQL 数据库都会记录 binlog 日志。

      日志的分类:
        ●逻辑日志:可以简单理解为记录的就是 SQL 语句。
        ●物理日志:因为 MySQL 数据最终是保存在数据页中的,物理日志记录的就是数据页变更。

      binlog 是通过追加的方式进行写入的,可以通过 max_binlog_size 参数设置每个 binlog 文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。

      binlog 的使用场景

        在实际应用中,binlog 的主要使用场景有两个,分别是主从复制数据恢复

        ●主从复制:在 Master 端开启 binlog,然后将 binlog 发送到各个 Slave 端,Slave 端重放 binlog 从而达到主从数据一致。

        ●数据恢复:通过使用 MySQL 的 binlog 工具来恢复数据。

      binlog 的刷盘时机

        对于 InnoDB 存储引擎而言,只有在事务提交时才会记录 binlog,此时记录还在内存中,那么 binlog 是什么时候刷到磁盘中的呢?MySQL 通过 sync_binlog 参数控制 binlog 的刷盘时机,取值范围是 0-N:
          ●0:不强制要求,由系统自行判断何时写入磁盘。
          ●1:每次 commit 的时候都要将 binlog 写入磁盘。
          ●N:每 N 个事务提交,才会将 binlog 写入磁盘。

        从上面可以看出,sync_binlog 最安全的是设置为 1,这也是 MySQL 5.7.7 之后版本的默认值。但是设置一个大一些的值可以提升数据库性能,因此实际情况下也可以将值适当调大,牺牲一定的一致性来获取更好的性能。

      binlog 的日志格式

        binlog 日志有三种格式,分别为 STATMENT、ROW 和 MIXED。

        在 MySQL 5.7.7 之前,默认的格式是STATEMENT;MySQL 5.7.7 之后,默认值是 ROW。日志格式通过 binlog-format 指定。

        ●STATMENT:
          基于 SQL 语句的复制(statement-based replication,SBR),每一条会修改数据的 SQL 语句都会记录到 binlog 中。

          ▶优点:不需要记录每一行的变化,减少了 binlog 日志量,节约了 I/O, 从而提高了性能。

          ▶缺点:在某些情况下会导致主从数据不一致,比如执行sysdate()、sleep()等。

        ●ROW:
          基于行的复制(row-based replication,RBR),不记录每条 SQL 语句的上下文信息,仅需记录哪条数据被修改了。

          ▶优点:不会出现某些特定情况下的存储过程、或 function、或 trigger 的调用和触发无法被正确复制的问题。

          ▶缺点:会产生大量的日志,尤其是 alter table 的时候会让日志暴涨。

        ●MIXED:
          基于 STATMENT 和 ROW 两种模式的混合复制(mixed-based replication,MBR)。一般的复制使用 STATEMENT 模式保存 binlog,对于STATEMENT 模式无法复制的操作使用 ROW 模式保存 binlog。

    redo log(重做日志)

      redo log 的来由

        为了保障事务的持久性,最简单的做法是在每次事务提交的时候,将该事务涉及修改的数据页全部刷新到磁盘中。但是这么做会有严重的性能问题,主要体现在两个方面:
          ●InnoDB 是以为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这个时候将完整的数据页刷到磁盘的话,太浪费 I/O 资源了。
          ●一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机 I/O 写入性能太差。

        因此 MySQL 设计了 redo log,就只记录事务对数据页做了哪些修改,这样就能解决性能问题了(相对而言文件更小并且是顺序 I/O)。

      redo log 的刷盘

        redo log 包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo log file)。MySQL 每执行一条 DML 语句,先将记录写入 redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file。这种先写日志,再写磁盘的技术就是 MySQL 里经常说到的 WAL(Write-Ahead Logging)技术。

        在计算机操作系统中,用户空间(user space)下的缓冲区数据一般情况下是无法直接写入磁盘的,中间必须经过操作系统内核空间(kernel space)缓冲区(OS Buffer)。因此,redo log buffer 写入 redo log file 实际上是先写入 OS Buffer,然后再通过系统调用 fsync() 将其刷到 redo log file 中:
MySQL探险-4、事务及锁机制_第14张图片

fsync函数:包含在UNIX系统头文件#include 中,用于同步内存中所有已修改的文件数据到储存设备。

        MySQL 支持三种将 redo log buffer 写入 redo log file 的时机,可以通过 innodb_flush_log_at_trx_commit 参数配置,各参数值含义如下:(默认为 1)
MySQL探险-4、事务及锁机制_第15张图片
MySQL探险-4、事务及锁机制_第16张图片
        InnoDB 通过 Force Log at Commit 机制实现事务的持久性,即当事务 COMMIT 时,必须先将该事务的所有日志都写入到 redo log 文件进行持久化之后,COMMIT 操作才算完成。

      redo log 记录形式

        redo log 全部写入磁盘后事务就算 COMMIT 成功了,但是此时事务修改的数据还在内存的缓冲区中,称其为脏页,这些数据会依据检查点(CheckPoint)机制择时刷新到磁盘中,然后删除相应的 redo log,但是如果在这个过程中数据库 Crash 了,那么数据库重启时,会依据 redo log file 将那些还在内存中未更新到磁盘上的数据进行恢复。

        redo log 记录数据页的变更,在实现上采用了大小固定循环写入的方式,当写到结尾时,会回到开头循环写日志。如下图:
MySQL探险-4、事务及锁机制_第17张图片

        在 InnoDB 中,既有 redo log 需要刷盘,还有数据页也需要刷盘,redo log 存在的意义主要就是降低对数据页刷盘的要求。在上图中,write pos 表示 redo log 当前记录的 LSN(逻辑序列号)位置,check point 表示数据页更改记录刷盘后对应 redo log 所处的 LSN(逻辑序列号)位置。

        write pos 到 check point 之间的部分是 redo log 空着的部分,用于记录新的记录;check point 到 write pos 之间是 redo log 待落盘的数据页更改记录。当 write pos 追上 check point 时,会先推动 check point 向前移动,空出位置再记录新的日志。

        启动 InnoDB 的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。因为 redo log 记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如binlog)要快很多。

        重启 InnoDB 时,首先会检查磁盘中数据页的 LSN,如果数据页的 LSN 小于日志中的 LSN,则会从 checkpoint 开始恢复。

        还有一种情况,在宕机前正处于 checkpoint 的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度,此时会出现数据页中记录的 LSN 大于日志中的 LSN,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。

      Redo Log LSN

        LSN 这个概念比较复杂。LSN 全称为日志的逻辑序列号(log sequence number),在 InnoDB 存储引擎中,lsn占用 8 个字节。LSN 的值会随着日志的写入而逐渐增大。(可以简单理解 LSN 就是记录从开始到现在已经产生了多少字节的 Redo log 值)

        存储方式两个指针又是通过 LSN 计算得到指向位置,因为 LSN 记录的是文件的大小字节,当超过文件大小时,需要用取模计算出这两个指针位置,取模使得写入就会从头开始写,这样使得两个指针在一个文件中,一直落在循环位置,你追我赶的过程。这就是 Redo log 环形逻辑思想设计实现。

        上面提到 LSN 比较复杂,是因为它有很多个值,输入命令 show engine innodb status; ,可以看到四个的 lsn 记录:
MySQL探险-4、事务及锁机制_第18张图片
        为了方便识别,上面为它们重新命名。如:内存日记(redo log buffer lsn)、磁盘日记(redo log file lsn)、内存数据页(data buffer lsn)、数据磁盘数据页(data disk lsn)、检查点(chckpoint lsn)。
        一般有关系:redo log buffer lsn >= redo log file lsn。如果刷盘时机为 1,则 redo log buffer lsn = redo log file lsn。
        一般有关系:data buffer lsn > data disk lsn。如果已经刷入数据磁盘,则 data buffer lsn = data disk lsn。

        前面提到检查点刷盘、数据刷盘和日记刷盘(如果有日记刷盘:则说明我假设的日记刷盘的时机设置值不为1,因为为 1 是同步的,即始终 redo log buffer lsn = redo log file lsn,不会由检查点触发刷日记磁盘)。都说 Redo log 是环形记录,下面结合 LSN 给出记录过程虚构图,可以对比 Redo log 存储方式图:
MySQL探险-4、事务及锁机制_第19张图片
        1 到 8 按时间顺序发生。1 点是假设最初的状态;2、3 点写日记磁盘;4 点是触发了检查点 checkpoint 进行刷盘,「checkpoint lsn = 1 开始」,刷盘结束并更新「checkpoint lsn = 512」。在 5 点、6 点已经刷过了一循环内存、二循环内存,「从头开始写入 log,两个指针指向回到了头部」。第 7 点也是一个触发 checkpoint 的过程。9 点是假设没有更新,最后达到平衡的结果,即内存中数据页和日记都完成了刷盘。

        整体流程为:
          ●在某些情况下,触发 checkpoint,触发数据页和日志页刷盘,此时将内存中的脏数据(数据脏页和日志脏数据)分别刷到数据磁盘和日记磁盘中,而且两者刷盘速度不一样。checkpoint 有保护机制,当数据刷盘速度超过日志刷盘时,将会暂时停止数据刷盘,等待日志刷盘进度超过数据刷盘。
          ●刷盘时,对于数据磁盘,全部都在内存中,此时每次刷一个数据页到内存更新数据页也更新了 data disk lsn 为 data buffer lsn(在更新内存数据页时,会更新 data buffer lsn)。
          ●对于日记磁盘,除了要记录 checkpoint lsn 的值为检查点 checkpoint 的值(必须在结束时直接记录一个值,速度很快),这里是针对日记刷盘时机不是 1(1 是同步缓存刷日记刷盘)时,并且日记还没刷到日记磁盘需要触发将缓存中日记提前刷到日记磁盘中。此时会将 redo buffer log 刷到 redo log file 中,也更新了 redo log file lsn 为 redo log buffer lsn 。

      Redo log 容灾恢复过程

        启动数据库时,InnoDB 会扫描数据磁盘的数据页 data disk lsn 和日志磁盘中的 checkpoint lsn。两者相等则从 checkpoint lsn 点开始恢复,恢复过程是利用 redo log 到 buffer pool,直到 checkpoint lsn 等于 redo log file lsn,则恢复完成。

        如果 checkpoint lsn 小于 data disk lsn,说明在检查点触发后还没结束刷盘时数据库宕机了。因为 checkpoint lsn 最新值是在数据刷盘结束后才记录的,检查点之后有一部分数据已经刷入数据磁盘,这个时候数据磁盘已经写入部分的部分恢复将不会重做,直接跳到没有恢复的 lsn 值开始恢复。

      redo log 与 binlog 的区别

        redo log 是物理日志,binlog 是逻辑日志
          ●物理的日志可看作是实际数据库中数据页上的变化信息,只看重结果,而不在乎是通过“何种途径”导致了这种结果。
          ●逻辑的日志可看作是通过了某一种方法或者操作手段导致数据发生了变化,存储的是逻辑性的操作

        redo log 是基于 crash recovery,保证 MySQL 宕机后的数据恢复;而 binlog 是基于 point-in-time recovery,保证服务器可以基于时间点对数据进行恢复,或者对数据进行备份

        binlog 日志只用于归档,只依靠 binlog 是没有 crash-safe 能力的。但只有 redo log 也不行,因为 redo log 是 InnoDB 特有的,且日志上的记录落盘后会被覆盖掉。因此需要 binlog 和 redo log 二者同时记录,才能保证当数据库发生宕机重启时,数据不会丢失。
MySQL探险-4、事务及锁机制_第20张图片

    undo log(回滚日志)

      事务的原子性在底层就是通过 undo log 实现的。undo log 主要记录了数据的逻辑变化,比如一条 INSERT 语句,对应一条 DELETE 的 undo log;对于每个 UPDATE 语句,对应一条相反的 UPDATE 的 undo log,这样在发生错误时,就能回滚到事务之前的数据状态。同时,undo log 也是 MVCC 实现的关键。

      有关 MVCC 的进一步说明

        在 InnoDB 中,每行记录实际上都包含了两个隐藏字段:事务id(trx_id)和回滚指针(roll_pointer)。
          ●trx_id:事务id。每次修改某行记录时,都会把该事务的事务id赋值给 trx_id 隐藏列。
          ●roll_pointer:回滚指针。每次修改某行记录时,都会把 undo 日志地址赋值给 roll_pointer 隐藏列。

        假设在 employee 表中插入一条数据:
MySQL探险-4、事务及锁机制_第21张图片
        之后两个事务 id 分别为 111、222 的事务对这条记录进行 UPDATE 操作,操作流程如下:
MySQL探险-4、事务及锁机制_第22张图片
        由于每次变动都会先把 undo 日志记录下来,并用 roll_pointer 指向 undo 日志地址。因此可以认为,对该条记录的修改日志串联起来就形成了一个版本链,版本链的头节点就是当前记录最新的值。
MySQL探险-4、事务及锁机制_第23张图片

      ReadView

        如果数据库隔离级别是 READ UNCOMMITTED(未提交读),那么读取版本链中最新版本的记录即可。如果隔离级别是 SERIALIZABLE(串行化),事务之间是加锁执行的,不存在读不一致的问题。

        但是如果隔离级别是 READ COMMITTED(已提交读)或者 REPEATABLE READ(可重复读),就需要遍历版本链中的每一条记录,判断该条记录是否对当前事务可见,直到找到为止(遍历完还没找到就说明记录不存在)。

        InnoDB 通过 ReadView 实现了这个功能。ReadView 中主要包含以下 4 个内容:
          ●m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。
          ●min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中的最小值。
          ●max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。
          ●creator_trx_id:表示生成该 ReadView 事务的事务id。

        有了 ReadView 之后,可以基于以下步骤判断某个版本的记录是否对当前事务可见
          ●如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
          ●如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
          ●如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
          ●如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中。如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

        在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。READ COMMITTED 在每次读取数据前都会生成一个 ReadView,这样就能保证每次都能读到其它事务已提交的数据。REPEATABLE READ 只在第一次读取数据时生成一个 ReadView,这样就能保证后续读取的结果完全一致。

  ③日志的记录顺序

    在 MySQL 执行更新语句时,都会涉及到 redo log 日志和 binlog 日志的读写。一条更新语句的执行过程如下:
MySQL探险-4、事务及锁机制_第24张图片
    从上图可以看出,MySQL 在执行更新语句的时候,在服务层进行语句的解析和执行,在引擎层进行数据的提取和存储。在日志记录方面,在服务层对 binlog 进行写入,在 InnoDB 内进行 redo log 的写入。

    在对 redo log 写入时有两个阶段的提交,一是 binlog 写入之前 prepare 状态的写入,二是 binlog 写入之后 commit 状态的写入。

    之所以使用两阶段提交,是为了保证数据的一致性。为了证明这种方法的可行性,可以通过反证法来探讨,即使用单阶段提交(先提交 binlog,后提交 redo log;先提交 redo log,后提交 binlog)。最终可以推导出单阶段提交在某些情况下都可能导致数据不一致。

    先写入 redo log,后写入 binlog

      在写完 redo log 之后,数据此时具有 crash-safe 能力,因此即使系统崩溃,数据也会恢复成事务开始之前的状态。但是,若在 redo log 写完时候,binlog 写入之前,系统发生了宕机。此时 binlog 没有对该更新语句进行保存,导致当使用 binlog 进行数据库的备份或者恢复时,就少了该更新语句。从而使得 id=2 这一行的数据没有被更新。
MySQL探险-4、事务及锁机制_第25张图片

    先写入 binlog,后写入 redo log

      在写完 binlog 之后,所有的语句都被保存,所以通过 binlog 复制或恢复出来的数据库中 id=2 这一行的数据会被更新为 a=1。但是如果在 redo log 写入之前,系统崩溃,那么 redo log 中记录的这个事务会无效,导致实际数据库中 id=2 这一行的数据并没有更新。
MySQL探险-4、事务及锁机制_第26张图片
    由此可见,两阶段提交就是为了避免上述的问题,使得 binlog 和 redo log 中保存的信息是一致的。


五、InnoDB细节详解与总结:

  MySQL 区别于其他数据库的最为重要的特点就是其插件式的表存储引擎。而在众多存储引擎中,InnoDB 是最为常用的存储引擎。从 MySQL5.5.8 版本开始,InnoDB 存储引擎成为默认的存储引擎。

  InnoDB 存储引擎支持事务,其设计目标主要面向在线事务处理(OLTP)的应用。其特点是行锁设计、支持外键、支持非锁定读(即默认读操作不会产生锁)。

  InnoDB 通过使用多版本并发控制(MVCC)来获取高并发性,并且实现了 SQL 标准的 4 种隔离级别(默认为 REPEATABLE 级别)。同时,使用一种被称为 next-key-locking 的策略来避免幻读现象的产生。除此之外,InnoDB 存储引擎还提供了插入缓冲(insert buffer)、二次写(double write)、自适应哈希索引(adaptive hash index)、预读(read ahead)等高性能和高可用的功能。

  ①InnoDB 的体系架构

MySQL探险-4、事务及锁机制_第27张图片
    InnoDB 的架构分为两块:内存中的结构和磁盘上的结构。InnoDB 使用日志先行策略(WAL),将数据修改先在内存中完成,并且将事务记录成重做日志(Redo Log),转换为顺序 I/O 高效的提交事务。这里的日志先行,说的是日志记录到数据库以后,对应的事务就可以返回给用户,表示事务完成。但是实际上,这个数据可能还只在内存中修改完,并没有刷到磁盘上去。具体在上文已有介绍,不再赘述。

    InnoDB 内存中的结构

      内存中的结构主要包括 Buffer Pool、Change Buffer、Adaptive Hash Index 以及 Log Buffer 四部分。如果从内存上来看,Change Buffer 和 Adaptive Hash Index 占用的内存都属于 Buffer Pool,Log Buffer占用的内存与 Buffer Pool独立。

      Buffer Pool

        InnoDB 存储引擎是基于磁盘存储的,并将其中的记录按照的方式进行管理。但是由于 CPU 速度和磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池记录来提高数据库的的整体性能。

        在数据库中进行读取操作,首先将从磁盘中读到的页放在缓冲池中,下次再读相同的页中时,首先判断该页是否在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页。否则,读取磁盘上的页。

        对于数据库中页的修改操作,则首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上。页从缓冲池刷新回磁盘的操作并不是在每次页发生更新时触发,而是通过一种称为 CheckPoint 的机制刷新回磁盘。所以,缓冲池的大小直接影响着数据库的整体性能,可以通过配置参数 innodb_buffer_pool_size 来设置。

        具体来看,缓冲池中缓存的数据页类型有:索引页、数据页、undo 页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB 存储的锁信息(lock info)和数据字典信息(data dictionary)。

        从架构图上可以看到,InnoDB 存储引擎的内存区域除了有缓冲池之外,还有重做日志缓冲和额外内存池。InnoDB 存储引擎首先将重做日志信息先放到这个缓冲区中,然后按照一定频率将其刷新到重做日志文件中。重做日志缓冲一般不需要设置的很大,该值可由配置参数 innodb_log_buffer_size 控制。

        通常 MySQL 服务器的 80% 的物理内存会分配给 Buffer Pool。

        基于效率考虑,InnoDB 中数据管理的最小单位为页,默认每页大小为 16KB,每页包含若干行数据。为了提高缓存管理效率,InnoDB 的缓存池通过一个页链表实现,很少访问的页会通过缓存池的 LRU 算法淘汰出去。InnoDB 的缓冲池页链表分为两部分:New sublist(默认占 5/8 缓存池)和 Old sublist(默认占 3/8 缓存池,可以通过 innodb_old_blocks_pct 修改,默认值为 37),其中新读取的页会加入到 Old sublist 的头部,而 Old sublist 中的页如果被访问,则会移到 New sublist 的头部。
MySQL探险-4、事务及锁机制_第28张图片
        缓冲池的使用情况可以通过 SHOW ENGINE INNODB STATUS 命令查看。其中缓存池相关信息如下:

----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 8585216 # 分配给InnoDB缓存池的内存大小(字节)
Dictionary memory allocated 239276 # 分配给InnoDB数据字典的内存大小(字节)
Buffer pool size   512 # 缓存池的页数目
Free buffers       256 # 缓存池空闲页链表的页数目
Database pages     256 # 缓存池LRU链表的页数目
Old database pages 0
Modified db pages  3 # 修改过的页数目
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 440, created 57, written 61
0.38 reads/s, 0.38 creates/s, 0.42 writes/s
Buffer pool hit rate 988 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 256, unzip_LRU len: 0
I/O sum[48]:cur[0], unzip sum[0]:cur[0]
      Change Buffer

        通常来说,InnoDB 辅助索引不同于聚集索引的顺序插入,如果每次修改二级索引都直接写入磁盘,则会有大量频繁的随机 I/O。Change buffer 的主要目的是将对非唯一辅助索引页的操作缓存下来,以此减少辅助索引的随机 I/O,并达到操作合并的效果。它会占用部分 Buffer Pool 的内存空间。在 MySQL 5.5 之前 Change Buffer 其实叫 Insert Buffer,最初只支持 insert 操作的缓存,随着支持操作类型的增加,改名为 Change Buffer。如果辅助索引页已经在缓冲区了,则直接修改即可;如果不在,则先将修改保存到 Change Buffer。Change Buffer 的数据在对应辅助索引页读取到缓冲区时合并到真正的辅助索引页中。Change Buffer 内部实现也是使用的 B+ 树

        简单来说,对于非聚簇索引的插入或者更新操作,不是每一次都直接插入到索引页中,而是先判断插入的非聚集索引是否在缓冲池中。若在,则直接插入;若不在,则先放入到一个 Change Buffer 中。看似数据库这个非聚集的索引已经查到叶节点,而实际没有,这时存放在另外一个位置。然后再以一定的频率和情况进行 Change Buffer 和非聚簇索引页子节点的合并操作。这时通常能够将多个插入合并到一个操作中,这样就大大提高了对于非聚簇索引的插入性能。

        可以通过 innodb_change_buffering 配置是否缓存辅助索引页的修改,默认为 all,即缓存 insert/delete-mark/purge 操作(注:MySQL 删除数据通常分为两步,第一步是 delete-mark,即只标记,而 purge 才是真正的删除数据)。
MySQL探险-4、事务及锁机制_第29张图片
        查看 Change Buffer 信息也可以通过 SHOW ENGINE INNODB STATUS 命令:

-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
 insert 0, delete mark 0, delete 0
discarded operations:
 insert 0, delete mark 0, delete 0
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
Hash table size 2267, node heap has 0 buffer(s)
0.00 hash searches/s, 3.48 non-hash searches/s
        ChangeBuffer vs Redo log

          Redo log 主要节省的是随机写磁盘的 I/O 消耗(转成顺序写),而 ChangeBuffer 主要节省的则是随机读磁盘的 I/O 消耗。

          Redo log 与 ChangeBuffer (含磁盘持久化) 这 2 个机制,不同之处在于优化了整个变更流程的不同阶段。

          先简化抽象一个变更流程:
            1、从磁盘读取待变更的行所在的数据页,读入内存页中。
            2、对内存页中的行,执行变更操作。
            3、将变更后的数据页,写入至数据磁盘中。
          其中,流程中的步骤 1 涉及随机读磁盘 I/O;步骤 3 涉及随机写磁盘 I/O;刚好对应 ChangeBuffer 和 Redo log。

          ChangeBuffer:优化了步骤 1——避免了随机读磁盘 I/O ,将不在内存中的数据页的操作写入ChangeBuffer 中,而不是将数据页从磁盘读入内存页中。
          Redo log:优化了步骤 3——避免了随机写磁盘 I/O,将随机写磁盘,优化为了顺序写磁盘(写 Redo log,确保 crash-safe)。

          在 InnoDB 中, ChangeBuffer 机制不是一直会被应用到,仅当待操作的数据页当前不在内存中,需要先读磁盘加载数据页时,ChangeBuffer 才有用武之地。

        ChangeBuffer 的 merge 过程

          merge 是指将 ChangeBuffer 应用到内存中,得到最新的内存页,并将数据页的改动记录到 Redo log 中的一个过程。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

          merge 过程大概分为三步:
            ●从磁盘读入数据页到内存(老版本的数据页)。
            ●从 ChangeBuffer 里找出这个数据页的 ChangeBuffer 记录 (可能有多个),依次应用,得到新版数据页。
            ●写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。

      Adaptive Hash Index

        自适应哈希索引(AHI)查询非常快,一般时间复杂度为 O(1),相比 B+ 树通常要查询 3~4次,效率会有很大提升。InnoDB 通过观察索引页上的查询次数,如果发现建立哈希索引可以提升查询效率,则会自动建立哈希索引,称之为自适应哈希索引,不需要人工干预,可以通过 innodb_adaptive_hash_index 开启,MySQL 5.7 默认开启。

        考虑到不同系统的差异,有些系统开启自适应哈希索引可能会导致性能提升不明显,而且为监控索引页查询次数增加了多余的性能损耗,MySQL 5.7 更改了 AHI 实现机制,每个 AHI 都分配了专门分区,通过 innodb_adaptive_hash_index_parts 配置分区数目,默认是8个。(从上面命令的结也可以看出)

        注意:自适应哈希索引有一个要求,即对这个页的连续访问模式必须是一样的,也就是说其查询的条件(WHERE)必须完全一样,而且必须是连续的。

      Log Buffer

        当缓冲池中的页的版本比磁盘要新时,数据库需要将新版本的页从缓冲池刷新到磁盘。但是如果每次一个页发送变化,就进行刷新,那么性能开发是非常大的,于是 InnoDB 采用了 Write Ahead Log 策略,即当事务提交时,先写重做日志,然后再择时将脏页写入磁盘。如果发生宕机导致数据丢失,就通过重做日志进行数据恢复。

        Log Buffer 是重做日志在内存中的缓冲区,大小由 innodb_log_buffer_size 定义,默认是 16M。一个大的 Log Buffer 可以让大事务在提交前不必将日志中途刷到磁盘,可以提高效率。如果一个系统有很多修改很多行记录的大事务,可以增大该值。

        InnoDB 存储引擎会首先将重做日志信息先放入重做日志缓冲中,然后再按照一定频率将其刷新到重做日志文件。重做日志缓冲一般不需要设置得很大,因为一般情况每一秒钟都会讲重做日志缓冲刷新到日志文件中。(具体策略可以回到上文 redo log 部分)

        除了每秒刷新机制之外,每次事务提交时重做日志缓冲也会刷新到日志中。InnoDB 是事务的存储引擎,其通过 Force Log at Commit 机制实现事务的持久性,即当事务提交时,必须先将该事务的所有日志写入到重做日志文件进行持久化,然后事务的提交操作完成才算完成。

        为了确保每次日志都写入到重做日志文件,在每次讲重做日志缓冲写入重做日志后,必须调用一次 fsync 操作,将缓冲文件从文件系统缓存中真正写入磁盘。

        具体参数设置及效果见上文。

    InnoDB 磁盘上的结构

      InnoDB 的主要的磁盘文件主要分为两大类:表空间、redo日志文件。其中二进制文件(binlog等文件)是 MySQL Server 层维护的文件,所以未列入 InnoDB 的磁盘文件中。
        ●表空间:分为系统表空间(MySQL 目录的 ibdata1 文件)、临时表空间、常规表空间、Undo 表空间以及 file-per-table 表空间(MySQL 5.7 默认打开 file_per_table 配置)。系统表空间又包括了 InnoDB 数据字典、双写缓冲区(Doublewrite Buffer)、修改缓存(Change Buffer)、Undo 日志等。
        ●redo 日志:存储的就是 Log Buffer 刷到磁盘的数据。

      假设建表 t,参数如下:

CREATE TABLE t (a INT, b CHAR (20), PRIMARY KEY (a)) ENGINE=InnoDB;

      可以在 MySQL 目录中看到数据库名称的目录,然后里面有 db.opt、t.frm 和 t.ibd 3个文件。
      db.opt 保存了数据库的默认字符集 utf8mb4 和校验方法 utf8mb4_general_ci。
      t.frm 是表的数据字典信息(InnoDB 数据字典信息主要是存储在系统表空间 ibdata1 文件中,由于历史原因才在 t.frm 多保留了一份)。
      t.ibd 是表的数据索引

      可以通过如下命令查询 MySQL 数据存储的目录:

SHOW GLOBAL VARIABLES LIKE "%datadir%";
      InnoDB 表结构

        InnoDB 与 MyISAM 不同,它在系统表空间存储数据字典信息,因此它的表不能像 MyISAM 那样直接拷贝数据表文件移动。

        MySQL 5.7 采用的文件格式是 Barracuda,它支持 COMPACT 和 DYNAMIC 这两种新的行记录格式。创建表时可以通过 ROW_FORMAT 指定行记录格式,默认是 DYNAMIC。
        可以通过如下命令查看表信息:

SHOW TABLE STATUS;

        此外,也可使用如下命令查看单张表的信息:

SELECT * FROM INFORMATION_SCHEMA.INNODB_SYS_TABLES WHERE NAME='数据库名/表名';

        InnoDB 表使用上有一些限制,比如一个表最多只能有 64 个辅助索引、一行大小不能超过 65535、组合索引不能超过 16 个字段等。一般使用应该不会突破限制,详细见官方文档相关说明。

      InnoDB 表空间

        表空间根据类型可以分为系统表空间、File-Per-Table 表空间、常规表空间、Undo 表空间、临时表空间等。
          ●系统表空间:是一个共享的表空间。包含内容有数据字典、双写缓冲、修改缓冲、undo 日志,以及在系统表空间创建的表的数据和索引。
          ●常规表空间:类似系统表空间,也是一种共享的表空间。可以通过 CREATE TABLESPACE 创建常规表空间,多个表可共享一个常规表空间,也可以修改表的表空间。(注意:必须删除常规表空间中的表后才能删除常规表空间)
          ●File-Per-Table 表空间:MySQL InnoDB 新版本提供了 innodb_file_per_table 选项,每个表可以有单独的表空间数据文件(.ibd),而不是全部放到系统表空间数据文件 ibdata1 中。在 MySQL5.7 中该选项默认开启。
          ●其他表空间:其他表空间中 Undo 表空间存储的是 Undo 日志。除了存储在系统表空间外,Undo 日志也可以存储在单独的 Undo 表空间中。临时表空间则是非压缩的临时表的存储空间,默认是数据目录的 ibtmp1 文件。所有临时表共享,压缩的临时表用的是 File-Per-Table 表空间。

        表空间文件结构上分为段、区、页:
MySQL探险-4、事务及锁机制_第30张图片
        ●段(Segment)分为索引段、数据段、回滚段等。其中索引段就是非叶子结点部分,而数据段就是叶子结点部分,回滚段用于数据的回滚和多版本控制。一个段包含256个区(256M大小)。
        ●区是页的集合,一个区包含 64 个连续的页,默认大小为 1MB(64*16K)。
        ●页是 InnoDB 管理的最小单位,常见的有 FSP_HDR、INODE、INDEX 等类型。所有页的结构都是一样的,分为文件头(前38字节)、页数据和文件尾(后8字节)。页数据根据页的类型不同而不一样:
          ⑴FILE_SPACE_HEADER 页:用于存储区的元信息。ibd 文件的第一页 FSP_HDR 页通常就用于存储区的元信息,里面的 256 个 XDES(extent deors)项存储了 256 个区的元信息,包括区的使用情况和区里面页的使用情况。
          ⑵IBUF_BITMAP 页:用于记录 change buffer 的使用情况。
          ⑶INODE 页:用于记录文件段(FSEG)的信息,每页有 85 个 INODE entry,每个 INODE entry 占用 192 字节,用于描述一个文件段。每个 INODE entry 包括文件段 ID、属于该段的区的信息以及碎片页数组。区信息包括 FREE(完全空闲的区)、NOT_FULL(至少使用了一个页的区)、FULL(没空闲页的区)三种类型的区的 List Base Node(包含链表长度和头尾页号和偏移的结构体)。碎片页数组则是不同于分配整个区的单独分配的32个页。
          ⑷INDEX 页:索引页的叶子结点的 data 就是数据,如聚集索引存储的行数据、辅助索引存储的主键值。

      InnoDB File-Per-Table 表空间

        采用 File-Per-Table 的优缺点如下:
          ●优点:可以方便回收删除表所占的磁盘空间。如果使用系统表空间的话,删除表后空闲空间只能被 InnoDB 数据使用。TRUNCATE TABLE 操作会更快。可以单独拷贝表空间数据到其他数据库(使用 transportable tablespace 特性),可以更方便的观测每个表空间数据的大小。
          ●缺点:fsync 操作需要作用的多个表空间文件,比只对系统表空间这一个文件进行 fsync 操作会多一些 I/O 操作。此外,mysqld 需要维护更多的文件描述符。

        表空间文件结构

          InnoDB 表空间文件 .ibd 初始大小为 96K,而 InnoDB 默认页大小为 16K,页大小也可以通过 innodb_page_size 配置为 4K、8K…64K 等。在 ibd 文件中,0-16KB 偏移量即为0号数据页,16KB-32KB 的为1号数据页,以此类推。页的头尾除了一些元信息外,还有 Checksum 校验值。这些校验值在写入磁盘前计算得到,当从磁盘中读取时,重新计算校验值并与数据页中存储的对比,如果发现不同,则会导致 MySQL 崩溃。

          ibd 文件存储结构如下所示:
MySQL探险-4、事务及锁机制_第31张图片
          InnoDB 页分为 INDEX页、Undo 页、系统页、IBUF_BITMAP 页、INODE页等多种:
            ●第0页是 FSP_HDR 页,主要用于跟踪表空间、空闲链表、碎片页以及区等信息。
            ●第1页是 IBUF_BITMAP 页,保存 Change Buffer 的位图。
            ●第2页是 INODE 页,用于存储区和单独分配的碎片页信息,包括 FULL、FREE、NOT_FULL 等页列表的基础结点信息(基础结点信息记录了列表的起始和结束页号和偏移等),这些结点指向的是 FSP_HDR 页中的项,用于记录页的使用情况。
            ●第3页开始是索引页 INDEX(B-tree node),从 0xc000(每页16K)开始,后面还有些分配的未使用的页。

          可以使用如下命令查看页信息:

SELECT * FROM INFORMATION_SCHEMA.INNODB_SYS_TABLES WHERE NAME='数据库名/表名';
SELECT * FROM information_schema.INNODB_BUFFER_PAGE WHERE `SPACE` = 前一条语句查询得到的SPACE值
        索引页分析

          可以用 Linux 中的 hexdump 命令查看 t.ibd 文件,然后对照 InnoDB 页的结构分析下各个页的字段。
          InnoDB 引擎索引页的结构如下图:
MySQL探险-4、事务及锁机制_第32张图片
          各字段内容如下:
            ●FIL Header(38字节):记录文件头信息。前4字节是 checksum,接着4字节是页偏移值,即这是第几页。接着4字节是上一页偏移值,接着4字节是下一页偏移值。然后8字节是日志序列号 LSN。随后的2字节是页类型。接着8字节表示被更新到的 LSN,在 File-Per-Table 表空间中都是0。然后4字节表示该数据页属于的表 t 的表空间 ID。
            ●INDEX Header(36字节):记录的是 INDEX 页的状态信息。前2字节表示页目录的 slot 数目值;接着2字节是页中第一个记录的指针。接着2字节是这页的格式和记录数。接着2字节是可重用空间首指针,再后面2字节是已删除记录数。接着2字节是最后插入记录的位置偏移,即最后插入位置,即第2条记录开始地址。接着2字节是最后插入的方向(2 表示 PAGE_DIRECTION_RIGHT,即自增长方式插入)。接着2字节指一个方向连续插入的数量。接着2字节是 INDEX 页中的真实记录数。然后8字节为修改该页的最大事务 ID,这个值只在辅助索引中存在。接着2字节为页在索引树的层级,0表示叶子结点。最后8个字节为索引 ID(索引 ID 可以在 information_schema.INNODB_SYS_INDEXES 中查询)。
            ●FSEG Header:这是 INDEX 页中的根结点才有的,非根结点的为0。前10字节是叶子结点所在段的 segment header,分别记录了叶子结点的表空间 ID、INODE 页的页号和 INODE 项偏移。而后10字节是非叶子结点所在段的 segment header。FSEG Header 中存储了该 INDEX 页的 INODE 项,INODE 项里面则记录了该页存储所在的文件段以及文件段页的使用情况。对于 File-Per-Table 情况下,每个单独的表空间文件的 FSP_HDR 页负责管理页使用情况。
            ●System Records(26字节):每个 INDEX 页都有两条虚拟记录 infimum 和 supremum,用于限定记录的边界,各占 13 个字节。其中记录头的5个字节分别标识了拥有记录的数目和类型(拥有记录数目是即后面页目录部分的 owned 值,当前页目录只有两个槽,infimum 拥有记录数只有它自己为1,而 supremum 拥有插入的记录和它自己)、下一条记录的偏移就是实际记录开始位置。后面8个字节为 infimum + 空值,supremum 类似,只是它下一条记录偏移为0。
            ●User Records:接下来是插入的记录。记录前面7字节是记录头(Record Header),其中前面的1字节记录的是可变变量的长度。然后1字节记录的是可为 NULL 的变量是否是 NULL(不为 NULL时为0)。接着的5字节记录了插入顺序2(infimum 插入顺序固定是0,supremum 插入顺序是1,其他记录则是从2开始),下一个记录的偏移(即下一个记录开始位置)、删除标记等。后面就是记录内容。这里的事务 ID 可以通过 select * from information_schema.innodb_trx 进行验证。
            ●Page Directory(4字节):假设页目录的 slot 有2个,每个 slot 占2字节,则页目录为4字节,存储的是相对于最初行的位置。使用页目录进行二分查找,可以加速查询,详细见后面分析。
            ●FIL Tail(8字节):最后8字节,其中前4字节为 checknum,跟 FIL Header 的 checksum 一样。后4字节与 FIL Header 的 LSN 的后4个字节一致。
MySQL探险-4、事务及锁机制_第33张图片
MySQL探险-4、事务及锁机制_第34张图片

        索引结构

          InnoDB 数据文件本身就是索引文件,其索引分聚集索引辅助索引。聚集索引的叶节点包含了完整的数据记录,辅助索引叶节点数据部分是主键的值。除了空间索引外,InnoDB 的索引实现基本都是 B+ 树,如图所示:
MySQL探险-4、事务及锁机制_第35张图片
          其中非叶子结点存储的是子页的最小的键值和子页的页号,叶子结点存储的是数据,数据按照索引键排序。同一层的页之间用双向链表连接(前面提到的 FIL Header 中 PREV PAGE 和 NEXT PAGE),同一页内的记录用单向链表连接(Record Header 中记录了下一条记录的偏移)。每一页设置了两个虚拟记录 Infimum 和 Supremum 用于标识页的开始和结束。

          在 InnoDB 中根据辅助索引查询,如果除了主键外还有其他字段,则需要查询两遍,先根据辅助索引查询主键的值,然后再到主索引中查询得到记录。此外,因为辅助索引的数据部分是主键值,主键不能过大,否则会导致辅助索引占用空间变大,因此用自增 ID 做主键是个不错的选择。

        页目录

          前面提到 INDEX 页内的记录是通过单向链表连接在一起的,遍历列表性能会比较差,而 INDEX 页的页目录就是为了加速记录搜索。
MySQL探险-4、事务及锁机制_第36张图片

      InnoDB 系统表空间

        系统表空间包含内容有:数据字典、双写缓冲、修改缓冲、undo 日志以及在系统表空间创建的表的数据和索引。可以看到,除了分配未使用的页外, UNDOLOG、SYS、INDEX 页占据了不少的空间。UNDOLOG 页存储的是 Undo log,SYS 页存储的是数据字典、回滚段、修改缓存等信息,INDEX 是索引页,TRX_SYS 页用于 InnoDB 的事务系统。数据字典就是数据表的元信息,修改缓冲前面提到是为了提高 I/O 性能,这里主要分析下 Undo 日志和双写缓冲。

        Undo 日志

          MySQL 的 MVCC(多版本并发控制)依赖 Undo Log 实现。MySQL 的表空间文件 t.ibd 存储的是记录最新值,每个记录都有一个回滚指针(见表空间文件结构图中的 Roll Ptr),指向该记录的最近一条 Undo 记录,而每条 Undo 记录都会指向它的前一条 Undo 记录,如下图所示。默认情况下 undo log存储在系统表空间 ibdata1 中。
MySQL探险-4、事务及锁机制_第37张图片
          需要注意的是,Undo Log 在事务执行过程中就会产生,事务提交后才会持久化持久化,如果事务回滚了则 Undo Log 也会删除

          这里的删除记录并不会立即在表空间中删除该记录,而只是做个标记(delete-mark),真正的删除则是等由后台运行的 purge 进程处理。除了每条记录有 Undo Log 的列表外,整个数据库也会有一个历史列表,purge 进程会根据该历史列表真正删除已经没有再被其他事务使用的 delete-mark 的记录。purge 进程会删除该记录以及该记录的 Undo Log。

        双写缓冲

          先回顾下 InnoDB 的记录更新流程:先在 Buffer Pool 中更新,并将更新记录到 Redo Log 文件中,Buffer Pool 中的记录会标记为脏数据并定期刷到磁盘。由于 InnoDB 默认 Page 大小是 16 KB,而磁盘通常以扇区为单位写入,每次默认只能写入 512 个字节,无法保证 16 K 数据可以原子的写入。如果写入过程发生故障(比如机器掉电或者操作系统崩溃),会出现页的部分写入(partial page writes),导致难以恢复。因为 MySQL 的重做日志采用的是物理逻辑日志,即页间是物理信息,而页内是逻辑信息,在发生页部分写入时,无法确认数据页的具体修改而导致难以恢复。

          在对缓冲池的脏页进行刷新时,并不直接写磁盘,而是通过 memcpy 函数将脏页先复制到内存中的该区域,之后通过 Double-Write Buffer(默认大小为 2 MB,128 个页)再分两次,每次1MB顺序地写入共享表空间的物理磁盘上,然后马上调用 fsync 函数,同步磁盘,避免操作系统缓冲写带来的问题。等双写缓冲写入成功后才会将数据页写到实际表空间的位置。因为双写缓冲和数据页的写入时机不一致,如果在写入双写缓冲出错,可以直接丢弃该缓冲页,而如果是写入数据页时出错,则可以根据双写缓冲区数据恢复表空间文件。

          如果说 Change Buffer 给 InnoDB 存储引擎带来了性能上的提升,那么 Double Write 带给 InnoDB 存储引擎的是数据页的可靠性。

  ②InnoDB 事务隔离级别

    InnoDB 的多版本并发控制是基于事务隔离级别实现的,而事务隔离级别则是依托前面提到的 Undo Log 实现的。当读取一个数据记录时,每个事务会使用一个读视图(Read View),读视图用于控制事务能读取到的记录的版本。详细内容上文已经讨论过,不再赘述。

  ③InnoDB 和 ACID 模型

    事务有 ACID 四个属性, InnoDB 实现 ACID 的内容上文已经有所涉及,下面总结如下:

    Atomicity,InnoDB 的原子性主要是通过提供的事务机制实现,与原子性相关的特性有:
      ●Autocommit 设置。
      ●COMMIT 和 ROLLBACK 语句(通过 Undo Log实现)。

    Consistency,InnoDB 的一致性主要是指保护数据不受系统崩溃影响,相关特性包括:
      ●InnoDB 的双写缓冲区(doublewrite buffer)。
      ●InnoDB 的故障恢复机制(crash recovery)。

    Isolation,InnoDB 的隔离性也是主要通过事务机制实现,特别是为事务提供的多种隔离级别,相关特性包括:
      ●Autocommit 设置。
      ●SET ISOLATION LEVEL 语句。
      ●InnoDB 锁机制。

    Durability,InnoDB 的持久性相关特性如下:
      ●Redo log。
      ●双写缓冲功能。(可以通过配置项 innodb_doublewrite 开启或者关闭)
      ●配置 innodb_flush_log_at_trx_commit。用于配置 InnoDB 如何写入和刷新 redo 日志缓存到磁盘。默认为1,表示每次事务提交都会将日志缓存写入并刷到磁盘。innodb_flush_log_at_timeout 可以配置刷新日志缓存到磁盘的频率,默认是1秒。
      ●配置 sync_binlog。用于设置同步 binlog 到磁盘的频率,为0表示禁止 MySQL 同步 binlog 到磁盘,binlog 刷到磁盘的频率由操作系统决定,性能最好但是最不安全。为1表示每次事务提交前同步到磁盘,性能最差但是最安全。MySQL 文档推荐是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置为 1。
      ●操作系统的 fsync 系统调用。
      ●UPS 设备和备份策略等。

  ④InnoDB 的 MVVC 实现

    多版本并发控制仅仅是一种技术概念,并没有统一的实现标准, 其的核心理念就是数据快照。不同的事务访问不同版本的数据快照,从而实现不同的事务之间相互隔离。虽然字面上是说具有多个版本的数据快照,但这并不意味着数据库必须拷贝数据,保存多份数据文件,这样会浪费大量的存储空间。InnoDB 通过事务的 undo 日志巧妙地实现了多版本的数据快照。

    数据库的事务有时需要进行回滚操作,这时就需要对之前的操作进行 undo。因此,在对数据进行修改时,InnoDB 会产生 undo log。当事务需要进行回滚时,InnoDB 可以利用这些 undo log 将数据回滚到修改之前的样子。

    根据行为的不同 undo log 分为两种 insert undo log 和 update undo log。
      ●insert undo log 是在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对于其它事务此记录是不可见的,所以 insert undo log 可以在事务提交后直接删除而不需要进行 purge 操作。
      ●update undo log 是 update 或 delete 操作中产生的 undo log,因为会对已经存在的记录产生影响,为了提供 MVCC 机制,因此 update undo log 不能在事务提交时就进行删除,而是将事务提交时放到入 history list 上,等待 purge 线程进行最后的删除操作。

    为了保证事务并发操作时,在写各自的 undo log 时不产生冲突,InnoDB 采用回滚段的方式来维护 undo log 的并发写入和持久化。回滚段实际上是一种 Undo 文件组织方式。

    Read View 是 InnoDB 中用于判断记录可见性的数据结构,记录了一些用于判断可见性的属性。REPEATABLE READ 隔离级别下事务开始后使用 MVVC 机制进行读取时,会将当时活动的事务 id 记录下来,记录到 Read View 中。READ COMMITTED 隔离级别下则是每次读取时都创建一个新的 Read View。

    具体实现细节在上文中分两部分说明:概括介绍和细节补充

你可能感兴趣的:(数据库,mysql)