数据库事务隔离级别原理

什么是事务

单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。简单的说,事务就是并发控制的单位,是用户定义的一个操作序列。

事务的ACID

原子性(Atomicity)

原子性是指事务是一个不可再分割的工作单元,事务中的操作要么都发生,要么都不发生。可采用“A向B转账”这个例子来说明解释在DBMS中,默认情况下一条SQL就是一个单独事务,事务是自动提交的。只有显式的使用start transaction开启一个事务,才能将一个代码块放在事务中执行。

一致性(Consistent)

一致性是指在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。这是说数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。

如A给B转账,不论转账的事务操作是否成功,其两者的存款总额不变(这是业务逻辑的一致性,至于数据库关系约束的完整性就更好理解了)。

保障机制(也从两方面着手):数据库层面会在一个事务执行之前和之后,数据会符合你设置的约束(唯一约束,外键约束,check约束等)和触发器设置;此外,数据库的内部数据结构(如 B 树索引或双向链表)都必须是正确的。业务的一致性一般由开发人员进行保证,亦可转移至数据库层面。

隔离性 (Isolation)

多个事务并发访问时,事务之间是隔离的,一个事务不应该影响其它事务运行效果。

在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。

事务最复杂问题都是由事务隔离性引起的。完全的隔离性是不现实的,完全的隔离性要求数据库同一时间只执行一条事务(串行化),这样会严重影响性能。

持久性(Durability)

意味着在事务完成以后,该事务所对数据库所作的更改便持久的保存在数据库之中,并不会被回滚。(完成的事务是系统永久的部分,对系统的影响是永久性的,该修改即使出现致命的系统故障也将一直保持)。

事务的隔离级别

Read uncommitted(读未提交)

一个事务可以读取另一个未提交事务的数据
事例:老板要给程序员发工资,程序员的工资是3.6万/月。但是发工资时老板不小心按错了数字,按成3.9万/月,该钱已经打到程序员的户口,但是事务还没有提交,就在这时,程序员去查看自己这个月的工资,发现比往常多了3千元,以为涨工资了非常高兴。但是老板及时发现了不对,马上回滚差点就提交了的事务,将数字改成3.6万再提交。

分析:实际程序员这个月的工资还是3.6万,但是程序员看到的是3.9万。他看到的是老板还没提交事务时的数据。这就是脏读。

那怎么解决脏读呢?Read committed-读提交,能解决脏读问题。

Read committed (读已提交)

一个事务要等另一个事务提交后才能读取数据。

事例:程序员拿着信用卡去享受生活(卡里当然是只有3.6万),当他埋单时(程序员事务开启),收费系统事先检测到他的卡里有3.6万,就在这个时候!!程序员的妻子要把钱全部转出充当家用,并提交。当收费系统准备扣款时,再检测卡里的金额,发现已经没钱了(第二次检测金额当然要等待妻子转出金额事务提交完)。程序员就会很郁闷,明明卡里是有钱的。

分析:这就是读提交,若有事务对数据进行更新(UPDATE)操作时,读操作事务要等待这个更新操作事务提交后才能读取数据,可以解决脏读问题。但在这个事例中,出现了一个事务范围内两个相同的查询却返回了不同数据,这就是不可重复读。

Repeatable read (可重复读)

重复读,就是在开始读取数据(事务开启)时,不再允许修改操作。

事例:程序员拿着信用卡去享受生活(卡里当然是只有3.6万),当他埋单时(事务开启,不允许其他事务的UPDATE修改操作),收费系统事先检测到他的卡里有3.6万。这个时候他的妻子不能转出金额了。接下来收费系统就可以扣款了。

分析:重复读可以解决不可重复读问题。写到这里,应该明白的一点就是,不可重复读对应的是修改,即UPDATE和DELETE操作当前数据项。但是可能还会有幻读问题。因为幻读问题对应的是插入INSERT操作,而不是UPDATE和DELETE操作当前数据项。

什么时候会出现幻读?

