MySQL 实战45讲--基础篇

开篇词

开篇词 | 这一次,让我们一起来搞懂MySQL


基础篇

01 | 基础架构:一条SQL查询语句是如何执行的?

MySQL 基本架构示意图:

  • MySQL 分为 Server 层和存储引擎层;
  • Server 层包括连接器、查询缓存、分析器、优化器、执行器等,存储引擎层负责数据的存储和提取;
  • 不同的存储引擎共用一个 Server 层;

连接器

  • 负责跟客户端建立连接、获取权限、维持和管理连接;
  • wait_timeout = 8小时,连接器自动断开没动静的客户端连接。

查询缓存

  • 查询缓存往往弊大于利:表上有更新操作就会导致查询缓存失效;
  • MySQL 8.0 版本已经移除查询缓存功能。

分析器

  • 词法分析:识别关键字、表名、列名;
  • 语法分析:识别语法是否正确。

优化器

  • 索引选择、表关联顺序;

执行器

  • 执行 SQL 语句;

问题整理

1、如果表 T 中没有字段 k,而你执行了这个语句 select * from T where k = 1, 那肯定是会报“不存在这个列”的错误: “Unknown column ‘k’ in ‘where clause’”。

这个是在分析器阶段报出的错误。

2、为什么权限检查不是在分析器阶段,而是要到执行器之前检查?

执行过程中可能会有触发器这种在运行时才能确定的过程,分析器工作结束后的 precheck 是不能对这种运行时涉及到的表进行权限校验的,所以需要在执行器阶段进行权限检查。


02 | 日志系统:一条SQL更新语句是如何执行的?

WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。

InnoDB 的 redo log 是固定大小的,从头开始写,写到末尾就又回到开头循环写。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力成为 cash-safe。

redo log – MySQL InnoDB 存储引擎特有的

binlog – MySQL Server 层日志(归档日志)

先有 MySQL Server 层,先有 binlog,binlog 无法做到 cash-safe;InnoDB 存储引擎后出现,通过 redo log 实现 cash-safe。

MySQL binlog 和 InnoDB redo log 的区别:

  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。

  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1”。

  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

redo log 日志的写入采用两阶段提交方式,写入 redo log 但是处于 prepare 阶段,写入 binlog,commit 提交事务,完成 redo log 写入。

  • innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。

  • sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。

问题整理

1、有了 redo log,binlog 的存在是否多余呢?

不多余,binlog 有存在意义。

  • redo log 只有 InnoDB 有,其他存储引擎没有;

  • redo log 是循环写的,无法持久保存,binlog 的“归档”功能,redo log 是不具备的。

2、如何理解 binlog 是”逻辑日志“,redo log 是”物理日志“?

逻辑日志可以给别的数据库,别的引擎使用,已经大家都讲得通这个“逻辑”;
物理日志就只有“我”自己能用,别人没有共享我的“物理格式”。

3、需要掌握的知识点问题:

  1. redo log 的概念是什么?为什么会存在?
  2. 什么是 WAL(write-ahead log) 机制, 好处是什么?
  3. redo log 为什么可以保证 crash-safe 机制?
  4. binlog 的概念是什么, 起到什么作用, 可以做 crash-safe 吗?
  5. binlog 和 redo log 的不同点有哪些?
  6. 物理一致性和逻辑一致性各应该怎么理解?
  7. 执行器和 InnoDB 在执行 update 语句时候的流程是什么样的?
  8. 如果数据库误操作, 如何执行数据恢复?
  9. 什么是两阶段提交, 为什么需要两阶段提交, 两阶段提交怎么保证数据库中两份日志间的逻辑一致性(什么叫逻辑一致性)?
  10. 如果不是两阶段提交, 先写 redo log 和先写 binlog 两种情况各会遇到什么问题?

那么在什么场景下,一天一备会比一周一备更有优势呢?或者说,它影响了这个数据库系统的哪个指标?

好处是“最长恢复时间”更短。在一天一备的模式里,最坏情况下需要应用一天的 binlog。比如,你每天 0 点做一次全量备份,而要恢复出一个到昨天晚上 23 点的备份。一周一备最坏情况就要应用一周的 binlog 了。系统的对应指标就是 @尼古拉斯·赵四 @慕塔 提到的 RTO(恢复目标时间)。当然这个是有成本的,因为更频繁全量备份需要消耗更多存储空间,所以这个 RTO 是成本换来的,就需要你根据业务重要性来评估了。


03 | 事务隔离:为什么你改了我还看不见?

在 MySQL 中事务是在引擎层实现的,MySQL 是一个支持多引擎的数据库系统,但是不是所有的引擎都支持事务。

ACID(Atomicity、Consistency、Isolation、Durability):即原子性、一致性、隔离性、持久性)。

SQL 标准隔离级别:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable)。

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

为实现数据库的隔离级别,数据库内部会创建视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。

