关于隔离性

关于隔离性

概述

ACID是关系型数据库的重要特性,其中I表示Isolation隔离性。隔离性和操作系统的虚拟内存一样,是一种抽象,让并行执行的事务相互之间不会干扰,看起来就像一次只执行一个事务一样。也就是所谓的可串行化(Serializable),即多个并行的事务,在提交时其结果与串行执行完全相同。

隔离级别的一个核心问题是:一个事务的执行过程和结果是否会影响到其他正在执行的事务。可串行化是最高级别的隔离,即事务之前互不影响。除此之外,为了并发性考虑还有几种弱一点的隔离级别,按照隔离度从低到高依次是:未提交读(read uncommitted)、提交读(read committed)、可重复度(repeatable read)以及可串行化(Serializable)。下面我们就来分别说说这四种隔离级别。

Read Uncommitted

未提交读,是最低级别的隔离,其实就是没隔离。在这个隔离级别下,会出现脏读脏写。那么这两种隔离级别会有什么问题呢?

关于脏读,我们以银行转账为例,从A账户向B账户转入100W。在A账户转出100W之后,B账户转入100W之前,你发起的一个查询,想看看转账是否完成。由于数据库可以读出未提交的数据,于是你发现A账户扣了钱,但B账户钱没有转进去,于是你慌得一批。

关于脏写,我们同样以钱为例。你的银行账户原本有1000元,现在你向账户中存入100W,在存钱之前正好赶上发工资,公司向你的银行账户里面存入1W元,那么账户的总金额为101W零1000元。但是公司在存入工资的时候,系统发生了故障,导致发工资发生了回滚。由于是脏写,所以账户的金额会被回滚为初始值1000,于是你慌得一批。

不难看出,Read Uncommitted会导致一个事务的中间过程影响其他正在执行的事务。Read Uncommitted其实没什么卵用,它的存在是由于早期一些数据库并不支持MVCC,这些数据库为了支持Read Committed隔离级别,不得不采用读写锁的方式,即读操作加共享锁,写操作加互斥锁。这极大的降低了数据库的并发性,所以才提供了Read Uncommitted级别来保证高并发。

著名的Oracle大神Thomas Kyte说过:Read Uncommitted不应该是一种数据库特性,而应该是数据库的BUG。任何情况都应该避免Read Uncommitted的发生。

Read Committed

不可重读

为了解决脏读和脏写的问题,就有了Read Committed隔离级别。即事务的相关操作在未提交之前是不可见的。对于上面转账的例子,在转账完成之前,如果查询A,B账户的钱,会发现两个账户的钱都没有发生变化。同时在Read Committed隔离级别下,对于同一条元组的修改时互斥的,也就是说你存钱或取钱完成之前,公司给你的工资是存不进来的(所以存钱取钱请快点,不要让后面的人等太久)。

Read Committed是应用最广泛的隔离级别。采用MVCC技术后,该级别拥有非常高效的并发性(读写操作可以并发执行),同时Read Committed下绝大部分的串行化异常都可以通过其他技术手段来解决。Read Committed存在的一个问题就是不可重读。假设有T1、T2两个事务,事务执行顺序如下所示(执行顺序:从左至右,从上至下)。

T1T2
begin;begin;
SELECT a FROM TEST;UPDATE TEST SET a = a + 20;
commit;
SELECT a FROM TEST;
commit;

T1事务首先查询TEST表字段a的值,在T1事务执行第二次查询之前,T2事务将a的值加上了20并提交。由于隔离级别为提交读所以T1可以看见T2的修改,所以第二次查询出的a和第一次不一样。即在同一个的事务中对同一列的两次查询值不相同。

上述用例是解释不可重读最简单和直观的用例,但是没什么实际意义,我为什么要在一个事务中执行两次完全相同的查询呢?所以,我们来看一个更为贴切的例子,事务执行顺序如下所示(执行顺序:从左至右,从上至下)。

班主任数学老师
begin;
SELECT COUNT(*) FROM student WHERE score < 60;
begin;
UPDATE student SET score = 75 where name = ‘小明’;
commit;
SELECT COUNT(*) FROM student WHERE score >= 60 and score < 80;
SELECT COUNT(*) FROM student WHERE score >= 80 and score <= 100;
commit;

