掘金 后端 ( ) • 2024-05-06 14:31

1. 事务回顾

事务指的是逻辑上的一组操作,组成这组操作的各个单元要么全部成功,要么全部失败。

事务作用:保证一个事务中多次SQL操作要么全部成功,要么全部失败。

Mysql是一个服务器/客户端架构的软件,对应同一个服务器来说,可以有多个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。我们可以同时在不同的会话中输入各种语句,这些语句可以作为事务的一部分进行处理。不同的会话可以同时发送请求,也就是说服务器可能同时处理多个事务,这样就会导致不同的事务可能访问到相同的记录。

事务的隔离性在理论上,是指,在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对性能影响太大,所以才会出现各种隔离级别,来最大限度的提升系统并发处理事务的能力,牺牲部分隔离性来提升性能。

事务是数据库最为重要的机制之一,凡是使用过数据库的人,都了解过数据库的事务机制,也对ACID四个基本特性如数家珍,但是对其底层的实现原理往往了解不够,所以接下来聊一聊事务的原理。

由于Mysql中的事务是存储引擎实现,而且只有InnoDB支持事务,因此我们讲解InnoDB的事务。

1.1 事务四大特性ACID

数据库事务具有ACID四大特性,分别为

  • 原子性(Atomicity):原子性是指事物是一个不可分割的工作单位,事务内的操作要么都发生,要么都不发生
  • 一致性(Consistency):事务前后数据的完整性必须保持一致
  • 隔离性(Isolation):多个用户并发访问数据库时,一个用户的事务不能被其他用户的事务所干扰,多个并发事务之间数据要相互隔离。隔离性由隔离级别保障!
  • 持久性(Durability):一个事务一旦提交,他对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。

1.2 事务并发问题

  1. 脏读:一个事务读到了另一个事务未提交的数据
  2. 不可重复读:一个事务读到了另一个事务已经提交(update) 的数据。引起事务中的多次查询结果不一致。
  3. 虚读/幻读:一个事务读到了另一个事务已经插入(insert) 的数据。导致事务中多次的查询结果不一致。
  4. 丢失更新的问题!

1.3 隔离级别

  • read uncommitted 读未提交【RU】:一个事务读到另一个事务没有提交的数据
    • 存在:3个问题(脏读、不可重复读、幻读)
  • read committed 读已提交【RC】:一个事务读到另一个事务已经提交的数据
    • 存在:2个问题(不可重复读、幻读)
    • 解决:1个问题(脏读)
  • repeatable read 可重复读【RR】:在一个事务中读到的数据始终保持一致,无论是另一个事务是否提交
    • 存在:1个问题(幻读) 其实已经解决了,但不是通过MVCC解决的,是通过锁来实现的,后面再聊
    • 解决:2个问题(脏读、不可重复读)
  • serializable 串形化:同时只能执行一个事务,相当于事务中的单线程
    • 解决:3个问题(脏读、不可重复读、幻读)

安全和性能对比

  • 安全性:serializable > repeatable read > read committed > read uncommitted
  • 性能:read uncommitted > read committed > repeatable read > serializable

常见数据库的默认隔离级别

  • Mysql:repeatable read
  • Oracle:read committed

2. 一条Insert语句的执行流程

Insert into tab_user(id,name,age,address) values (1,'刘备',18,'蜀国');

image.png

3. 事务底层原理详解

如何实现的隔离级别? RC是怎样实现的? RR是怎样实现的?

3.1 丢失更新问题

两个事务针对同一个数据进行修改操作时会丢失更新,这个现象被称之为丢失更新问题。

举个例子:管理员查询所有用户的存款总和,假设除了用户01和用户02之外,所有用户的存款都为0,用户01和02各有存款1000,所以所有用户的存款总额为2000. 但是在查询过程中,用户01向用户02转账100元,就会形成下面的情况:

image.png

3.2 解决方案

3.2.1 解决方案一:基于锁并发控制LBCC

使用基于锁的并发控制LBCC(Lock Based Concurrency Control)可以解决上述问题。

查询总额事务会对读取的行加锁,等到操作结束后再释放所有行上的锁。因为用户A的存款被锁,导致转账操作被阻塞,指导查询总额事务提交并将所有的锁释放。

