MySQL 事务实现原理

MySQL 事务实现原理

事务隔离级别

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

示例

mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);

  • 读未提交:V1 = V2 = V3 = 2,事务 B 虽然还没有提交,但是结果已经被 A 看到了。
  • 读提交:V1 = 1V2 = V3 = 2,事务 B 的更新在提交后才能被 A 看到。
  • 可重复读:V1 = V2 = 1V3 = 2,事务在执行期间看到的数据前后必须是一致的。
  • 串行化:在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。

隔离级别是如何实现的

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

我们可以看到在不同的隔离级别下,数据库行为是有所不同的。Oracle 数据库的默认隔离级别其实就是“读提交”,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,你一定要记得将 MySQL 的隔离级别设置为“读提交”。

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)

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

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id

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

SELECT 的一致性视图

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

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

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

UPDATE 的一致性逻辑

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。 其实,除了 update 语句外,select 语句如果加锁,也是当前读。

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

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

幻读介绍及原因

在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。幻读仅专指“新插入的行”。

INSERT INTO T VALUES (1, 1, 5);

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙

这样,当你执行 select * from t where d=5 for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。

事务相关的 Log

四个属性

  • A: 要么不执行,要么完全执行,宕机重启已执行的要回滚
  • C: 主键不为空、参照完整性等
  • I: 如果全是串行执行,也不需要隔离,所以和并发有关
  • D: 数据不能丢

Redo Log

redo log 是 InnoDB 引擎特有的日志。

Write-Ahead Log

先在内存中提交事务,然后写 Write-Ahead Log,然后后台把数据异步刷到磁盘。在 InnoDB 中,Write-Ahead Log 是 Redo Log。事务提交之后,Redo Log 先写入到内存的 Redo Log Buffer 中,然后异步刷到磁盘上的 Redo Log 。

InnoDB 关键参数 innodb_flush_log_at_trx_commit 控制刷盘策略:

  • 0: 每秒刷一次 (默认)
  • 1: 提交一个事务,就刷一次
  • 2: 不刷盘,根据 innodb_flush_log_at_timeout 设置的值决定刷盘频率

Redo Log 刷脏页

内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

刷脏页时机:

  • redo log 写满了,要 flush 脏页
  • 内存不够用了,要先将脏页写到磁盘

InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:第一种是,还没有使用的;第二种是,使用了并且是干净页;第三种是,使用了并且是脏页

  • MySQL 认为**系统“空闲”**的时候。
  • MySQL 正常关闭。

Redo Log 逻辑与物理结构

为什么 Redo Log Block 是 512 字节?

因为早期的磁盘,一个扇区是存储 512 字节数据。

为什么 Redo Log 循坏使用?

Redo Log 是固定大小的,循环使用,一旦 Page 数据刷到磁盘上,日志数据就没有存在的必要了。

LSN (Log Sequence Number) 是按照时间顺序从小到大的编号,记录了从安装到现在为止,写入的总的字节数。

binlog

MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。

两阶段提交

为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。

Undo Log

为什么需要 Undo Log ?

基于现代数据库两个设计的事实:

  • 已经提交的事务,可以继续保留在内存
  • 未提交的事务,也可以写入磁盘,如果需要回滚,再更改磁盘上的数据

Undo Log 结构

Undo Log 维护了数据从旧到新的各个版本,各个版本通过链表串联。事务如果想要实现隔离性,只能读取 (select 快照读) 历史版本,不能读取正在修改的数据。

与快照读对应的是当前读:

Undo Log 不是 Log

  • 无序随机写入
  • 事务 commit 之后,就可以删除掉 Undo Log

加锁规则

  • 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  • 原则 2:查找过程中访问到的对象才会加锁。
  • 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

全局锁

Flush tables with read lock;

当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是,做全库逻辑备份

表锁

表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。

-- 第一个窗口执行
LOCK TABLE t2 READ; -- 加读锁

-- 第二个窗口执行
SELECT * FROM t2; -- 不会加锁
UPDATE t2 SET name = 'g1' WHERE id = 7; -- 锁住

-- 第一个窗口执行,可以看到被等待锁的语句
SHOW PROCESSLIST;

-- 第一个窗口执行,第二个窗口的语句才会返回
UNLOCK TABLES;

行锁

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。

-- 首先看一下我们的表
CREATE TABLE t (
  id TINYINT(3) UNSIGNED NOT NULL AUTO_INCREMENT,
  name VARCHAR(10) NOT NULL
  
  --           ↓ (id 字段是索引字段)
  PRIMARY KEY(id)
);

-- 窗口一、窗口二依次执行
BEGIN;

-- 窗口一
--                              ↓ (索引条件)
UPDATE t SET name = 'd1' WHERE id = 4;

-- 窗口二
--                              ↓ (索引条件)
UPDATE t SET name = 'd2' WHERE id = 4; -- 陷入锁等待
UPDATE t SET name = 'd2' WHERE id = 5; -- 不是同一行,不会陷入锁等待

-- 大量事务无法获取锁会挂起,造成严重性能问题
-- INNODB_LOCK_WAIT_TIMEOUT = 100 秒

行锁转为表锁

id 不是索引了。

-- 首先看一下我们的表
CREATE TABLE t (
  -- ↓ (id 不是索引字段)
  id TINYINT(3) UNSIGNED NOT NULL AUTO_INCREMENT,
  name VARCHAR(10) NOT NULL
);

-- 窗口一、窗口二依次执行
BEGIN;

-- 窗口一
--                              ↓ (非索引条件)
UPDATE t SET name = 'd1' WHERE id = 4;

-- 窗口二
--                              ↓ (非索引条件)
UPDATE t SET name = 'd2' WHERE id = 5; -- 陷入表锁等待

乐观锁

update table set x = 1, version = #{version} where id = #{id} and version = #{version}

悲观锁

-- (写锁,排他锁) 必须要在事务中才可以起作用
select * from table for update

减少死锁

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