班主任正在统计学生的数学成绩,看看班上成绩<60的有多少人,60~80的多少人,80~100的多少人…于是她发起了上述查询,当统计完小于60分的人数后,他起身去喝水。这个时候,小明发现他的成绩算错了,原本应该是75分但算成了55分,于是他找到了数学老师,数学老师核对分数之后,将小明的成绩改为了75分。这个时候班主任喝完水回到了电脑前,继续统计60~80分的人数和80~100分的人数,然后他就惊奇的发现,三个分数段的人相加的总人数比全班人数还多一个。这是因为小明的分数修改之后就进入了60~80分段,所以被多统计了一次。

幻读

幻读是不可重读的另外一种形式,这两者的表现也非常相似,下面我们就来看看幻读。

T1T2
begin;begin;
SELECT a FROM TEST;INSERT INTO TEST VALEUS(1);
commit;
SELECT a FROM TEST;
commit;

对于上面的T1和T2事务,T1在执行完第一次查询后,T2向TEST表中插入了一条元组并提交。由于隔离级别为提交读,所以T1的第二次查询就会发现TEST表中多了一条记录。所以其实幻读和不可重读的表现都是同一个事务的多次查询结果不一样。

那么幻读又会有什么问题呢?

HR1HR2
begin;begin;
SELECT * FROM staff where age > 35;INSERT INTO staff VALEUS(‘Thomas Kyte’,50);
commit;
DELETE FROM staff where age > 35;
commit;

假设你是公司的HR,你想看看公司35岁以上人员的情况,于是执行了一个查询。然后发现当前35岁以上的全是刚被劝退的程序员,于是你准备把他们的信息删除。然而就在你准备删除之前,公司高薪聘请的Oracle专家Thomas Kyte由另外一位HR办理了入职,由于入职办理的手续已经提交,所以Thomas Kyte的信息对于你的删除来说是可见的,最终一位伟大的数据库专家就被你删除了。

Repeatable Read

Repeatable Read隔离级别可以解决不可重读和幻读的问题。在这里需要说明一下教科书上的隔离级别和工程实践上的区别。在很多数据库教材里面,习惯把隔离级别当成技术手段来解释,并称Repeatable Read隔离级别用于解决不可重读,Serializable隔离级别用于解决幻读。而工程实现上,隔离级别只是各个数据库的功能。这个功能有很多种实现方式,不同的实现方式会导致不同的结果,这也使得不同数据库相同隔离级别有不一样的效果(比如:Oracle的Serializable的效果实际上和PostgreSQL的Repeatable Read相同,因为背后采用的技术是一样的)。

所以,相比隔离级别,更重要的是隔离级别背后的技术,Oracle、MySQL、PostgreSQL都采用了一种称为快照隔离的技术,快照隔离是MVCC的一种实现方式。通过快照隔离可以有效防止脏读、不可重读、幻读。所以,我总是更愿意把幻读当做不可重读的另外一种形式,这两者有着非常相似的表现形式,以及相同的解决方案(快照隔离)。

基于快照隔离,MySQL、PostgreSQL实现了Repeatable Read,Oracle实现了Serializable。

Serializable

在很长的一段时间里,我都有这样一个疑问:PostgreSQL和MySQL的Repeatable Read就可以解决幻读,那么为什么还要支持Serializable,他们的Serializable是为了解决什么问题?

脏读、不可重读、幻读是最为人熟知的三种串行化异常,因为这是SQL92标准定义的三种串行化异常,但是不是解决了这三个问题就意味着数据库完全支持可串行化呢?不是!我们再回顾一下可串行化的定义:并行执行的事务相互之间不会干扰,看起来就像串行执行一样。其实除了脏读、不可重读、幻读之外还有一些其他的串行化异常,下面我们一起来看看。

更新丢失

现在test表中有一个值value,现在应用程序需要对value值做递增,假设我们采用如下流程:

-- x为程序变量
begin;
x = select value from test;
x += 1;
update test set value = x;
commit;

这个事务如果串行执行,不会有任何问题,但在并行执行时会出现更新丢失。假设x的当前值为10,当两个程序并行时,他们可能同时获取value的值10,然后将x递增1变成11,最后将value的值更新为11,于是明明执行了两次递增操作,但实际上value的值只递增了1,这就是更新丢失。当然这个用例看起来很扯淡,因为不会有人这么去递增操作。但是站在数据库的角度来说,面对这样的设计,并行执行产生的结果与串行执行可能截然不同。

PostgreSQL更新丢失