image.png

这种方案比较简单粗暴,就是一个事务去读取一条数据的时候,就上锁,不允许别的事务来操作。加入当前事务只是加读锁, 那么其他事务就不能有写锁,也就是不能修改数据;而假如当前事务需要加写锁,那么其他事务就不能持有任何锁。总而言之,能加锁成功,就确保了除了当前事务之外,其他事务不会对当前数据产生影响,所以自然而然,当前事务读取到的数据就只能是最新的,而不会是快照数据

3.2.2 解决方案二:基于版本并发控制MVCC

当然使用版本的并发控制MVCC(Multi Version Concurrency Control)机制也可以解决这个问题。

查询总额事务先读取了用户A的账户存款,然后转账事务会修改用户A和用户B的账户存款,查询总额事务读取用户B存款时,不会读取转账事务修改后的数据,而是读取本事务开始时的副本数据【快照数据】。

image.png

MVCC使得普通的SELECT请求不加锁,读写不冲突,显著提高了数据库的并发处理能力。 MVCC保障了ACID中的隔离性。

请求不加锁、读写不冲突!!!

3.3 MVCC实现原理【InnoDB】

定义:MVCC全称叫多版本并发控制,是RDBMS常用的一种并发控制方法,用来对数据库进行并发访问,实现事务。 核心思想是读不加锁、读写不冲突。在读多写少的应用中,读写不冲突非常重要,极大的增加了系统的并发性能。

MVCC实现原理关键在于数据快照,不同的事务访问不同版本的数据快照,从而实现事务下对数据的隔离级别。 虽然说具有多个版本的数据快照,但这并不意味着必须拷贝数据,保存多份数据文件(这样会浪费存储空间),Innodb通过事务的Undo日志巧妙的实现了多版本的数据快照。

MVCC的实现依赖于 Undo日志ReadView

image.png InnoDb下的表有默认字段和可见字段, 默认字段是实现MVCC的关键,默认字段是隐藏的列。默认字段最关键的2列,一个保存了行的事务ID,一个保存了行的回滚指针。 每当开启新的事务,都会自动递增产生一个新的事务ID。

image.png 事务开始后,生成当前事务影响行的ReadView。当查询时,需要用当前查询的事务ID与ReadView确定要查询的数据版本。

3.3.1 Undo日志

Redo日志记录了事务的行为,可以很好的通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要undo log。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo log,还会产生一定量的undo log。这样如果用户执行的事务或者语句由于某种原因失败了,又或者用户用一条rollback语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。在多事务读取数据时,有了undo日志,可以做到读不加锁、读写不冲突。

undo存放在数据库内部的一个特殊段(segment)中,这个段被称为Undo段,位于系统表空间中,也可以设置为undo表空间。

Undo日志保存了记录修改前的快照。所以,对于更新和删除操作,InnoDB并不是真正的删除原来的记录,而是设置记录的delete mark为1。因此为了解决数据Page和Undo日志膨胀的问题,则需要回收机制进行清理Undo日志。

根据行为的不同,Undo日志分为2种:Insert Undo Log 和 Update Undo Log

1. Insert Undo Log:是在insert操作中产生的undo日志

Insert操作的记录只对事务本身可见,对于其他事务此记录是不可见的,所以Insert Undo Log可以在事务提交后直接删除而不需要进行回收操作。

如下图所示(初始状态):

# 事务1:
Insert into tab_user(id,name,age,address) values (10,'麦麦',23,'beijing');

image.png

2. Update Undo Log: 是Update或者Delete操作中产生的Undo日志

Update操作会对已经存在的行记录产生影响,为了实现MVCC多版本并发控制机制,因此Update Undo Log不能在事务提交时就删除,而是在事务提交时将日志放入指定区域,等待Purge线程进行最后的删除操作。

如下图所示(第一次操作):

# 事务2:
update tab_user set name='雄雄',age=18 where id=10;

# 当事务2使用Update语句修改该行数据时,会首先使用写锁锁定目标行,将该行当前的值复制到Undo中,然后再真正地修改当前行的值,最后填写事务ID,使用回滚指针指向Undo中修改前的行。

image.png

当事务3进行修改 与事务2的处理过程类似,如下图所示(第二次修改):

