chikaku

且听风吟

永远是深夜有多好。
github
email

InnoDB 存储引擎概览

《MySQL 技术内幕 InnoDB 存储引擎 (第二版)》 读书笔记。

缓冲池#

对于数据库来说,查询和读取的效率是非常关键的,而一般数据库都是将数据存储在磁盘中。但是相对而言磁盘的读写速度太慢了无法满足数据库高速读写的需求,所以一般存储引擎会利用缓冲池技术,将磁盘中的一部分数据读到内存中缓存下来,称之为缓冲池。

  • 对于读请求,先看缓冲池里面有没有对应的数据,有就直接从内存中读返回,没有就在从磁盘里面读出来,放到内存里再返回。
  • 对于写请求,先查看缓冲池里有没有对应的数据,有就直接修改内存数据返回。如果没有,就还是从磁盘里读出来对应的那一块数据出来放到内存,然后修改内存数据返回。存储引擎会在之后的的某些时间点把脏数据重新刷回到磁盘。

缓冲池最重要的参数是缓冲池的大小,这个数值将直接影响数据库的性能。想象以下如果缓冲池不够大,缓存的命中率很低,需要经常读磁盘,那效率可太低了。 InnoDB 也使用了缓冲池技术,其大小配置在 innodb_buffer_pool_size 中。在 InnoDB 缓冲池中缓存的不仅仅是数据,还有索引,undo buffer, change buffer, hash index 等等。这些内存数据在内存中以页 (page) 的形式组织,页的大小配置在 innodb_page_size 中默认是 16K

InnoDB 支持多个缓冲池实例,每个页会根据哈希分配到不同的实例,配置在 innodb_buffer_pool_instances 默认是 1 多个缓冲池可以减少内部的资源如锁的竞争,提升并发性能。

LRU List#

缓冲池的任务就是从内存中读取请求需要的数据页,如果能从内存中读到,可以称之为命中,响应速度就会很快。如果读不到,没命中,就需要读磁盘,速度就很慢。观察一个缓冲池的性能最重要的参数就是缓冲命中率。

为了提高缓冲的命中率,一般会采用 LRU(latest recent used) 算法。这个算法维护了一个缓冲列表,把最经常访问的页放在列表的最前面,最少使用的放在最后面。如果缓存没有命中,从磁盘中读取新的页,就从列表中丢弃最后的一个页,再把新读取的页放到列表里面去。至于新读取的页放到列表的那个位置,要看具体的实现。

InnoDB 就采用了这种算法,但是做了一些优化。在 InnoDB 的 LRU 列表中,新读取的页会放到列表中 midpoint 的位置 midpoint 是一个百分比,在这个百分比之后的列表称为 old 列表,在此之前的称为 new 列表。以 modpoint 为分界线 InnoDB 的 LRU 其实是由这两个单独的列表共同组成的。modpoint 值配置在 innodb_old_blocks_pct 默认是 37 也就是说新读取的页会放到距列表尾端 37% 的位置。

为什么没有放到最前端呢?设想一下,如果放到了最前端,也就是把这个页当作最常用的页。但是对于某些 SQL 操作比如索引或者是数据的扫描,经常会访问大量的数据,但是这些数据很可能仅仅是在这次扫描中使用了一次。如果把这样的数据每次都放到最前端很可能就把真正的热点数据给顶出 LRU 列表,造成命中率下降。不过新读取的数据页也有可能真的是热点数据,该如何判断呢?

首先需要知道的是,在 new 列表中命中的页会被提升到 new 列表的最前端。在 old 列表中命中的页也会提升到 old 列表的最前端。

那么假设新读取的这个数据页是热点数据,一段时间内,这个页会肯定被经常访问,每次访问都会把它提升到 old 列表的头部这个页是不会被刷出去的。但如果不是热点数据而是一个临时数据,那应该很快就会被顶到下面去,甚至刷出 old 列表。这样,通过让数据页自己来保证自己能够存活的足够久,基本上可以证明这是一个热点数据页。所以 InnoDB 还增加了另外一个参数配置在 innodb_old_blocks_time 表示新读取的页在 old 列表中持续存在多长时间后会被放到 new 列表里面去。把页从 old 中移到 new 也叫做 page made young 而在 old 列表中没有存活足足够长的时间后被刷出叫做 page not made young

unzip LRU#