事例:程序员某一天去消费,花了2千元,然后他的妻子去查看他今天的消费记录(全表扫描FTS,妻子事务开启),看到确实是花了2千元,就在这个时候,程序员花了1万买了一部电脑,即新增INSERT了一条消费记录,并提交。当妻子打印程序员的消费记录清单时(妻子事务提交),发现花了1.2万元,似乎出现了幻觉,这就是幻读。

那怎么解决幻读问题?Serializable!

Serializable 序列化

Serializable 是最高的事务隔离级别,在该级别下,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。事务串行化顺序执行,可以避免脏读、不可重复读与幻读。但是这种事务隔离级别效率低下,比较耗数据库性能,一般不使用。

大多数数据库默认的事务隔离级别是Read committed,比如Sql Server , Oracle。Mysql的默认隔离级别是Repeatable read。
在库存系统中,如果数据库是用Oracle,不能用一个数据记录代表一个库存,因为存在不可重复读,会因为其他事务delete或insert库存记录,导致库存不准确,可以是提高隔离级别为Repeatable read或使用Mysql数据库。

MySQL隔离级别的实现

事务的机制是通过视图(read-view)来实现的并发版本控制(MVCC),不同的事务隔离级别创建读视图的时间点不同。

  • 可重复读是每个事务重建读视图,整个事务存在期间都用这个视图。
  • 读已提交是每条 SQL 创建读视图,在每个 SQL 语句开始执行的时候创建的。隔离作用域仅限该条 SQL 语句。
  • 读未提交是不创建,直接返回记录上的最新值
  • 串行化隔离级别下直接用加锁的方式来避免并行访问。
    这里的视图可以理解为数据副本,每次创建视图时,将当前已持久化的数据创建副本,后续直接从副本读取,从而达到数据隔离效果。

undo log

数据表其实有一些隐藏的属性,比如每一行的事务id,所以每一行数据可能会有多个版本,每一个修改过它的事务都会有一个事务id,并且还会有关联的 undo log,表示这个操作原来的数据是什么,可以用它做回滚。

  • undo log 中存储的是老版本数据。假设修改表中 id=2 的行数据,把 Name=‘B’ 修改为 Name = ‘B2’ ,那么 undo 日志就会用来存放 Name=‘B’ 的记录,如果这个修改出现异常,可以使用 undo 日志来实现回滚操作,保证事务的一致性。
  • 当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着 undo 链找到满足其可见性的记录。当版本链很长时,通常可以认为这是个比较耗时的操作。
  • 另外,在回滚段中的 undo log 分为: insert undo log 和 update undo log:
  • 1、insert undo log : 事务对 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后就可以立即丢弃。(谁会对刚插入的数据有可见性需求呢!!)
  • 2、update undo log : 事务对记录进行 delete 和 update 操作时产生的 undo log。不仅在事务回滚时需要,一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被 purge 线程删除。

何时删除?
在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

就是当系统里没有比这个回滚日志更早的 read-view 的时候。

长事务

长事务意味着系统里面会存在很老的事务视图。
危害:
1、由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的 undo log 都必须保留,这就会导致大量占用存储空间。
2、长事务还占用锁资源,也可能拖垮整个库。

InnoDB可重复读

在RC(Read Committed)和RR(Repeatable Read)两种事务隔离级别下,InnoDB存在两种数据读取方式:

快照读(Snapshot Read)

在InnoDB引擎下是基于undo log中保存的快照数据。
假设有这样一个表:

1
2
3
4
5
6
7
8
9
10
11
12
-- 表结构
CREATE TABLE `innodb_test` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '0',
`age` INT(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
INDEX `idx_age` (`age`)
)
ENGINE=InnoDB;

-- 初始数据
INSERT INTO `innodb_test` (`id`, `name`, `age`) VALUES (1, '貂蝉', 100),(2, '庄周', 120),(3, '项羽', 130);
  • id=1的初始数据行

  • 事务A执行如下语句

    1
    UPDATE innodb_test SET name='嬴政', age=90 WHERE id=1;

    此时innodb会做如下操作:

1、把该行修改前的值Copy到undo log(Copy on write);
2、修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行。

  • 事务B执行如下语句
    1
    UPDATE innodb_test SET name='甄姬', age=91 WHERE id=1;
    此时undo log中有2条记录,它们通过回滚指针相连。