# 事务3:
update tab_user set name='迪迪',age=16 where id=10;

image.png

3.3.2 ReadView

MVCC的核心问题是:判断一下版本链中的哪个版本是当前事务可见的!

  • 对于使用RU隔离级别的事务来说,直接读取记录的最新版本就好了,不需要Undo log
  • 对于使用串形化隔离级别的事务来说,使用加锁的方式来访问记录,不需要Undo log
  • 对于使用RC和RR隔离级别的事务来说,需要用到Undo日志的版本链

1. 什么是readView? ReadView是张存储事务ID的表,主要包含当前系统中有哪些活跃的读写事务,把他们的事务ID放到一个列表中。结合Undo日志的默认字段【事务trx_id】来控制哪个版本的Undo日志可被其他事务看见。

四个列:

  • m_ids: 表示在生成ReadView时,当前系统中活跃的读写事务ID列表。
  • m_low_limit_id: 事务ID下限,表示当前系统中活跃的读写事务中最小的事务id, m_ids事务列表中最小的事务id
  • m_up_limit_id: 事务ID上限,表示生成ReadView时,系统中应该分配给下一个事务的ID值
  • m_creator_trx_id: 表示生成该ReadView的事务的事务ID

image.png

2. ReadView怎么产生,什么时候生成?

  • 开始事务之后,在第一次查询(select)时,生成ReadView
  • RC 和 RR 隔离级别的差异本质是因为MVCC中ReadView生成时机不同

3. 如何判断可见性?

开启事务执行第一次查询时,首先生成ReadView,然后根据Undo日志和ReadView按照判断可见性,按照下边步骤判断记录的版本链的某个版本是否可见。

循环判断规则如下:

  • 如果被访问版本的trx_id属性值小于ReadView中的事务下限ID,表示生成该版本的事务在生成ReadView之前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值等于ReadView中的m_creator_trx_id可以被访问
  • 如果被访问版本的trx_id属性值大于等于ReadView中的事务上限id,说明是在生成Readview后才产生的数据,所以该版本不可以被访问。
  • 如果被访问版本的trx_id属性值,在事务下限id和事务上线id之间,那就需要判断是不是在m_ids列表中:
    • 如果在m_ids列表中,说明创建ReadView时生成该版本的事务还是活跃的,不可以被访问
    • 如果不在m_ids列表中,说明创建ReadView时生成该版本的事务已经提交,可以访问

循环判断Undo log中版本链某一的版本是否对当前事务可见,如果循环到最后一个版本也不可见的话,那么意味着这条记录对该事务不可见,查询结果就不包含该记录。

image.png

3.3.3 ReadView案例分析

案例01 - 读已提交RC隔离级别下的可见性分析

每次读取数据前都会生成一个ReadView, 默认tab_user表里只有一条数据,数据内容是刘备。

image.png

事务ID是递增的

image.png

select01执行过程如下:

  • 在执行select语句时会先生成一个ReadView,m_ids列表内容就是【100,200】
  • 然后从版本链中挑选可见的记录,从图中可以看出
    • 最新版本的数据内容是‘张飞’,该版本的trx_id值为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘关羽’,该版本的trx_id值也为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘刘备’,该版本的trx_id值也为80,小于m_ids列表最小的事务id 100, 符合要求
  • 最后返回的是刘备的数据。

image.png

select02执行过程如下:

  • 在执行select语句时会先生成一个ReadView,m_ids列表内容就是【200】
    • 事务ID为100的那个事务已经提交了,所以生成快照的时候就没有它了
  • 然后从版本链中挑选可见的记录,从图中可以看出
    • 最新版本的内容是‘诸葛亮’,该版本trx_id值为200,在m_ids列表内,不符合可见性要求,跳下一个版本。
    • 下一个版本的内容是‘赵云’,该版本trx_id值为200,在m_ids列表内,不符合可见性要求,跳下一个版本。
    • 下一个版本的内容是‘张飞’,该版本trx_id值为100,小于m_ids列表内最小值200,符合可见性要求,此版符合要求
  • 最后返回’张飞‘的记录