在缓冲池中有些数据页的大小可能不是 16KB 的这种页如果页占用 LRU 的 16KB 页会有些浪费,所以 innodb 添加了一个 unzip_LRU 列表用来管理这种非 16KB 的压缩页。

现在假设我们需要一个 2KB 的页,unzip_LRU 会首先寻找 2KB 的页:

  • 没找到,继续向上找 4KB 的页,如果找到了分成两个 2KB 的页,完成分配
  • 没找到,继续向上找 8KB 的页,如果找到了分成一个 4KB 两个 2KB 的页完成分配
  • 没找到,继续向上找 16KB 的页,分成一个 8KB 一个 4KB 两个 2KB 的页完成分配

Free List#

上面讨论 LRU 列表的时候都是在假设 LRU 已经满了的情况下去讨论的,但是实际上在数据库刚启动的时候 LRU 是空的。这个时候缓冲池中的所有页都放在 Free 列表上。每次有从磁盘中读取新的页出来后,首先要从 Free 列表中找,有没有空闲的页,如果有的话,把空闲页从中取出,放到 LRU 列表里面去。如果没有的话,才是按照上面 LRU 中讨论的情况来丢弃某个页。

Flush List#

以上两种列表讨论的都是读的情况,正常的数据库请求中也会有很多写操作,对于写操作,首先还是按照 LRU 的规则读内存中的数据页,然后修改这个数据页,这个被修改了的数据页被称为脏页 (dirty page) 但是脏页并不会从 LRU 中移除,而是同时添加到另外一个 Flush 列表中。这样在内存中就完成了数据的修改,后序这个脏页的读还是由 LRU 来负责而且能够读到最新的数据保证可用性。另一边存储引擎会通过一定的机制把 Flush 列表中的数据都刷回到磁盘,这个刷新并不是即时的,否则大量的磁盘写将会非常影响数据库的性能。当然如果数据库宕机,可能会导致脏页还没来得及写回去,这种情况下磁盘数据的持久性由事务的实现来保证。

Checkpoint 机制#

在讨论 Checkpoint 技术之前,先讨论上面讲的 Flush 列表没来得及刷新脏页的问题。在 InnoDB 的事务实现中,每次事务提交之前,都会先写一个 redo log 也叫做重做日志,先写到内存的 redo log buffer 中然后按一定频率刷到磁盘上,记录录了这条事务对数据的完整修改。然后再去修改内存中的页,这样当数据库宕机后,重启时可以通过 redo log 来恢复数据。通过这样的方式,理想情况下甚至数据库都不需要把脏页刷新回磁盘,每次重启载入 redo log 就行了。当然前提是内存和磁盘足够大,这种情况是不现实的。所以脏页还是要刷回磁盘的。当数据库宕机重启时,只需要恢复上次已经刷回到磁盘往后的 redo log 即可。

除了对数据的修改,在 LRU old 列表中如果被丢弃的页是脏页,那这个时候也需要刷回磁盘的,不然下次从磁盘里取的就是旧数据了。在内存中 redo log buffer 的大小配置在 innodb_log_buffer_size 如果超过了这个上限,就没办法保证事务了,所以也是即时即时刷脏页到磁盘保证有空的 redo log buffer

InnoDB 采用了 Checkpoint 机制来将脏页刷新回磁盘。Checkpoint 在内部又分为两种:

一种是 Sharp Checkpoint 在数据库正常关闭的时候把所有的脏页刷回磁盘。这里的正常关闭是指配置 innodb_fast_shutdown 被设置为默认值 1 就是把缓冲池里的脏页刷新回去,其他就不管了。如果这个值是 0 那么数据库在关机前会进行完整的页回收和 change buffer 的合并等等称为 slow shutdown 而如果配置为 2 则只是把日志文件给写到磁盘日志文件里,就停掉。表数据的完整性需要等待下次启动时候的恢复。这个参数的具体行为可以查看 官网文档 与之相关的还有一个配置 innodb_force_recovery 控制 InnoDB 的恢复。