undo log的存在解决了两个问题,一是数据回滚,二是实现了MVCC (Multi-Version Concurrency Control) ,快照读读取的就是undo log中的数据,所以这种读取是不需要加锁的,避免了读写冲突。常见的快照读语句就是最常见的SELECT,比如:

1
2
SELECT * FROM innodb_test WHERE id=1;

快照读在RC和RR隔离级别下的表现却是不一样的,为了方便说明,现在将数据还原到初始数据,然后按照下表的顺序操作。

  • RC
    输出的是最新提交的结果(1-嬴政-90),RC级别的快照读遵循以下规则:
  1. 优先读取当前事务修改的数据,自己修改的,当然可以读到了;
  2. 其次读取最新已提交数据。
    会出现前后读取结果不一样的情况,但读取的是最新数据。
  • RR
    输出结果和第一次查询是一样的(1-貂蝉-100),RR级别的快照读遵循以下规则:
  1. 优先读取当前事务修改的数据,和RC一样;
  2. 其次读取小于当前事务id的最新一条已提交数据,此时数据版本已经确定了,后面的快照读取始终读取这个版本。

通过这样的机制,保证了快照读的可重复读,但读取到的数据很可能已经过期了。

当前读(Current Read)

当前读,读取的是最新已提交数据,并且都会加行锁,如下语句都会产生当前读:

1
2
3
4
5
SELECT balabala LOCK IN SHARE MODE;
SELECT balabala FOR UPDATE;
INSERT balabala;
UPDATE balabala;
DELETE balabala;

当前读需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,第一条语句,对读取记录加S锁(共享锁),其他的操作,都加的是X锁(排它锁)。当前读在RC和RR隔离级别下的表现也是不一样的,为了方便说明,现在将数据还原到初始数据,然后按照下表的顺序操作。

  • RC
    成功执行,但会造成事务1的幻读,前后两次读取结果不一样。
  • RR
    会锁等待,在RR隔离级别下,事务1的sql不仅会对该记录加X锁,还会对上下两个数据间隙加间隙锁,以此确保在数据读取期间,其它事物不会在该间隙内增加数据,从而保证可重复读。

小结

RR隔离级别下,快照读通过undo log来保证可重复读,当前读通过X(S)锁+GAP锁来保证可重复读。
RR隔离级别下,假设有两个事务A和B共同对同一个数据项进行update,那么只有其中一个事务执行commit操作,另外一个事务才能进行update,否则另外一个事务将一直阻塞,等待释放锁。这是因为select不加锁,update时才对数据项加行锁。另外,第一个执行update的事务将以undo log中的值为准,而后执行update的事务将以最新事务commit的值为准,即数据库中的最新值。select的值为当前事务最新修改的值。

读未提交(READ_UNCOMMITED)

原理

    1、事务对当前被读取的数据不加锁。
    2、事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。

现象

    事务1读取某行记录时,事务2也能对这行记录进行读取、更新(因为事务1并未对数据增加任何锁)
当事务2对该记录进行更新时,事务1再次读取该记录,能读到事务2对该记录的修改版本(因为事务2只增加了共享读锁,事务1可以再增加共享读锁读取数据),即使该修改尚未被提交。
    事务1更新某行记录时,事务2不能对这行记录做更新,直到事务1结束。(因为事务1对数据增加了共享读锁,事务2不能增加排他写锁进行数据的修改)。

读已提交(READ_COMMITED)

原理

    1、事务对当前被读取的数据加行级共享锁(当读到时才加锁),一旦读完该行,立即释放该行级共享锁。
    2、事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。

现象

    事务1在读取某行记录的整个过程中,事务2都可以对该行记录进行读取(因为事务1对该行记录增加行级共享锁的情况下,事务2同样可以对该数据增加共享锁来读数据)。

    事务1读取某行的一瞬间,事务2不能修改该行数据,但是,只要事务1读取完修改行数据,事务2就可以对该行数据进行修改。(事务1在读取的一瞬间会对数据增加共享锁,任何其他事务都不能对该行数据增加排他锁。但事务1只要读完该行数据,就会释放行级共享锁,一旦锁释放,事务2就可以对数据增加排他锁并修改数据)。

    事务1更新某行记录时,事务2不能对这行记录做更新,直到事务1结束。(事务1在更新数据的时候,会对该行数据增加排他锁,知道事务结束才会释放锁,所以在事务2没有提交之前,事务1都不能对数据增加共享锁进行数据的读取。所以提交读可以解决脏读的现象)。