事务隔离的实现

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

回滚日志的删除:当系统里没有比这个回滚日志更早的 read-view 的时候,回滚日志会被删除。

为什么不建议使用长事务?

长事务意味着系统里面会存在很老的事务视图。由于这些事情随时可能访问数据库里的任何数据,所以这个事务提交之前,数据库里面他可能用到的回滚记录都必须保留,这就会导致占用大量的存储空间。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

如何查询数据库长事务?

1
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

事务的启动方式

  • 显式启动事务语句,begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。

  • set autocommit=0,这个命令会将这个线程的自动提交关掉。

建议使用方式:建议总是使用 set autocommit=1, 通过显式语句的方式来启动事务;如果减少与数据库交互次数,可以使用 commit work and chain 语法。

问题整理

你现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢?

  • 首先,从应用开发端来看:

    • 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。

    • 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。

    • 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

  • 其次,从数据库端来看:

    • 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
    • Percona 的 pt-kill 这个工具不错,推荐使用;
    • 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
    • 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

04 | 深入浅出索引(上)

索引的出现就是为了提高数据的查询效率,就像书的目录一样。

常见的索引模型

1、哈希表

  • 哈希表这种结构适用于只有等值查询的场景;

2、有序数组

  • 有序数组在等值查询和范围查询场景中的性能就都非常优秀。

    • 等值查询:使用二分法,时间复杂度 O(log(N))。
    • 范围查询:使用二分法,定位查询起点,进行范围查询。
  • 有序数组索引适用于静态存储引擎,如果需要在有序数组中间插入一个记录就必须得挪动后面所有的记录,成本太高。

3、二叉搜索树

二叉搜索树的特点是:父节点左子树所有结点的值小于父节点的值,右子树所有结点的值大于父节点的值。

查找某个结点的时间复杂度:O(log(N))。

新增某个结点的时间复杂度:O(log(N)),为了维持平衡二叉树。

问题点:

1、如何计算一棵二叉树的高度,比如有 100W 个结点二叉树高度,高度是 20。
2、以 InnoDB 的一个整数字段索引为例,N 差树的这个 N 差不多是 1200。

InnoDB 的索引模型

在 MySQL 中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。

在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。

InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的;每一个索引在 InnoDB 里面对应一棵 B+ 树。

  • 主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。

  • 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。

  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;

  • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。

基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

索引维护

B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。

在中间插入数据,需要逻辑上挪动后面的数据,空出一个位置。如果插入数据的数据页已满,会触发页分裂。

  • 页分裂会影响数据库的性能;
  • 页分裂还会影响数据页的利用率。

删除数据可能会触发页合并,当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

自增主键 vs 业务唯一字段主键

自增主键由于具备单调自增性,所以不会涉及挪动其他记录,也不会出现页分裂问题。

业务唯一字段做主键,插入顺序不固定,可能会出现挪动其他记录,触发页分裂。

自增主键占用空间比较小,业务唯一字段可能占用空间比较大;主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

从性能和存储空间方面考量,自增主键往往是更合理的选择。

  • 自增主键不会触发其他记录挪动,也不会触发页分裂;

  • 自增主键长度比较小,普通索引占用空间也就越小。

业务唯一字段做主键的场景:KV场景。

  • 只有一个索引;
  • 该索引必须是唯一索引。

问题总结

1、“N 叉树”的 N 值在 MySQL 中是可以被人工调整的么?

  • 通过改变 key 值来调整

N 叉树中非叶子节点存放的是索引信息,索引包含 Key 和 Point 指针。Point 指针固定为 6 个字节,假如 Key 为 10 个字节,那么单个索引就是 16 个字节。如果 B+ 树中页大小为 16K,那么一个页就可以存储 1024 个索引,此时 N 就等于 1024。我们通过改变 Key 的大小,就可以改变 N 的值;

  • 改变页的大小

页越大,一页存放的索引就越多,N就越大。数据页调整后,如果数据页太小层数会太深,数据页太大,加载到内存的时间和单个数据页查询时间会提高,需要达到平衡才行。

2、请问没有主键的表,有一个普通索引。怎么回表?

没有主键的表,innodb会给默认创建一个Rowid做主键

链接地址

问题整理

对于上面例子中的 InnoDB 表 T,如果你要重建索引 k,你的两个 SQL 语句可以这么写:

1
2
alter table T drop index k;
alter table T add index(k);
1
2
alter table T drop primary key;
alter table T add primary key(id);

问题是,对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么?

为什么要重建索引?索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。

重建索引 k 的做法是合理的,可以达到省空间的目的。但是,重建主键的过程不合理。不论是删除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做了。这两个语句,你可以用这个语句代替:alter table T engine=InnoDB。


05 | 深入浅出索引(下)

1
2
3
4
5
6
7
8
mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k)
)engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行?