另一种就是在运行时不断的执行的 Fuzzy Checkpoint 在一定情况下刷新一部分的脏页到磁盘。这几类情况如下:

  • 主线程每隔一段时间会异步的把缓冲池里面的脏页刷到磁盘
  • LRU 列表里面淘汰了一个数据页,如果是脏页,要刷回到磁盘
  • redo log 日志可用空间不多的时候,如果达到 redo log 文件最大容量 75% 以上的时候就要需要执行异步刷脏页到磁盘了不然后序 redo log 不够用会影响到事务提交。达到 90% 以上的时候就需要同步刷新到磁盘。
  • 如果脏页的数量太多也是要刷回磁盘的,配置 innodb_max_dirty_pages_pct_lwm 首先表示了一个较低的脏页占缓冲池数量的百分比比值,达到这个百分比后开始进行预刷新 (TODO: 什么是预刷新?) 而达到配置 innodb_max_dirty_pages_pct 的百分比后将会进行正常的刷新。

查看缓冲池状态#

通过查看 innodb 存储引擎的状态的命令可以查看 innodb 的运行状态,此处列举了缓冲池和内存相关的几个关键参数,基本上从名字都能看出来。这里 Free 加上 LRU 并不等于总的缓冲池大小,因为缓冲池中的 page 还会分配给其他用途比如 hash 或 change buffer 等等。

> show engine innodb status;

----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137363456  // 分配给 innodb 的总内存大小
Buffer pool size   8192                 // 内存池的页的个数,大小要乘上 page_size
Free buffers       7146                 // Flush List 页数量
Database pages     1042                 // LRU List 页数量
Old database pages 402                  // LRU 中 old 列表页数量
Buffer pool hit rate 1000 / 1000        // 缓存命中率

InnoDB 引擎特性#

MySQL 和 InnoDB 简介#

MySQL 是比较常用的一个关系型数据库,他和其他数据库的一个区别在于提供了插件式的存储引擎。按照官方的说法,这个插件式在于你可以自己编写一个存储引擎,然后把他加到一个运行中的 MySQL 实例上,甚至不需要重新编译和重启。

InnoDB 是 MySQL5.5.8 以后的默认存储引擎。设计目标主要是做 OLTP 特点是行锁设计,支持外键,默认读取操作不会产生锁,可以将每个表的数据存放到一个单独的 .idb 文件中。
InnoDB 通过 MVCC(multiversion concurrency control) 获取高并发性。其实现了四种隔离级别,默认为 REPEATABLE

InnoDB 表数据的存储是按照主键的顺序进行存放,如果没有显式的在定义的时候指定主键,InnoDB 会自动生成一个六字节的 ROWID 做主键。如果数据量超出这个 ROWID 的范围 (281 万亿行),最早的数据会被覆盖。

Change buffer#

通常在据库的每个表中都会有一个聚集索引 (也就是我们常说的主键),也可能会多个的辅助索引。假设我们需要向表中插入一条新的记录,那这个时候索引树是需要更新的。对于只有一个聚集索引的情况来说,很好办因为聚集索引一般都是顺序递增的,新加一行的时候只需要顺序写,把新的索引添加到索引树的最后就行了。但是如果表中有辅助索引那就需要在辅助索引树中找到新加的索引应该在的位置。如果这个索引页不在缓冲池里面的话,就需要进行磁盘的随机读写。对于大量的写请求,这样多次的随机磁盘 IO 是非常影响性能的。

所以 InnoDB 引进了一种 change buffer 的方案,在对辅助索引进行插入,更新,删除操作的时候,如果这个索引页在缓冲池里,就直接改缓冲池。如果不在缓冲池里,就先放到 change buffer 里面去,等其他操作,比如有一个读当前索引页的请求,那这时候就一定要从磁盘里读出来到缓冲池了。然后 change buffer 再把自己的修改给合并进去,这样就避免了每次 insert/update/delete 需要进行的随机磁盘 IO

除此之外如果一段时间内,索引页没有被读到缓冲池,但是对这个索引页又进行了多次访问或者修改,那么多次操作也会在 change buffer 的对应索引页上进行合并,后续只需要回写一次磁盘就可以了。

另外 change buffer 也是有一定限制的,首先,这个索引一定是个辅助索引,而且这个索引不能是唯一的,因为如果这个索引需要唯一,那 insert/update 的时候肯定就不可避免的要把所有索引页扫描一遍确认是否有相同的索引,再做 change buffer 就没意义了。

change buffer 分为三类:

  • insert buffer 表示一个插入操作缓冲
  • delete buffer 表示将记录标记为删除
  • purge buffer 表示将记录真正的删除