以此类推,如果之后事务id为 200 的记录也提交了,再次在使用 RC 隔离级别的事务中查询表 t 中 id 值为 1 的记录时,得到的结果就是 '诸葛亮' 了,具体流程我们就不分析了。

总结:使用RC隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。

案例代码如下:

CREATE TABLE `tab_user` (
`id` int(11) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`age` int(11) NOT NULL,
`address` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

Insert into tab_user(id,name,age,address) values (1,'刘备',18,'蜀国');
# 事务01

-- 查询事务隔离级别:
select @@tx_isolation;

-- 设置数据库的隔离级别
set session transaction isolation level read committed;

SELECT * FROM tab_user; # 默认是刘备

# Transaction 100

BEGIN;

UPDATE tab_user SET name = '关羽' WHERE id = 1;

UPDATE tab_user SET name = '张飞' WHERE id = 1;

COMMIT;
# 事务02

-- 查询事务隔离级别:
select @@tx_isolation;

-- 设置数据库的隔离级别
set session transaction isolation level read committed;

# Transaction 200

BEGIN;

# 更新了一些别的表的记录

...

UPDATE tab_user SET name = '赵云' WHERE id = 1;

UPDATE tab_user SET name = '诸葛亮' WHERE id = 1;

COMMIT;
# 事务03

-- 查询事务隔离级别:
select @@tx_isolation;

-- 设置数据库的隔离级别
set session transaction isolation level read committed;

BEGIN;

# SELECT01:Transaction 100、200未提交
SELECT * FROM tab_user WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT02:Transaction 100提交,Transaction 200未提交
SELECT * FROM tab_user WHERE id = 1; # 得到的列c的值为'张飞'

# SELECT03:Transaction 100、200提交
SELECT * FROM tab_user WHERE id = 1; # 得到的列c的值为'诸葛亮'

COMMIT;

使用的SQL小结:

-- 开启事务:还有一种方式begin
start transaction

-- 提交事务:
commit

-- 回滚事务:
rollback

-- 查询事务隔离级别:
select @@tx_isolation;

-- 设置数据库的隔离级别
set session transaction isolation level read committed

-- 级别字符串:`read uncommitted`、`read committed`、`repeatable read【默认】`、
`serializable`

-- 查看当前运行的事务

SELECT
a.trx_id,a.trx_state,a.trx_started,a.trx_query,
b.ID,b.USER,b.DB,b.COMMAND,b.TIME,b.STATE,b.INFO,
c.PROCESSLIST_USER,c.PROCESSLIST_HOST,c.PROCESSLIST_DB, d.SQL_TEXT
FROM
information_schema.INNODB_TRX a
LEFT JOIN information_schema.PROCESSLIST b ON a.trx_mysql_thread_id = b.id
AND b.COMMAND = 'Sleep'
LEFT JOIN PERFORMANCE_SCHEMA.threads c ON b.id = c.PROCESSLIST_ID
LEFT JOIN PERFORMANCE_SCHEMA.events_statements_current d ON d.THREAD_ID =
c.THREAD_ID;

案例02-可重复读RR隔离级别下的可见性分析

在事务开始后第一次读取数据生成一个readView。对于使用RR隔离级别的事务来说,只会在第一次执行查询语句时生成一个readView,之后的查询就不会重复生成了。

代码与执行过程与RC案例完全一致,唯一不同的是事务隔离级别。

T3时刻,表t中id为1的记录得到的版本链如下所示:

image.png

select01执行过程如下:

  • 在执行select语句时会先生成一个ReadView,m_ids列表内容就是【100,200】
  • 然后从版本链中挑选可见的记录,从图中可以看出
    • 最新版本的数据内容是‘张飞’,该版本的trx_id值为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘关羽’,该版本的trx_id值也为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘刘备’,该版本的trx_id值也为80,小于m_ids列表最小的事务id 100, 符合要求。
  • 最后返回的是刘备的数据。

image.png

select02执行过程如下:

  • 因为之前已经生成过ReadView了,所以此时直接复用之前的ReadView,m_ids列表内容就是【100,200】
  • 然后从版本链中挑选可见的记录,从图中可以看出
    • 最新版本的数据内容是‘诸葛亮’,该版本的trx_id值为200,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘赵云’,该版本的trx_id值也为200,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘张飞’,该版本的trx_id值为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘关羽’,该版本的trx_id值为100,在m_ids范围内,所以不符合可见性要求,跳到下一个版本。
    • 下一个版本的数据内容是‘刘备’,该版本的trx_id值为800,小于m_ids最小值id100,所以符合可见性要求。
  • 最后返回的是刘备的数据。

也就是说两次 SELECT 查询得到的结果是重复的,记录的列 c 值都是 '刘备' ,这就是 可重复读 的含义。

如果我们之后再把事务id为 200 的记录提交了,之后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个id为 1 的记录,得到的结果还是 '刘备' ,具体执行过程大家可以自己分析一下。

注意:MVCC只在RR和RC两个隔离级别下工作。RU和串行化隔离级别不需要 MVCC,为什么?

  • 因为RU总是读取最新的数据行,本身没有隔离性,也不解决并发潜在问题,因此不需要。
  • serialzable则会对所有读取的行加锁,相当于串形执行,线程之间绝对隔离,也不需要。

3.4 MVCC下的读操作

在MVCC并发控制中,读操作可以分为两类:快照读(Snapshot Read)当前读(Current Read)

  • 快照读:读取的是记录的可见版本(有可能是历史版本),不用加锁。刚才所有的案例都是快照读。
  • 当前读:读取的是记录最新版本,并且当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

3.4.1 当前读和快照读

快照读也就是一致性非锁定读,是指InnoDB存储引擎通过多版本控制(MVCC)读取当前数据库中行数据的方式。如果读取的行正在执行delete或update操作,这时读操作不会因此去等待行上锁的释放。相反的,InnoDb会去读取行的一个最新可见快照。ReadView的读取操作就是快照读。

举例:

  • 快照读:简单的select操作,属于快照读,不加锁
select * from table where ?;
  • 当前读:特殊的读操作,插入、更新、删除操作,属于当前读,需要加锁。
select * from table where ? lock in share mode; #加读锁
select * from table where ? for update; #加写锁

insert into table values (...); #加写锁
update table set ? where ?; #加写锁
delete from table where ?; #加写锁

# 所有以上的语句,都属于当前读,读取记录的最新版本。并且读取之后,还需要保证其他并发事务不能修改当前记录,对读取数据加锁。
# 除了第一条语句,对读取记录加读锁外,其他操作都是加的写锁。

3.4.2 案例:当前读

BEGIN;

# SELECT1:Transaction 100、200未提交

SELECT * FROM tab_user WHERE id = 1; # 得到的列name的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交

SELECT * FROM tab_user WHERE id = 1; # 得到的列name的值为'张飞'

select * from tab_user where id=1 lock in share mode; # 当前读
如果查询这条数据的时候,有别的事务在操作该数据,后等到提交修改事务后,再读取最新的结果。

COMMIT;

3.4.3 一个CRUD的CUD操作的具体过程

update table set ? where ?;

image.png

从图中可以看出: 当update sql 被发给Mysql后,

  • 首先,Mysql会根据where条件,读取第一条满足条件的记录,然后Innodb引擎会将第一条记录返回,并加锁(current read)。
  • 待Mysql Server收到这条加锁的记录后,再发起一个update请求,更新这条数据
  • 一个记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。因此,update操作内部,就包含了一个当前读。

同理,delete操作也一样。Insert操作会稍微有些不同,简单来说,insert操作可能会触发Unique Key的冲突检查,也会进行一个当前读。

根据上图的交互,针对一条当前读的SQL语句,InnoDB与Mysql Server的交互,是一条一条进行的,因此,加锁也是一条一条进行的。先对一条满足条件的记录加锁,返回给mysql Server,做一些DML操作。然后再读取下一条加锁,直至读取完毕。

3.5 小结

  • MVCC指在使用RC、RR隔离级别下,使用不同事务的读-写写-读操作并发执行,提高系统性能
  • MVCC的核心思想是 读不加锁、读写不冲突
  • RC、RR这两个隔离级别的一个很大不同就是生成ReadView的时机不同
    • RC在每一次进行普通select操作前都会生成一个ReadView
    • RR在第一次进行普通select操作前生成一个ReadView,之后的查询操作都会重复使用这个ReadView