前言
读完本系列文章可以掌握:
- 事务的特性与事务并发造成的问题
- 事务读一致性问题的解决方案
- MVCC的原理
- 锁的分类、行锁的原理、行锁的算法
注意: 本文章使用的Mysql版本为5.7大版本,小版本不限,存储引擎:InnoDB、事物隔离级别:RR 。读者如用其他版本Mysql操作,得出的结果可能和本文不一致
select version();
show variables like 'engine%';
show global variables like 'tx_isolation';
1. 什么是数据库的事务?
1.1事务的典型场景
很多时候我们需要事务是因为我们希望涉及数据库的多个操作要么都成功要么都失败,比如客户下单会操作订单表,资金表,物流表等等,就需要放在一个事务里面执行。
很多同学在学习数据库事务的时候都接触过一个非常典型的案例,就是银行转账。 如果我们把行内转账简化成:一个账户余额减少,另一个账户的余额增加的情况,那么这两个动作一定是同时成功或者同时失败的,否则就会造成银行的账目不平。
另外一个例子:12306的接续换乘功能,两张票必须同时购买成功,只买到前半程或者只买到后半程都是没有意义的。
1.2 事务的定义
维基百科的定义:事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。
这里面有两个关键点,第一个,所谓的逻辑单位,意味着它是数据库最小的工作单元,是不可以再分的。第二个,它可能包含了一个或者一系列的DML语句,包括insert delete update。
(单条 DDL (create drop)和 DCL (grant revoke)也会有事务)
1.3哪些存储引擎支持事务
并不是所有的数据库或者所有的存储引擎都支持事务,它是作为一种特性出现的。 在MySQL所支持的这些存储引擎里面,有哪些是支持事务的呢?
除了做集群的NDB之外,只有InnoDB支持事务,这个也是它成为默认的存储引擎的一个重要原因。
为什么支持事务能够让InnoDB脱颖而岀,事务到底提供了哪些特性呢?
1.4事务的四大特性
- 原子性 Atomicity:,也就是我们刚才说的不可再分,因为原子是化学上(参加化学反应)最小的单位。也就意味着我们对数据库的一系列的操作,要么都是成功, 要么都是失败,不可能出现部分成功或者部分失败的情况,以刚才提到的转账的场景为例,一个账户的余额减少,必然对应着另一个账户余额的增加。
全部成功比较简单,问题是如果前面一个操作已经成功了,后面的操作失败了,怎么让它全部失败呢?这个时候我们必须要回滚。
原子性,在InnoDB里面是通过undo log来实现的,它记录了数据修改之前的值(逻辑日志),一旦发生异常,就可以用undo log来实现回滚操作。 - 隔离性 Isolation:,我们有了事务的定义以后,在数据库里面会有很多的事务同时去操作同一张表或者同一行数据,必然会产生一些并发或者干扰的操作。 我们对隔离性的定义,就是多个事务,对表或者行的并发操作,应该是透明的, 互相不干扰的。比如两个人给小明转账100,开启两个事务,都拿到了小明账户的余额 1000,然后各自基于1000加100,最后结果是1100,就出现了数据混乱的问题。
在InnoDB中隔离性怎么实现呢?这个我们后面再详细分析。 - 持久性 Durability:,事务的持久性是什么意思呢?我们对数据库的任意的操作,增删改,只要事务提交成功,那么结果就是永久性的,不可能因为数据库断电、 宕机、意外重启,又变成原来的状态。这个就是事务的持久性。
持久性怎么实现呢?回想一下,InnoDB崩溃恢复(crash-safe)是通过什么实现的?
持久性是通过redo log和double write buffer (双写缓冲) 来实现的,我们操作数据的时候,会先写到内存的buffer pool里面,同时记录redo log,如果在刷盘之前出现异常,在重启后就可以读取redo log的内容,写入到磁盘,保证数据的持久性。当然,恢复成功的前提是数据页本身没有被破坏,是完整的,这个通过双写缓冲保证。
需要注意的是,原子性,隔离性,持久性,最后都是为了实现一致性。 - 一致性 consistent:,指的是数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。
数据库自身提供了一些约束:比如主键必须是唯一的,字段长度符合要求。另外还有用户自定义的完整性。
比如说转账的这个场景,A账户余额为0,如果这个时候转账1000元,并且转账成功了,A账户的余额会变成-1000,虽然它也满足原子性,但是我们知道,借记卡的余额是不能够小于0的,所以违反了一致性。用户自定义的完整性通常要在代码中控制。
1.5数据库什么时候会出现事务
当我执行这样一条更新语句的时候,它有事务吗?
update student set sname ='二营长' where id=1;
实际上,它不仅自动开启了一个事务,而且自动提交了,所以最终写了磁盘。
这个是开启事务的第一种方式,增删改的语句会自动开启事务,当然是一条SQL一个事务。注意每个事务都是有编号的,这个编号是一个整数,有递增的特性。
如果要把多条SQL放在一个事务里面,就要手动开启事务。
手动开启事务有两种方式:
- 一种是用 begin;
- —种是用 start transaction
那么怎么结束一个事务呢?
结束事务两种方式:
- 第一种是回滚事务rollback,事务结束。
- 第二种就是提交一个事务,commit,事务结束。
InnoDB里面有一个autocommit的参数(分为两个级别,session级别和global级别)。
show variables like 'autocommit';
它的默认值是ON。autocommit这个参数是什么意思呢?是否自动提交。如果它的值是true/on的话,我们在操作数据的时候,会自动提交事务。
否则的话,如果我们把autocommit设置成false/off,那么数据库的事务就需要我们手动地结束,用rollback或者commit。
还有一种情况,客户端的连接断开的时候,事务也会结束。
1.6事务并发会带来什么问题?
-
脏读:
假设我们有两个事务,一个事务编号1010, 另—个事务编号1011。在第一个事务里面,它首先通过一个where id = 1的条件査询一条数据,返回name=小明, age=16的这条数据。然后第二个事务呢,它同样地是去操作id = 1的这行数据,它通过一个update的语句,把这行id = 1的数据的age改成了 18,但是大家注意,它没有提交。
这个时候,在第一个事务里面,它再次去执行相同的查询操作,发现数据发生了变化,获取user的数据age变成了 18。那么,这种在一个事务里面,由于其他的事务修改了数据并且没有提交,而导致了前后两次读取数据不一致的情况,这种事务并发的问题, 叫做脏读。
如果在转账的案例里面,我们第一个事务基于读取到的第二个事务未提交的余额进行了操作,但是第二个事务进行了回滚,这个时候就会导致数据不一致。 -
不可重复读:
同样是两个事务,第一个事务通过id=1査询到了一条数据。然后在第二个事务里面执行了一个update操作,执行了 update以后它commit 提交了修改。然后第一个事务读取到了其他事务已提交的数据导致前后两次读取数据不一致的情况,就像这里,age到底是等于16还是18,那么这种一个事务读取到了其他事务已提交的数据导致前后两次读取数据不一致的情况,叫做不可重复读。 -
幻读:
在第一个事务里面我们执行了一个范围查询,这个时候满足条件的数据只有一条。在第二个事务里面,它插入了一行数据,并且提交了。重点:插入了一行数据。在第一个事务里面再去查询的时候,发现多了一行数据。这种情况就好像突然冒出来的一个幻影一样。这种一个事务前后两次读取数据数据不一致,是由于其他事务插入数据造成的,这种情况我们把它叫做幻读。
不可重复读和幻读最大的区别在那里呢?
修改或者删除造成的读不一致叫做不可重复读,插入造成的读不一致叫做幻读。
小结:
我们刚才讲了事务并发带来的三大问题,现在来给大家总结一下。无论是脏读、不可重复读,还是幻读,它们都是数据库的读一致性的问题,都是在一个事务里面前后两次读取出现了不一致的情况。
读一致性的问题,必须要由数据库提供一定的事务隔离机制来解决。就像我们去饭店吃饭,基本的设施和卫生保证都是饭店提供的。那么我们使用数据库,隔离性的问题也必须由数据库帮助我们来解决。
1.7 SQL92 标准
美国国家标准协会(ANSI)制定了一个SQL标准,也就是说建议数据库厂商都按照这个标准,提供一定的事务隔离级别,来解决事务并发的问题。这个SQL标准有很多的版本,大家最熟悉的是SQL92标准。
我们来看一下SQL92标准的官网。
Level | P1(脏读) | P2(不可重复读) | P3(幻读) |
---|---|---|---|
READ UNCOMMITTED (读未提交) | Possible | Possible | Possible |
READ COMMITTED (读已提交) | Not Possible | Possible | Possible |
REPEATABLE READ (可重复读) | Not Possible | Not Possible | Possible |
SERIALIZABLE (串行化) | Not Possible | Not Possible | Not Possible |
这里面有一张表格(搜索iso),里面定义了四个隔离级别,右边的P1,P2,P3就是代表事务并发的3个问题,脏读,不可重复读,幻读。Possible代表在这个隔离级别下,这个问题有可能发生,换句话说,没有解决这个问题。Not Possible就是解决了这个问题。
我们详细地分析一下这4个隔离级别是怎么定义的。
- 第一个隔离级别叫做:Read Uncommitted (读未提交),一个事务可以读取到其他事务未提交的数据,会出现脏读,所以叫做RU,它没有解决任何的问题。
- 第二个隔离级别叫做:Read Committed (读已提交),也就是一个事务只能读取到其他事务已提交的数据,不能读取到其他事务未提交的数据,它解决了脏读的问题, 但是会出现不可重复读的问题。
- 第三个隔离级别叫做:Repeatable Read (可重复读),它解决了不可重复读的问题, 也就是在同一个事务里面多次读取同样的数据结果是一样的,但是在这个级别下,没有定义解决幻读的问题。
- 第四个Serializable (串行化),在这个隔离级别里面,所有的事务都是串行执行的,也就是对数据的操作需要排队,已经不存在事务的并发操作了,所以它解决了所有的问题。
事务隔离级别修改sql:
set global transaction isolation level read uncommitted;
set global transaction isolation level read committed;
set global transaction isolation level repeatable read;
set global transaction isolation level serializable;
这个是SQL92的标准,但是不同的数据库厂商或者存储引擎的实现有一定的差异,比如Oracle里面就只有两种RC (已提交读)和Serializable (串行化)。那么MySQL 中支持事务的存储引擎InnoDB的实现又是怎么样的呢?
1.8 MySQL InnoDB对隔离级别的支持
事务隔离级别 | P1(脏读) | P2(不可重复读) | P3(幻读) |
---|---|---|---|
READ UNCOMMITTED (读未提交) | Possible | Possible | Possible |
READ COMMITTED (读已提交) | Not Possible | Possible | Possible |
REPEATABLE READ (可重复读) | Not Possible | Not Possible | 对InnoDB不可能 |
SERIALIZABLE (串行化) | Not Possible | Not Possible | Not Possible |
InnoDB支持的四个隔离级别和SQL92定义的完全一致,隔离级别越高,事务的并发度就越低。唯一的区别就在于,InnoDB在RR的级别就解决了幻读的问题。也就是说,不需要使用串行化的隔离级别去解决所有问题,既保证了数据的一致性,又支持较高的并发度。这个就是InnoDB默认使用RR作为事务隔离级别的原因。
1.9 解决读一致性的两大实现方案
如果要解决读一致性的问题,保证一个事务中前后两次读取数据结果一致,实现事务隔离,应该怎么做?总体上来说,我们有两大类的方案。
1.9.1 LBCC (基于锁的并发控制 Lock Based Concurrency Control )
第一种,既然要保证前后两次读取数据一致,那么我读取数据的时候,锁定我要操作的数据,不允许其他的事务修改就行了。
如果仅仅是基于锁来实现事务隔离,一个事务读取的时候不允许其他时候修改,那就意味着不支持并发的读写操作,而我们的大多数应用都是读多写少的,这样会极大地影响操作数据的效率。
所以我们还有另一种解决方案。
1.9.2 MVCC (多版本的并发控制 Multi Version Concurrency Control)
如果要让一个事务前后两次读取的数据保持一致, 那么我们可以在修改数据的之前给它建立一个备份或者快照,后面再来读取这个快照就行了
MVCC的原则:
一个事务能看到的数据版本:
- 第一次查询之前已经提交的事务的修改
- 本事务的修改
一个事务不能看见的数据版本:
- 在本事务第一次查询之后创建的事务(事务ID比我的事务ID大)
- 活跃的(未提交的)事务的修改
MVCC的效果: 我可以査到在我这个事务开始之前已经存在的数据,即使它在后面被修改或者删除了。而在我这个事务之后新增的数据,我是查不到的。
所以我们才把这个叫做快照,不管别的事务做任何增删改查的操作,它只能看到第一次查询时看到的数据版本。
问题:这个快照是怎么实现的呢?会不会占用额外的存储空间?
下面我们来分析一下MVCC的原理。首先,InnoDB的事务都是有编号的,而且会不断递增。
InnoDB为每行记录都实现了两个隐藏字段:
DB_TRX_ID : 6字节:事务ID,数据是在哪个事务插入或者修改为新数据的,就记录为当前事务ID。
DB_ROLL_PTR : 7字节:回滚指针(我们把它理解为删除版本号,数据被删除或记录为旧数据的时候,记录当前事务ID,没有修改或者删除的时候是空)
(1) 第一个事务,初始化数据(检查初始数据)
Transaction 1 |
---|
begin; |
insert into mvcctest values (NULL,‘小明’); |
insert into mvcctest values (NULL,‘老王’); |
commit; |
此时的数据,创建版本是当前事务ID (假设事务编号是1),删除版本为空:
id | name | 创建版本 | 删除版本 |
---|---|---|---|
1 | 小明 | 1 | NULL |
2 | 老王 | 1 | NULL |
(2) 第二个事务,执行第1次查询,读取到两条原始数据,这个时候事务ID是2;
Transaction 2 |
---|
begin; |
select * from mvcctest ; – 第一次查询 |
(3) 第三个事务,插入数据:
Transaction 3 |
---|
begin; |
insert into mvcctest values (NULL,‘老张’); |
commit; |
此时的数据,多了一条’老张‘,它的创建版本号是当前事务编号,3;
id | name | 创建版本 | 删除版本 |
---|---|---|---|
1 | 小明 | 1 | NULL |
2 | 老王 | 1 | NULL |
2 | 老张 | 3 | NULL |
(4) 第二个事务,执行第2次查询:
Transaction 2 |
---|
select * from mvcctest ; – 第二次查询 |
根据MVCC的査找规则:不能查到在我的事务开始之后插入的数据,’老张‘ 的创建ID大于2,所以还是只能査到两条数据。
(5) 第四个事务,删除数据,删除了 id=2 ’老王‘ 这条记录:
Transaction 4 |
---|
begin; |
delete from mvcctest where id = 2; |
commit; |
此时的数据,’老王‘ 的删除版本被记录为当前事务ID 4,其他数据不变:
id | name | 创建版本 | 删除版本 |
---|---|---|---|
1 | 小明 | 1 | NULL |
2 | 老王 | 1 | 4 |
2 | 老张 | 3 | NULL |
(6) 在第二个事务中,执行第3次査询:
Transaction 2 |
---|
select * from mvcctest ; – 第三次查询 |
查找规则:只能查找创建时间小于等于当前事务ID的数据,和删除时间大于当前事务ID的行(或未删除)。也就是在我事务开始之后删除的数据。所以’老王‘依然可以查出来。所以还是这两条数据。
(7) 第五个事务,执行更新操作,这个事务事务ID是5:
Transaction 5 |
---|
begin; |
update mvcctest set name =‘中华第一帅’ where id=1; |
commit; |
此时的数据,更新数据的时候,旧数据的删除版本被记录为当前事务ID 5 (undo 回滚),
产生了一条新数据,创建ID为当前事务ID5:
id | name | 创建版本 | 删除版本 |
---|---|---|---|
1 | 小明 | 1 | 5 |
2 | 老王 | 1 | 4 |
2 | 老张 | 3 | NULL |
1 | 中华第一帅 | 5 | NULL |
(8) 第二个事务,执行第4次查询:
Transaction 2 |
---|
select * from mvcctest ; – 第四次查询 |
查找规则:只能查找创建时间小于等于当前事务ID的数据,和删除时间大于当前事务ID的行(或更新)。
因为更新后的数据 ’中华第一帅‘ 创建版本大于2,代表是在事务之后增加的,查不出来。而旧数据’老王‘的删除版本大于2,代表是在事务之后删除的,可以查出来。
通过以上演示我们能看到,通过版本号的控制,无论其他事务是插入、修改、删除, 第一个事务查询到的数据都没有变化。这个是MVCC的效果。当然,这里是一个简化的模型。
问题一: InnoDB中,一条数据的旧版本,是存放在哪里的呢?
答案是:undo logo 。 因为修改了多次, 这些undo log会形成一个链条,叫做undo log链,所以前面我们说的DB_ROLL_PTR,它其实就是指向undo log链的指针。
问题二: 每个不同时间点的事务,它们去undo log链找数据的时候,拿到的数据是不一样的。在这个undo log链里面,一个事务怎么判断哪个版本的数据是它应该读取的呢?
按照前面说的MVCC的规则,必须根据事务id做一系列比较。所以,我们必须要有一个数据结构,把本事务ID、活跃事务ID、当前系统最大事务ID存起来,这样才能实现判断。这个数据结构就叫Read View (可见性视图),每个事务都维护一个自己的Read View。
m_ids{} | min_trx_id | max_trx_id | creator_trx_id |
---|---|---|---|
列表,当前系统活跃的事务id | m_ids 的最小值 | 系统分配给下一个事务的id | 生成readView事务的事务id |
- m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表
- min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
- creator_trx_id:表示生成该ReadView的事务的事务id。
有了这个数据结构以后,事务判断可见性的规则是这样的:(了解)
- 从数据的最早版本开始判断(undo log)
- 数据版本的trx_id = creator_trx_id,本事务修改,可以访问
- 数据版本的trx_id < min_trx_id (未提交事务的最小ID),说明这个版本在生成ReadView已经提交,可以访问
- 数据版本的tr_ id > max_trx_id (下一个事务ID),这个版本是生成ReadView 之后才开启的事务建立的,不能访问
- 数据版本的trx_id在min_trx_id和max_trx_id之间,看看是否在m_ids中。 如果在,不可以。如果不在,可以。
- 如果当前版本不可见,就找undo log链中的下一个版本。
注意:
RR(可重复读)中Read View是事务第一次査询的时候建立的。RC(读已提交)的Read View是事务每次查询的时候建立的。
Oracle. Postgres等等其他数据库都有MVCC的实现。
需要注意,在InnoDB中,MVCC和锁是协同使用的,这两种方案并不是互斥的。
第一大类解决方案是锁,锁又是怎么实现读一致性的呢?
移步下一篇文章;
Mysql的事务与锁知识(二) 之 Mysql的锁