谈谈数据库事务隔离(一):为什么修改了我看不见?

 

背景

提到数据库事务,首先想到事务的4大特性--ACID

  1. 原子性(Atomicity) 事务的操作要不全部成功 要不全部失败
  2. 一致性(Consistency)  事务操作前后必须处于一致性的状态,例如拿转账来说,A和B两个用户账户共1000元,两个用户间转账前后总额还是1000元。
  3. 隔离性(Isolation) 隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离
  4. 持久性(Durability) 持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的

其中原子性、一致性、持久性这三种特性字面意思比较好理解,所以本次我们重点来说一说事务的隔离性。

 

数据库事务隔离

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。

  1. 脏读(dirty read) 一个事务处理过程里读取了另一个未提交的事务中的数据
  2. 不可重复读(non-repeatable read) 数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了(修改)
  3. 幻读(phantom read) 指一个事务读取到了另外一个事务中提交的insert的数据。

 

 为了解决上面的问题,提出了隔离级别的概念,SQL标准的事务隔离级别包括:

  1. 读未提交(READ-UNCOMMITTED) 一个事务还未提交,它做的变更即被其他事务看到 不常用
  2. 读提交(READ-COMMITTED) 一个事务提交后,它所做的变更才被其他事务看到  常用
  3. 可重复读(REPEATABLE-READ) 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 常用
  4. 串行化(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

 
T6commit; 
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

 
T6commit; 
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

 
T6commit; 
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

 
T4commit; 
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)。对于每行数据,数据库会保存两个隐藏列

  1. DB_TRX_ID :每次对记录进行改动时,都会把对应的事务id赋值给DB_TRX_ID 隐藏列
  2. 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;

T2UPDATE tx_test1 SET nums=2 WHERE id=1; 
T3UPDATE tx_test1 SET nums=3 WHERE id=1; 
T4commit; 
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 里,有两个“视图”的概念:

  1. 数据库视图View,它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view ....,而它的查询方法与表一样。
     
  2. InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

这里我们讨论的是第二个视图,它帮助我们判断在事务执行期间,版本链中的哪个版本是当前事务可见的。

 

READ VIEW 中 包含如下重要内容

1、活跃数组:保存当前正在“活跃”的所有事务 ID(活跃”指的就是,启动了但还没提交)。

2、高低水位:上述活跃数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(这里请注意 是已创建的事务的最大值加1 而不是活跃的事务ID最大值加1)。

这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  1. 如果被访问版本的trx_id属性值小于低水位事务id,表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问。
     
  2. 如果被访问版本的trx_id属性值大于高水位事务id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问。
     
  3. 如果被访问版本的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

T1begin; 
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有所区别,感兴趣可以对比一下)

 

 

T5commit; 
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表没有关系

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值