在 InnoDB 内部,update 是由 delete 和 purge 两步操作共同完成的。配置 innodb_change_buffering 可以指定 change buffer 缓冲的类型,默认是 all 也就是缓冲全部类型。

在 InnoDB 内部,change buffer 的结构是一颗 B+ 树。这个树当然也有大小限制,配置在 innodb_change_buffer_max_size 表示 change buffer 占总缓冲池大小的最大百分比默认是 25 如果 change buffer 的量达到了一定大小,内部也会强制读取索引页进行合并,避免 change buffer 空间不足。

Double write#

在数据库运行的过程中,时时刻刻都在将缓冲池中的数据同步到磁盘中,保证数据的持久性。这个时候就可能会产生一些,比如我们一个 16KB 的数据页在写磁盘的过程中,刚写完 4KB 数据库宕机了,这个时候即使有 redo log 但是磁盘上的页已经被写了 4KB 脏了 redo log 存储的物理日志并不能恢复完整的这一个页。(详细的原因看下面 redo log 这一节) 所以在用 redo log 之前需要先备份这个页,如果写入失效,可以通过备份页把磁盘页还原回去,之后再用 redo log 尝试恢复。

InnoDB 使用 doublewrite 技术来解决这个问题。在内存中有一块区域叫做 doublewrite buffer 大小为 2MB 在磁盘的共享表空间上也有一块大小 2MB 的空间。在缓冲池脏页刷新的过程中,首先会把脏页给写到磁盘的共享表空间里执行 fsync 然后再去写各个表数据文件。这样即使再写数据文件的过程中出错,也能从共享表空间中找回脏页的副本。(TODO: 刷新到共享表空间的时候为什么会分成两次,每次 1MB 的写?)

通过配置 innodb_doublewrite 可以禁用 doublewrite 功能,在某些提供写失效保护的文件系统上是不需要这种功能的。

异步 IO#

InnoDB 采用异步 IO 的方式把数据刷新到磁盘,这样就不会产生线程阻塞,而且 AIO 也可以进行 IO Merge 减少磁盘 IO 次数。关于 Linux 下的 AIO 可以查看 man 文档

可以通过配置 innodb_use_native_aio 开关 AIO

刷新临近页#

InnoDB 在刷新一个脏页回磁盘的时候可以检测这个页所在区的所有页,如果有其他脏页,也一并刷新到磁盘,这样可以利用 IO Merge 减少 IO 次数。通过配置 innodb_flush_neighbors 来开关。

InnoDB 工作线程#

上面我们看了 InnoDB 在运行时的一些状态,下面就介绍一些 InnoDB 是如何工作的。

Master thread#

Master thread 是 InnoDB 运行的主线程,负责大部分的工作。主要有:

  • 刷新脏页到磁盘
  • 把日志文件刷新到磁盘,即使事务还没有提交,提高事务的提交速度
  • 合并 change buffer
  • undo 页回收

IO thread#

IO 线程主要负责 AIO 的请求回调,通过配置 innodb_read_io_threadsinnodb_write_io_threads 可以更改读写线程数量

Purge thread#

在事务的执行过程中,会记录一个 undo log 用来在事务失败的时候回滚数据。purge 线程的工作就是回收这些在事务执行完以后不再需要的 undo 页。可以通过配置 innodb_purge_threads 来修改此线程的数量。

日志文件#

这里讲的日志其实是 MySQL 的日志跟 InnoDB 无关。因为经常需要查看和处理所以就连带着放上来了。

error log#

错误日志记录的是 MySQL 启动,运行,关闭过程中的错误和警告。通过 log_error 变量查看。

slow query log#

慢查询日志记录运行时间超过配置的 long_query_time 的 SQL 语句,默认是 10 秒。通过 slow_query_log 打开,默认是关闭的。日志记录位置在配置 slow_query_log_file 中。

general query log#

普通查询日志记录了所有的数据库请求,记录在 general_log_file 文件中。通过配置 general_log 来开关。一般是关着的,一直开着对性能有一定的影响。一般只有在调试的时候打开。

binary log#

二进制日志记录了所有对数据库执行的更改 (不包括 select 和 show 这种) 操作。可以通过 bin log 来进行备份恢复,和主从复制等。此日志记录在 datadir 下的 bin_log.xxxx 文件中。其中 bin_log.index 是二进制索引文件,存储之前的 bin log 序号。