1、在 k 索引树上找到 k=3 的记录,取得 ID = 300;
2、再到 ID 索引树查到 ID=300 对应的 R3;
3、在 k 索引树取下一个值 k=5,取得 ID=500;
4、再回到 ID 索引树查到 ID=500 对应的 R4;
5、在 k 索引树取下一个值 k=6,不满足条件,循环结束。

回到主键索引树搜索的过程,我们称为回表。这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

覆盖索引

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

最左前缀原则

B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

在建立联合索引的时候,如何安排索引内的字段顺序。

  • 第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。

  • 第二个考虑的原则就是空间了。比如 name 字段是比 age 字段大的 ,那我就建议你创建一个 (name,age) 的联合索引和一个 (age) 的单字段索引。

索引下推

图一:

图二:

在图一和二这两个图里面,每一个虚线箭头表示回表一次。图一中,在 (name,age) 索引里面我特意去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。图二跟图一区别是,InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。

问题整理

DBA 小吕在入职新公司的时候,就发现自己接手维护的库里面,有这么一个表,表结构定义类似这样的:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `geek` (
`a` int(11) NOT NULL,
`b` int(11) NOT NULL,
`c` int(11) NOT NULL,
`d` int(11) NOT NULL,
PRIMARY KEY (`a`,`b`),
KEY `c` (`c`),
KEY `ca` (`c`,`a`),
KEY `cb` (`c`,`b`)
) ENGINE=InnoDB;

公司的同事告诉他说,由于历史原因,这个表需要 a、b 做联合主键,这个小吕理解了。

但是,学过本章内容的小吕又纳闷了,既然主键包含了 a、b 这两个字段,那意味着单独在字段 c 上创建一个索引,就已经包含了三个字段了呀,为什么要创建“ca”“cb”这两个索引?同事告诉他,是因为他们的业务里面有这样的两种语句:

1
2
select * from geek where c=N order by a limit 1;
select * from geek where c=N order by b limit 1;

现在的问题是,这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢?

06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。

全局锁

全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。

让整库都只读,会出现如下一些问题:

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;

  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。

官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数 –single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。

single-transaction 方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过 FTWRL 方法。

既然要全库只读,为什么不使用 set global readonly=true 的方式呢?确实 readonly 方式也可以让全库进入只读状态,但我还是会建议你用 FTWRL 方式,主要有两个原因:

  • 一是,在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。

  • 二是,在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。

表级锁

两种表级锁:a、表锁;b、元数据锁(meta data lock,MDL)。

表锁的语法:lock table ... read/write,释放锁:unlock tables(客户端断开会自动释放表锁)。

举例:在某个线程 A 中执行 lock tables t1 read, t2 write; 后的现象:

  • 其他线程读 t1、读写 t2 都会阻塞;

  • 线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。不允许执行写 t1 操作,也不能访问其他表。

MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

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

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。

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

如何安全的给小表加字段?

1、解决长事务,事务不提交,就会一直占着 MDL 锁。information_schema 库的 innodb_trx 表中查询长事务,暂停或者 kill 掉长事务。

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

1
2
ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...

07 | 行锁功过:怎么减少行锁对性能的影响?

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

如果一个事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁示例

出现死锁后,会有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置(默认值为:50s)。

  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

由于需要做死锁检测,所以如果在热点行存在并发更新,不同事务之间的死锁检测会耗费大量的 CPU 资源,观察到的现象就是 CPU 利用率很高,但是每秒却执行不了几个事务。

怎么解决由这种热点行更新导致的性能问题呢?

  • 如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。

    • 有一定风险,关闭死锁检测,一旦出现死锁,只能等待超时;
  • 控制并发

    • 客户端实现:如果有大量客户端连接,控制效果不佳
    • 如果有中间件,可以在中间件实现
    • 可以定制化修改 MySQL 源码
  • 通过将一行改成逻辑上的多行来减少锁冲突(将一行上并发,调整到多行上并发)

    • 数据分散存储到多行上,需要业务系统配合调整

如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。

减少死锁的主要方向,就是控制访问相同资源的并发事务量。


08 | 事务到底是隔离的还是不隔离的?(学的不好,需要重新学习)

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

  • 第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;
  • 第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。

在 MySQL 里面,有两个“视图”的概念:

  • 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view …,而它的查询方法与表一样。

  • 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。

同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

图中的三个虚线箭头其实就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

在实现上,InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。这个视图数组把所有的 row trx_id 分成了几种不同的情况。

一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  • 版本未提交,不可见;
  • 版本已提交,但是是在视图创建后提交的,不可见;
  • 版本已提交,而且是在视图创建前提交的,可见。

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
感谢您的阅读,本文由 董宗磊的博客 版权所有。如若转载,请注明出处:董宗磊的博客(https://dongzl.github.io/2021/07/08/08-MySQL-Actual-Combat-45-Lectures/)
Redis 知识思维导图总结
Redis 常用数据结构及其底层存储实现总结