背景
提到数据库事务,首先想到事务的4大特性--ACID
- 原子性(Atomicity) 事务的操作要不全部成功 要不全部失败
- 一致性(Consistency) 事务操作前后必须处于一致性的状态,例如拿转账来说,A和B两个用户账户共1000元,两个用户间转账前后总额还是1000元。
- 隔离性(Isolation) 隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离
- 持久性(Durability) 持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的
其中原子性、一致性、持久性这三种特性字面意思比较好理解,所以本次我们重点来说一说事务的隔离性。
数据库事务隔离
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。
- 脏读(dirty read) 一个事务处理过程里读取了另一个未提交的事务中的数据
- 不可重复读(non-repeatable read) 数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了(修改)
- 幻读(phantom read) 指一个事务读取到了另外一个事务中提交的insert的数据。
为了解决上面的问题,提出了隔离级别的概念,SQL标准的事务隔离级别包括:
- 读未提交(READ-UNCOMMITTED) 一个事务还未提交,它做的变更即被其他事务看到 不常用
- 读提交(READ-COMMITTED) 一个事务提交后,它所做的变更才被其他事务看到 常用
- 可重复读(REPEATABLE-READ) 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 常用
- 串行化(SERIALIZABLE) 顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 不常用
隔离级别 | 脏读(dirty read) | 不可重复读(non-repeatable read) | 幻读(phantom read) |
---|---|---|---|
串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
可重复读(REPEATABLE-READ) | 不可能 | 不可能 | 可能 |
读提交(READ-COMMITTED) | 不可能 | 可能 | 可能 |
读未提交(READ-UNCOMMITTED) | 可能 | 可能 | 可能 |
级别越高,数据越安全,但性能越低。常用的隔离级别包括读提交和可重复读,读未提交和串行化一般用的很少。
tips 网上很多技术博文谈到事务隔离,经常使用RU、RC、RR这样的简写分别代表读未提交、读提交、可重复读
MySQL事务隔离示例
(注:如下所有说明均是基于MySQL 5.6 InnoDB引擎)
知识准备
MySQL中实现了SQL标准的事务隔离,MySQL默认的事务隔离级别是可重复读(每个数据库的默认事务隔离级别不同 Oracle的默认事务隔离级别是读提交)
在MySQL中查看事务隔离级别可以通过如下命令查看
show variables like 'tx_isolation';
事务隔离实战
- 建立测试表,插入初始化数据
DROP TABLE IF EXISTS tx_test1;
CREATE TABLE tx_test1 (
id INT NOT NULL PRIMARY KEY,
nums INT NOT NULL DEFAULT 0
) ENGINE=INNODB DEFAULT CHARSET=utf8;
INSERT INTO tx_test1(id,nums) VALUES(1,1);
- 四种隔离级别测试
下面会通过MySQL中的实际案例来理解事务隔离下数据的可见性
可重复读
1、初始化数据
2、查看当前MySQL的事务隔离级别和事务启动方式,如上文提到的命令查看,查看的结果 1、可重复读级别 2、autocommit=1 自动提交事务方式
3、
时刻Time | 事务A | 事务B |
---|---|---|
T1 | 启动事务,查询得到值为1 | 启动事务,查询得到值为1 |
T2 | 将值从1修改为2 | |
T3 | 查询表数据 value值为1 | |
T4 | commit; | |
T5 | 查询表数据 value值为1 | |
T6 | commit; | |
T7 | 查询表数据 value值为2 |
在可重复读事务隔离级别中,事务在启动后第一次读select会构建快照视图(readview),之后在此事务中读数据均是读的次快照视图,保证读取到的数据是一致的。
读提交
1、初始化数据
2、修改会话的事务级别由可重复读修改为读提交
#查看当前的事务隔离级别
show variables like 'tx_isolation';
#修改事务隔离级别为读提交
set tx_isolation='READ-COMMITTED';
#验证是否修改成功
show variables like 'tx_isolation';
.
3、
时刻Time | 事务A | 事务B |
---|---|---|
T1 | 启动事务,查询得到值为1 | 启动事务,查询得到值为1 |
T2 | 将值从1修改为2 | |
T3 | 查询表数据 value值为1 | |
T4 | commit; | |
T5 | 查询表数据 value值为2 | |
T6 | commit; | |
T7 | 查询表数据 value值为2
|
在读提交隔离级别中,在事务中可以读取到其他事务已经提交的结果
读未提交
同样的,初始化数据,并按照如上将隔离级别调整为读未提交
#查看当前的事务隔离级别
show variables like 'tx_isolation';
#修改事务隔离级别为读未提交
set tx_isolation='READ-UNCOMMITTED';
#验证是否修改成功
show variables like 'tx_isolation';
时刻Time | 事务A | 事务B |
---|---|---|
T1 | 启动事务,查询得到值为1 | 启动事务,查询得到值为1 |
T2 | 将值从1修改为2 | |
T3 | 查询表数据 value值为2 | |
T4 | commit; | |
T5 | 查询表数据 value值为2 | |
T6 | commit; | |
T7 | 查询表数据 value值为2 |
串行化
同样的,初始化数据,按照如上将隔离级别调整为串行化
#查看当前的事务隔离级别
show variables like 'tx_isolation';
#修改事务隔离级别为串行化
set tx_isolation='SERIALIZABLE';
#验证是否修改成功
show variables like 'tx_isolation';
时刻Time | 事务A | 事务B |
---|---|---|
T1 | 启动事务,查询得到值为1 | 启动事务,查询得到值为1 |
T2 | 将值从1修改为2 进入等待状态 直到事务A提交 | |
T3 | 查询表数据 value值为1 | |
T4 | commit; | |
T5 | update成功 | |
T6 | 查询表数据 value值为1 | |
T7 | commit; | |
T8 | 查询表数据 value值为2 |
总结
总结一下各种事务隔离级别下的表现
时刻Time | 事务A | 事务B |
---|---|---|
T1 | 启动事务 查询得到值1 | 启动事务 |
T2 | 查询得到值1 | |
T3 | 将1改成2 | |
T4 | 查询得到值V1 | |
T5 | 提交事务B | |
T6 | 查询得到值V2 | |
T7 | 提交事务A | |
T8 | 查询得到值V3 |
1、若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。
因此,V2、V3 也都是 2。
2、若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
3、若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
4、若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。
MVCC(Multi-Version Concurrency Control)
MVCC(Multi-Version Concurrency Control),即为数据库多版本并发控制。
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read), 本篇文章主要讨论快照读,当前读会在下一篇文章结合锁机制讨论
快照读:普通的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
- 如上语句均为当前读
MVCC不只使用在MySQL中,Oracle、PostgreSQL,以及其他一些数据库系统也同样使用它。各数据库的实现方式有所不同,这里以MySQL InnoDB为例。
版本链
MySQL InnoDB 是一个多版本存储的引擎,它会保存数据库行更新的历史版本信息(it keeps information about old versions of changed rows)。对于每行数据,数据库会保存两个隐藏列
- DB_TRX_ID :每次对记录进行改动时,都会把对应的事务id赋值给DB_TRX_ID 隐藏列
- DB_ROLL_PTR :每次对记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息
以上述tx_test1表为例,假设插入记录的事务id为80,之后两个事务id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下
时刻Time | 事务A(假设TRX_ID=100) | 事务B(假设TRX_ID=200) |
---|---|---|
T1 | begin; select * from tx_test1; | begin; select * from tx_test1; |
T2 | UPDATE tx_test1 SET nums=2 WHERE id=1; | |
T3 | UPDATE tx_test1 SET nums=3 WHERE id=1; | |
T4 | commit; | |
T5 | UPDATE tx_test1 SET nums=4 WHERE id=1 and nums=3; (这里加了多加了一个限制条件 nums=3,如果限制条件是nums=1 则无法更新到数据。 这是后面文章中要讲到的update是当前读 这里铺垫一下)
| |
T6 | commit; |
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个DB_ROLL_PTR 属性(INSERT对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表
对该记录每次更新后,都会将旧值放到一条undo日志
中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被DB_ROLL_PTR 属性连接成一个链表,我们把这个链表称之为版本链
,版本链的头节点就是当前记录最新的值。
READ VIEW
在 MySQL 里,有两个“视图”的概念:
- 数据库视图View,它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view ....,而它的查询方法与表一样。
- InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
这里我们讨论的是第二个视图,它帮助我们判断在事务执行期间,版本链中的哪个版本是当前事务可见的。
READ VIEW 中 包含如下重要内容
1、活跃数组:保存当前正在“活跃”的所有事务 ID(活跃”指的就是,启动了但还没提交)。
2、高低水位:上述活跃数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(这里请注意 是已创建的事务的最大值加1 而不是活跃的事务ID最大值加1)。
这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
- 如果被访问版本的trx_id属性值小于低水位事务id,表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值大于高水位事务id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id属性值在高水位事务id和低水位事务id之间,那就需要判断一下trx_id属性值是否在活跃事务数组内
(1)如果存在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
(2)如果不存在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。
总结一下:
InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。
而当前读,总是读取已经提交完成的最新版本(update语句为当前读)
幻读
MySQL Innodb 在可重复读级别中,普通的查询使用了快照读的方式避免了幻读
在当前读中,使用了gap间隙锁避免了幻读
在mysql官方文档中,也声明可重复读是可以避免幻读的。
但是如下这种情况出现了所谓的幻觉,欢迎大家一起留言讨论一下是否属于幻读
1、初始化数据
2、保证事务隔离级别为快照读
3、普通的读没有问题,看不到其他事务插入的数据,如下当前读看到了其他事务插入的数据 出现了所谓的幻读
时刻Time | 事务A | 事务B |
---|---|---|
begin; select * from tx_test1;
| ||
insert into tx_test1(id,nums) values(2,1); | ||
|
FAQ
1、执行Begin语句后是否就正式开始了事务?
使用Begin语句 代表即将开始一个事务 但是事务ID还未生成,必须begin后面执行的第一个select、update、delete、insert语句才会真正生成事务ID
时刻Time | 事务A | 事务B |
---|---|---|
T1 | begin; | |
T2 | select * from information_schema.INNODB_TRX; 查看事务,无事务
| |
T3 | select * from tusiji_test1; (执行select、update、delete、insert任意语句 均可以,上述以select为例) | |
T4 | select * from information_schema.INNODB_TRX; 查询到有事务ID生成了 证明了上述结论 (这里mysql5.7在select的情况下和5.6有所区别,感兴趣可以对比一下)
| |
T5 | commit; | |
T6 |
2、可重复读级别中,事务A在执行begin语句后进行insert操作语句,事务B进行插入并提交,事务A是否可以看到事务B中插入的数据?
可以,因为生成一致性视图ReadView是在第一个select语句执行后,所以事务A在begin后进行insert语句还未生成一致性视图,此时事务B插入语句提交,事务A此时查询是可以看到数据的。
时刻Time | 事务A | 事务B |
---|---|---|
T1 | begin; insert into tx_test1(id,nums) values(1,1); | |
insert into tx_test1(id,nums) values(2,2); | ||
select * from tx_test1;
| ||
insert into tx_test1(id,nums) values(3,3); | ||
3、可重复读级别下,如果事务A在执行begin命令后查询t1表,然后事务B插入t2表,那么此时事务A查询t2表是否能查到事务B插入的数据?
无法查到,因为事务B中插入记录的事务ID在事务A生成一致性视图时还未生成,所以事务A无法看到此数据,和事务A一开始查询的是否为t2表没有关系