InnoDB 事务执行过程中会把二进制日志记录到缓冲区中,等事务提交后再将缓冲区中的 bin log 写到磁盘的 bin log 中。通过配置 sync_binlog 调整写多少次缓冲后同步到磁盘文件。默认是 1 也就是每次写完缓冲就刷新到磁盘里。如果这个参数设置的比较大,可能会有宕机导致 bin log 没来得及刷到磁盘导致主从数据不同步的问题。

存储结构#

InnoDB 中所有的数据存储在一个空间中,称为共享表空间。表空间从大到小的组织单位是:段 (segment),区 (extend),页 (page) 其中每一级都是由多个下级单位组织而成的。

表结构文件#

在 MySQL 中任何的存储引擎下都会有一个 .frm 文本文件存储表的结构和用户创建的视图的结构。

表空间文件#

InnoDB 默认所有表有一个共享的表空间。通过配置 innodb_file_per_table 可以将每张表的数据放到一个单独的表空间。但是这样每张表的表空间只存储数据,索引和 change buffer 的 bitmap 页。其他的数据都还是放在原来的共享表空间里。表空间文件的后缀是 .ibd 默认情况下的共享表空间文件是 ibddata1

表空间文件是由各种段组成的,比如数据段,索引段,undo 段等。段是由多个区组成的,区又是由多个页组成的,且区中的页都是连续的。页是 InnoDB 内的最小单元。页分为很多种,比如数据页,undo 页,事务数据页等等。

行记录格式#

InnoDB 内数据以行的形式存放在数据页中,保存在 B+ 树的节点上,如果有某一列的数据类型长度可变比如 text ,则有可能数据会存放到溢出页中,源数据行保留一个溢出行的偏移量。对于定长的数据,如果数据长度过大,导致一个页上只能存放一行数据不满足 B+ 树的结构,InnoDB 内部也会自动的把行数据存放到溢出页中。

数据分区#

数据分区是 MySQL 支持的功能,可以把一个表或者是索引数据,物理上分解成多个部分。当前 MySQL 只支持水平分区,即将一个表中的不同行分配到不同的物理文件。而且是局部分区索引,即每个物理文件中即存放数据,也存放索引。

MySQL 的分区支持以下几种类型:

  • range 分区,根据指定连续的行数据区间,每个区间保存在一个分区中 (1, 2, 3, 4) (5, 6, 7, 8)
  • list 分区,和 range 类似,不过分区的数据可以是离散的比如 (1, 3, 5, 7) (2, 4, 6, 8)
  • hash 分区,根据用户自定义的表达式的返回值进行 hash 分到不同的区中比如 hash (id % 1000)
  • key 分区,根据数据库提供的 hash 函数来进行分区,只需要指定被 hash 的字段即可

在分区的基础上 MySQL 还可以再进一步进行分区,称为子分区。MySQL 允许在 range 和 list 分区上再进行 hash 或者 key 分区。具体的分区语法可以参考 官方文档

索引#

索引是提升数据库查询性能最关键的技术。InnoDB 内部支持多种索引:B+ 树索引,全文索引,哈希索引等。一般开发人员都是用 B+ 树索引比较多,所以这里就只讨论 B+ 树索引。B+ 树索引也分为很多种。

聚集索引#

每个 InnoDB 的数据表中都会有一个主键,聚集索引就是按照表的主键构造的一颗 B+ 树,这颗树的叶子节点就是表的数据页,保存完整的行数据。每个数据页通过一个双向链表进行连接,跟表数据中按主键存放数据的顺序是一样的。所有表数据的查询,除了缓冲命中和一些特殊情况之外,都会通过颗聚集索引树上查找对应的数据页。

聚集索引当然也是有磁盘上的物理数据的,但是聚集索引在磁盘上并不是物理连续的,而是在逻辑上连续比如一个一个索引节点的最后一项指向下一个索引节点在文件中的偏移量。否则维护聚集索引的成本会很高。

辅助索引#

每个辅助索引也是一个单独的 B+ 树但是这棵树的叶子节点并不保存完整的行数据,只保存了辅助索引的键和表主键。通过辅助索引进行查询行,会首先得到表主键,然后再通过主键去聚集索引树上查找完整的行数据。当然有一种特殊情况是查询的数据在辅助索引的键里面,那这个时候就不必再去聚集索引树上去找了,这种也叫做 覆盖索引