其实PostgreSQL的Repeatable Read可以检测出上面的问题。PostgreSQL在Repeatable Read隔离级别下,如果发现当前准备更新的元组被其他事务更新过,那么会直接报错:ERROR: could not serialize access due to concurrent update。而MySQL的Repeatable Read在上述场景下会发生更新丢失。

Write Skew

第二个串行化异常被称为Write Skew写偏斜,这里可以用一个场景来说明:医院有一个值班系统来管理医生值班的情况。医生可以请假,但前提是确保至少有一位医生在值班

假设,我们当前有两名医生在值班:

nameon_call
Jill1
Ada1

现在,Jill希望请假,于是正常的流程如下:

begin;
x <- SELECT COUNT(*) FROM doctors WHERE on_call = 1; 

IF x >= 2 THEN 
UPDATE doctors SET on_call = 0 WHERE name = 'Jill';

COMMIT;

如果Jill和Ada先后请假,那么在Ada请假时,她就会发现当前只有她一个医生在值班,所以不能请假。如果Jill和Ada同时请假呢,那么由于快照隔离SELECT操作无法感知COUNT的变化(因为在他们发起请假流程前,对方的请假还未提交)。所以最终就会造成两个人都以为对方在岗,从而导致无人值班的情况。

Write Skew和前面的更新丢失有一个非常重要的区别,前面更新丢失的场景多个事务并行修改同一个数据,在这种情况下还可以通过判断当前数据有没有被别人改过来检测更新丢失。而Write Skew场景是首先查询了两条元组,然后两个事务分别修改其中的一条(查询一个整体,然后多个事务分别修改这个整体中的不同部分)。所以,对于Write Skew,快照隔离已经无能为力。

只读事务偏斜

在《Serializable Snapshot Isolation in PostgreSQL》论文中还介绍了一种只读事务偏斜。具体场景如下,我们有两张表:

  • receipts表

    用于记录每天的receipt,每条receipt都关联一个batch number。

  • control表

    用于保存当前的batch number。

我们规定三种操作:

  • NEW-RECEIPT:从control表中读取 batch number,将其与一条新的receipt关联,然后将这条receipt插入receipts表。
  • CLOSE-BATCH:递增control表的 batch number。
  • REPORT:从control表中读取 batch number,然后从receipts表中读取前一个batch number(batch number - 1)关联的所有receipt(比如:用来显示前一天的所有receipt)。

现在这三种操作的执行顺序如下(执行顺序:从左至右,从上至下):

REPORTNEW-RECEIPTCLOSE-BATCH
begin
x <- SELECT current_batch
begin
INCREMENT current_batch
commit
begin
x <- SELECT current_batch
SELECT SUM(amount) FROM receipts where batch = x -1
commit
INSERT INTO receipts VALUES(x, somedata)
commit

假设当前current_batch值为10,NEW-RECEIPT首先开启了事务查询到current_batch的值为10,然后CLOSE-BATCH开启了事务,将current_batch的值改为11,并提交。接着REPORT开启了事务,查询current_batch,由于CLOSE-BATCH在REPORT开启之前已经提交了,所以 CLOSE-BATCH对current_batch的修改对于REPORT是可见的,所以查询得到current_batch值为11。接着REPORT在receipts表中查询batch = x -1即batch = 10的所有amount的总和,并提交。最后NEW-RECEIPT向receipts中插入了一条batch为10的新receipt,并提交,而显然REPORT在统计SUM的时候并没有将这条新的receipt计算在内。

显然,REPORT、NEW-RECEIPT、CLOSE-BATCH无论以怎样的先后顺序串行执行,都不会发生上述情况。并且,有意思的是REPORT、NEW-RECEIPT、CLOSE-BATCH这三个操作任意去掉一个操作,剩余的两个都是可以串行化的。由此可见,串行化异常并不是那么容易察觉的。

可串行化实现

要达到真正的可串行化,原始快照隔离已经搞不定了。可串行化的实现主要有两种手段:

  • 两阶段锁(2 phase locking,2PL)
  • 可串行化快照隔离(Serializable Snapshot Ioslation,SSI)

两阶段锁是传统的基于锁的方案,读操作加共享锁,写操作加互斥锁,这是一种悲观的设计思想,会影响并发性,也会提升死锁的概率。较新的技术是可串行化快照隔离,在事务执行的时候采用快照隔离,在事务提交时检测是否发生串行化异常,如果发生则回滚事务。这是一种乐观方式,对并发性影响不大,但是如果回滚的事务是一个长事务,那么回滚的代价会比较大。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值