可重复读(REPEATABLE_READ)

原理

    1、事务在读取某数据的瞬间(就是开始读取的瞬间),必须先对其加行级共享锁,直到事务结束才释放。
    2、事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。

现象

    事务1在读取某行记录的整个过程中,事务2都可以对该行记录进行读取(因为事务1对该行记录增加行级共享锁的情况下,事务2同样可以对该数据增加共享锁来读数据)。

    事务1在读取某行记录的整个过程中,事务2都不能修改该行数据(事务1在读取的整个过程会对数据增加共享锁,直到事务提交才会释放锁,所以整个过程中,任何其他事务都不能对该行数据增加排他锁。所以,可重复读能够解决不可重复读的读现象)。

    事务1更新某行记录时,事务2不能对这行记录做更新,直到事务1结束。(事务1在更新数据时,会对该行数据增加排他锁,直到事务结束才会释放锁,所以,在事务2没有提交之前,事务1都不能对数据增加共享锁进行数据的读取。所以提交读可以解决脏读的现象)。

可串行化(SERIALIZABLE)

原理

    1、事务在读取数据时,必须先对其加表级共享锁,直到事务结束才释放。
    2、事务在更新数据时,必须先对其加表级排他锁,直到事务结束才释放。

现象

    事务1正在读取A表中的记录时,则事务2也能读取A表,但不能对A表做更新、新增、删除,直到事务1结束。(因为事务1对表增加了表级共享锁,其他事务只能增加共享锁读取数据,不能进行其他任何操作)。

    事务1正在更新A表中的记录时,则事务2不能读取A表的任何记录,更不可能对A表做更新、新增、删除,直到事务1结束。(事务1 对表增加了表级排他锁,其他事务不能对表增加共享锁或排他锁,也就无法进行任何操作)。

隔离级别相关疑点

幻读和不可重复读区别

1、幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(比如数据的个数)。对于前者, 只需要锁住满足条件的记录;对于后者, 要锁住满足条件及其相近的记录。

2、不可重复读重点在于update和delete,而幻读的重点在于insert。

如果使用锁机制来实现这两种隔离级别,在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复 读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会 发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。

所以说不可重复读和幻读最大的区别,就在于如何通过锁机制来解决他们产生的问题。

上文说的,是使用悲观锁机制来处理这两种问题,但是MySQL、ORACLE、PostgreSQL等成熟的数据库,出于性能考虑,都是使用了以乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题

InnoDB的RR可以避免不可重复读和幻象读,那么与串行化有什么区别呢?

RR隔离级别的防止幻象主要是针对写操作的,即只保证写操作的可串行化,因为只有写操作影响Binlog;而读操作是通过MVCC来保证一致性读(无幻象)。然而,可串行化隔离级别要求读写可串行化。由于在串行化下,查询操作不在使用MVCC来保证一致读,而是使用S锁来阻塞其他写操作。因此做到读写可串行化,然而换来就是并发性能的大大降低。

MySQL的可重复读

MySQL的可重复读是通过MVCC版本控制的,在当前整个事务的过程中如果没有执行update或delete操作,则select的值将还是undo log中的值。否则执行update或delete时,当前事务将会从数据库获取最新的值进行update或delete,则select的值则是undo log最新的值。MySQL不支持对同一数据项进行并发修改。除了保证可重复读,MySQL的RR还一定程度上避免了幻象读。

MySQL使用可重复读作为默认隔离级别的原因之一

MySQL使用可重复读来作为默认隔离级别的主要原因是语句级的Binlog。RR能提供SQL语句的写可串行化,保证了绝大部分情况(不安全语句除外)的DB/DR一致。

参考资料