联合索引#

联合索引是说对表上的多个列进行索引。用法跟单个键的辅助索引是一样的。在索引的排列顺序上是按照索引声明的顺序进行排列的,比如对于 (a, b) 联合索引。数据的排列是按照先给 a 排序再给 b 排序来的,比如 (1, 1) (1, 2) (2, 1) (2, 2) 更多键的情况以此类推。

对于多个键的值查询比如 where a = xx and b = xx 就可以使用联合索引。而且在联合索引中是会按第一个键进行排序的,所以对于 where a = xx 这样单个键值的查询也可以用联合索引。

还有一种用法是,假设我们有 where a = x group by b limit n 这种查询,对于单个辅助索引,查询完还需要对 b 进行一次排序。但是联合索引中,如果确定了第一个字段 a 是定值,第二个字段 b 本来就是排序好的,可以减少排序的操作。

自适应哈希索引#

InnoDB 采用 B+ 树建立索引,索引的查找次数与 B+ 的深度有关,比如深度是 3 的树,可能一个索引的查找要经过 3 次查询,如果我们经常访问一个数据,每次都要经历 3 次索引查询,是有些浪费的。在 InnoDB 内部会为每张表建立一个自适应哈希索引 (adaptive hash index) 用来缓存某些热点数据加快数据查询。

比如我们连续请求了很多次 select a from t where id = 1; 那 AHI 就会给 where id = 1 这个模式建立一个索引。后续再进行相同的请求的时候,就不需要去查询 B+ 树索引,直接从自适应哈希索引取得数据页。另外哈希索引是只能做等值查询,不能缓存范围查询。

建立哈希索引还有一定的条件限制,但是哈希索引本来就是 InnoDB 自己内部的优化,就不再仔细说了。用户可以通过配置 innodb_adaptive_hash_index 来开关 AHI

锁和事务#

数据库的访问一定是并发的,这个时候为了保证数据的一致性,就需要有锁。

InnoDB 内有两类锁:

  • latch 轻量级的锁,锁定的时间很短,一般是用来保护线程数据。latch 也分成两种一种是 mutex 一种是 rwlock
  • lock 这类锁作用的对象是事务,锁定数据库中的,表,行。而且锁定的时间会比较久,直到事务 commit 或者 rollback 的时候才会释放

这里主要关注的还是跟事务有关的 lock 在事务中 InnoDB 会在行级别上对表数据进行加锁。

行锁和意向锁#

InnoDB 支持两种行锁:

  • 共享锁 Shared Lock 允许事务读一行数据。共享锁和任何其他锁兼容
  • 排他锁 eXclusive Lock 允许事务删除或者更新一行数据。排他锁和任何其他锁都不兼容。

假设有一个事务 T1 想要获取某一行的数据,则需要或者这一行的 S 锁。这个时候有另外一个数据 T2 也想要获取这一行的数据,也需要一个 S 锁,两个 S 锁是兼容的,所以没问题。假设有一个 T3 想要更改这一行的数据,那 T3 就需要有这一行的 X 锁,但是 X 锁和 S 锁是不兼容的,所以 T3 会阻塞住等待 T1 和 T2 把 S 锁都释放掉。

除了行锁外,在表级别,InnoDB 还提供了一种 Intention Lock 即意向锁。

  • 如果需要对某些行的数据进行修改,在获取行的 X 锁之前,首先需要获取这个表的意向排他锁 IX 表达自己接下来会请求某几行的排他锁
  • 如果要读某些行的数据,在获取行的 S 锁之前,要先获取这个表的意向共享锁 IS 表达自己接下来会请求某几行数据的共享锁

意向锁主要还是提前表达自己希望获取的行的锁类型,跟表级别的锁是不会有冲突的,意向锁主要还是用在与其他表锁的比较和测试上。比如一个事务 T1 拿到了某一行数据的 X 锁,肯定已经拿到了行的 IX 锁。这个时候如果有一个全表更新的事务 T2 那 T2 需要获取整个表的 X 锁,T2 就需要知道当前是否还有没有哪一行被 X 锁占着。如果一行一行的去扫描那就太慢了,这个时候就可以通过查询表上的意向锁,看到有一个 IX 锁在上面,就可以知道当前还有事务持有某些行的 X 锁,然后就只能阻塞住等这个 IX 解锁了。

下面这幅图是兼容矩阵,其中所有 IS, IXIS, IX, S, X 的比较都是表级别的锁的比较。比如 IS 和 X 不兼容是说表的意向共享锁和表的排他锁不兼容,而非用意向锁和行级锁做比较。

ISIXSX
IS兼容兼容兼容冲突
IX兼容兼容冲突冲突
S兼容冲突兼容冲突
S冲突冲突冲突冲突

一致性非锁定读#

一致性非锁定读指的是 InnoDB 通过 MVCC 读取数据库中的行。如果当前行被上了 X 锁,可以通过读取行快照的方式来避免阻塞。读快照是用事务的 undo log 实现的。这种方式可以极大的提升数据库的并发性能。

在 RC 和 RR 的隔离级别下 InnoDB 会使用一致性非锁定读。但是在 RC 下读的快照数据是此时的最新数据,在 RR 下快照数据是事务开始的时候的数据。

某些情况下,如果用户需要保证数据的一致性,可以通过加锁的方式来进行一致性锁定读 InnoDB 支持两种 select 加锁模式:

  • select ... for update 会对行数据加一个 X 锁
  • select ... lock in share mode 会对数据加一个 S 锁

这两条语句一定要放在事务里用,事务提交或者回滚的时候会自动释放这两个锁。

自增锁#

对于自增长的列,在并发写情况下就需要使用锁机制保证数据的一致性,每个自增列在内存中都有一个计数器用来分配新插入行的自增列的值。

InnoDB 内部会有一个特殊的自增锁 AUTO-INC Locking 来维护这个值,每次有事务新增行的时候,自增锁会通过一个语句 select max(auto_inc) from t for update 来获取新的自增列值。获取完之后这个自增锁立即释放,不等事务结束。但是这种方式还是锁了表,在高并发情况下效率还是很低,事务必须等待其他事务用完自增锁。而且如果有事务回滚,自增值被丢弃,在自增列上会产生不连续的空洞。

InnoDB 可以配置 innodb_autoinc_lock_mode 的值来控制自增列的锁策略:

  • 0 采用自增锁,效率很低
  • 1 默认值,对于 insert 和 replace 语句对内存中的计数器加 mutex 锁。没有事务锁,相对会快很多。对于其他类型的插入还是会用自增锁。
  • 2 所有的插入都使用 mutex 锁效率会很高,但是可能会导致自增的值不连续。

关于自增列,更详细的内容可以查看 官方文档

锁算法#

行锁有三种算法:

  • Record Lock 锁单个行。比如我们要改某一行数据的时候
  • Gap Lock 锁一个范围,不包含记录本身。比如当前表里面有 1 3 5 7 这四个数据,现在事务要向里面加一个数据 4 那这个时候就要锁定 (3, 5) 这个范围避免同时有别的事务在这中间插入数据。如果还要再插入一条数据 8 就需要在 (7, +∞) 上再加一个范围锁。Gap Lock 的主要作用就是阻止多个事务插入到同一个范围内。设置隔离级别为 RC 可以关闭 Gap Lock
  • Next-Key Lock 相当于前两个锁相加,既锁本身又锁一个范围。这个锁是主要是用来解决幻读问题。

当查询的索引含有唯一属性的时候,InnoDB 会将 Next-Key Lock 降级为 Record Lock 比如事务需要向表中插入一行数据 id=7 ... 而且 id 是唯一索引,那只需要锁 7 这一行就够了,不需要再去锁范围。

幻读是说在同一个事务下,连续执行两次同样的 SQL 可能导致不一样的结果,第二次的 SQL 可能返回之前不存在的行。

比如一个事务 T1 先执行了一条语句 select id from t where id > 10; 这一次没有读出来数据。但是同时有另外一个事务 T2 执行了一个语句 insert into t (id) values (11);接着 T2 先提交。然后 T1 再次执行 select id from t where id > 10; 的时候就会发现突然就有数据了。这种行为违反了事务的隔离性,一个事务能够感知到另外一个事务的结果。

在使用了 Next-Key Lock 后,我们再执行语句 select id from t where id > 10; 的时候会在 (10, +∞) 加一个 X 锁这样 T2 再插入值的时候就会被阻塞住。而 T1 再次执行同一条语句的时候结果是肯定不会变的,因为这个范围已经被上了 X 锁没有事务可以更改这个范围内的数据。

InnoDB 默认的事务隔离级别 RR 下会使用 Next-Key Lock 来保护事务,避免幻读。

使用锁解决事务的隔离性问题#

  • 脏读:是说一个事务可以读取到另一个事务还没有提交的修改。脏读只会发生在 RU 隔离级别下。至少在 RC 下事务未提交的数据都不会被其他事务感知到。
  • 不可重复读 / 幻读:解决方案上面已经说过了。不再赘述。
  • 丢失更新:指的是两个事务更新同一行,由于事务的隔离性,最后先提交的事务的结果会被后提交的事务的结果给覆盖。这其实并不是数据库本身的问题,而是并行事务本来就可能会产生的结果。一种解决方案是使用上面提到的一致性锁定读把事务串行化。也就是使用 SERIALIZABLE 隔离级别。

事务 ACID 和隔离级别#

有强大的锁保护,InnoDB 实现的事务可以实现完全符合 ACID 即:

  • Atomicity 原子性:一个事务是不可分割的单元,事务中的所有操作都成功执行,事务才算成功执行。事务中有任何一个操作失败,所有已经执行的操作也都要撤回
  • Consistency 一致性:在事务开始前和结束后,数据库的所有约束不会被破坏。比如唯一性约束
  • Isolation 隔离性:每个事务的读写在提交前对其他事务都是不可见的
  • Durability 持久性:事务一旦提交,结果就是永久性的,即使发生宕机也能够重新恢复

SQL 标准定了事务的四个隔离级别:

  • READ UNCOMMITTED: 未提交读。某个事务在执行过程中可以读到其他事务没有提交的修改,也就是脏读
  • READ COMMITTED: 已提交读。某个事务在执行过程中可以读到其他事务已经提交的修改,有幻读问题
  • REPEATABLE READ: 可重复读。某个事务可以读到其他事务已经提交的新插入的记录,但是不能读到其他事务已经提交的修改。InnoDB 默认使用的隔离级别,且在此级别下通过 Next—Key Lock 解决幻读问题
  • SERIALIZABLE: 串行化事务。事务中每次读写都需要获取表级别的共享锁

一般来讲隔离级别越低,事务的锁保护就越少,保持锁的时间就越短。

InnoDB 事务的 redo log 和 undo log#

在事务提交前,必须把所有日志写到 redo log 文件里面。这样即使发生宕机,也能从 redo log 中恢复,保证了数据的持久性。同时为了确保 redo log 能够写入到文件中,模式每次写磁盘 redo log 都会进行一次 fsync 可以通过配置 innodb_flush_log_at_trx_commit 来调整 redo log 刷到磁盘的策略。默认是 1 每次都执行 fsync 确保写入。设置为 0 的时候在事务提交时不写 redo log 到文件。设置为 2 的时候会把 redo log 写到文件系统但是不执行 fsync 这样如果日志没有刷到磁盘就宕机可能会产生数据丢失。刷新 redo log 的策略会很大成都上影响到事务提交的速度。

事务在执行过程中,除了 redo log 还会产生 undo log 当事务回滚时,会执行这些 undo log 来将数据修改回原来的样子。undo log 只是逻辑日志每,并非把数据恢复成事务开始的时候,因为同时有可能并发的有多个事务已经修改了原始的数据。

事务控制语句#

在 MySQL 命令行的默认设置下事务都是自动提交的,每条 SQL 语句执行完都会立即执行 COMMIT 可以通过 SET AUTOCOMMIT=0 来禁用自动提交。当然也可以通过 BEGIN 命令显示的开启一个事务。这里顺便整理一下事务的控制语句:

  • BEGIN: 开启一个事务
  • COMMIT: 提交事务
  • ROLLBACK: 回滚事务
  • SAVEPOINT id: 创建一个检查点 id
  • RELEASE SAVEPOINT id: 删除一个检查点
  • ROLLBACK TO id: 回滚到检查点 id 这个检查点之前执行的才做不会被回滚
  • SET TRANSCATION: 设置事务的隔离级别

参考与引用
MySQL 技术内幕 InnoDB 存储引擎 (第二版)
Mysql InnoDB 官网文档

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。