事务管理(ACID)

前言

数据库事务可以被定义为一个或者几个数据库允许的操作的集合。这个集合需要支持ACID特性。

在ACID特性中,隔离性(isolation)指的是不同事务在提交的时候,最终呈现出来的效果是串行的,换句话说,既是不同事务,按照提交的先后顺序执行,再换句话说,对于事务本身来说,它所感知的数据库,应该只有它自己在操作。那么最简单的方法,我们按照定义来实现数据库事务的隔离性即可,很明显这在同时并发有多个事务要执行的环境下是没有执行效率的,一个事务的执行,必然会阻塞其他事务的执行。所以SQL的标准制定者对此作出妥协,提出隔离性的四个等级,其中最高级隔离等级才是串行执行。在没有到达串行执行等级的情况下,事务又是并发执行的,总是或多或少会存在问题,这在下面会讲到。

谈到事务一般都是以下四点:

  • 原子性(Atomicity)

原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。

  • 一致性(Consistency)

是指应用层系统从一种正确的状态,在事务成功后,达成另一种正确的状态。比如:A、B账面共计100W,A向B转账,加上事务控制,转成功后,他们账户总额应还是100W,事务应保持这种应用逻辑正确一致。还有,转账(事务成功)前后,数据库内部的数据结构--比如账户表的主键、外键、列必须大于0、Btree、双向链表等约束需要是正确的,和原来一致的。

  • 隔离性(Isolation)

隔离是指当多个事务提交时,让它们按顺序串行提交,每个时刻只有一个事务提交。但隔离处理并发事务,效率很差。所以SQL标准制作者妥协了,提出了4种事务隔离等级(1,read-uncommited 未提交就读,可能产生脏读  2,read-commited 提交后读  可能产生不可重复读  3,repeatable-read 可重复读  可能产生幻读    4,serializable 序列化,最高级别,按顺序串行提交)

  • 持久性(Durability)

持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响

举个简单的例子理解以上四点

原子性(Atomicity)

针对同一个事务

 事务的特性ACID_ACID

这个过程包含两个步骤

A: 800 - 200 = 600
B: 200 + 200 = 400

原子性表示,这两个步骤一起成功,或者一起失败,不能只发生其中一个动作

一致性(Consistency)

针对一个事务操作前与操作后的状态一致

 事务的特性ACID_事务的特性_02

操作前A:800,B:200
操作后A:600,B:400

一致性表示事务完成后,符合逻辑运算

持久性(Durability)

表示事务结束后的数据不随着外界原因导致数据丢失

操作前A:800,B:200
操作后A:600,B:400
如果在操作前(事务还没有提交)服务器宕机或者断电,那么重启数据库以后,数据状态应该为
A:800,B:200
如果在操作后(事务已经提交)服务器宕机或者断电,那么重启数据库以后,数据状态应该为
A:600,B:400

隔离性(Isolation)

针对多个用户同时操作,主要是排除其他事务对本次事务的影响

不同的事务并发操作相同的数据时,每个事务都有各自完成的数据空间,即一个事务内部的操作及使用的数据对其他并发事务时隔离的,并发执行的各个事务之间不能相互干扰。

 事务的特性ACID_数据库事务_03

事务的隔离级别

1.未提交读:(Read Uncommited)

未提交就读,可能产生脏读;

指一个事务读取了另外一个事务未提交的数据。

事务的特性ACID_ACID_04

 这个等级是最低等级,也可以认为,事务之间完全不隔离,事务A开始一个事务,接着事务B开始,事务B对数据C继续update,这时候,A读取了B未提交(commit)的数据,这种情况叫做脏读(dirty read)。这个时候要是事务B遇到错误必须rollback,那么A读取的数据就完全是错的。可以想象这样完全不隔离的状态下,我们相对于数据库的业务方程序员写的一个sql,提交个db的执行引擎,返回的结果是多么不可确定啊。

2.提交读:(Read Commited)

 提交后读  可能产生不可重复读;

导致不可重复读原因:

B事务在A事务的两次读取之间修改了A事务读取的数据,且A事务采用了低于“可重复读”隔离级别的事务,最终导致同一个事务下面两次读取结果不一样。

按照我们之前所说,我们的目标是:对于一个事务本身来说,它所感知的数据库,应该只有它自己在操作,那么事务A会觉得自己并没有更新数据啊,怎么数据突然变了,再也不能重复读到最开始的那个数据值了,此为不可重复读;

事务的特性ACID_ACID_05

在某些情况下,不可重复读并不是问题,比如我们多次查询某个数据当然以最后查询得到的结果为主。但在另一些情况下就有可能发生问题,例如对于同一个数据A和B依次查询就可能不同,A和B就可能打起来了……

例如:

页面统计查询值

事务的特性ACID_数据库事务_06

点击生成报表的时候,B有人转账进来300(事务已经提交)

事务的特性ACID_事务_07

代码演示(spring):

 @Transactional(isolation = Isolation.READ_COMMITTED)
    public Object listForIllusionRead() {//会话1
 
        List<Map<String,Object>> map = jdbcTemplate.queryForList("select * from tao");
        try {
            Thread.sleep(10000);//会话1堵塞
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        List<Map<String,Object>> map2 = jdbcTemplate.queryForList("select * from tao");
 
        Map<String,Object> res = new HashMap<String, Object>();
        res.put("before", map);
        res.put("after", map2);
        return res;
    }
 
 
    public void updateForNoRepeat () {//会话2
        jdbcTemplate.execute("update  tao set col2 = 'e'");
    }

会话1执行→第一次读→会话1sleep阻塞→会话2执行update有效会话操作→会话1第二次读→会话1返回{ "before":[ { "col1":1, "col2":"d" } ], "after":[ { "col1":1, "col2":"e" } ]}

结论:与理论相符,两次读取不一样

3.可重复读:(Repeatable Read)

可重复读  可能产生幻读;

事务的特性ACID_事务的特性_08


(一般是行影响,多了一行)

可重复读,即是在上一个级别(提交读)的基础上,SQL机制强行让A仍然读之前读到的数据值,保证在一个事务内两次select同一条数据不会出现变化,即是别的事务对你select的对象进行update操作不会影响,这就是可重复读。但是,如果是insert操作,在这个隔离级别还是会受到影响。事务A开启事务,并select一段有范围的数据,然后事务B开启事务,在先前A事务select的那段有范围的数据中insert一条数据,然后提交事务,接着事务A再select出来这段数据,发现数据多了一条,这种情况叫幻读(Phantom Read)·

·

4.串行化:(Serializable)

是最严格的事务隔离级别,它要求所有事务被串行执行,即事务只能一个接一个的进行处理,不能并发执行。

5.隔离性总结

设置   描述
 Serializable  可避免脏读、不可重复读、虚读情况的发生。(串行化)
 Repeatable read(可重复读)    可避免脏读、不可重复读情况的发生。 可能产生幻读;
 Read committed (提交后读)  可避免脏读情况发生(读已提交)。  可能产生不可重复读;
 Read uncommitted (读未提交)  最低级别,以上情况均无法保证。可能产生脏读;

 

以上四种隔离级别最高的是Serializable级别,最低的是Read uncommitted级别,隔离级别越高,越能保证数据的完整性和一致性,但是执行效率就越低,对并发性能的影响也越大。

6.不同数据库隔离级别

MySQL数据库中,支持上面四种隔离级别,默认的为Repeatable read (可重复读);

Oracle数据库中,只支持Serializable (串行化)级别和Read committed (读已提交)这两种级别,其中默认的为Read committed级别。

大多数的数据库默认隔离级别为 Read Commited,比如 SqlServer、Oracle

少数数据库默认隔离级别为:Repeatable Read 比如: MySQL InnoDB

 java代码设置隔离级别

如果是使用JDBC对数据库的事务设置隔离级别的话,也应该是在调用Connection对象的setAutoCommit(false)方法之前。调用Connection对象的setTransactionIsolation(level)即可设置当前链接的隔离级别,至于参数level,可以使用Connection对象的字段:

Connection conn=null
Statement st=null
ResultSet rs=null
try{ conn=JdbcUtils.getConnection(); //设置该链接的隔离级别 conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
conn.setAutoCommit(false);//开启事务

Connection字段对应各个级别说明:

设置  描述
TRANSACTION_NONE 指示事务不受支持的常量
TRANSACTION_SERIALIZABLE 指示不可以发生脏读、不可重复读和虚读的常量。
TRANSACTION_REPEATABLE_READ 指示不可以发生脏读和不可重复读的常量;虚读可以发生。
TRANSACTION_READ_UNCOMMITTED  指示可以发生脏读 (dirty read)、不可重复读和虚读 (phantom read) 的常量。
TRANSACTION_READ_COMMITTED  指示不可以发生脏读的常量;不可重复读和虚读可以发生。

mysql测试事务隔离性 

SELECT @@session.tx_isolation;   
SELECT @@tx_isolation;  
  
SET SESSION TRANSACTION ISOLATION LEVEL read uncommitted;  
SET SESSION TRANSACTION ISOLATION LEVEL read committed;  
SET SESSION TRANSACTION ISOLATION LEVEL repeatable read;  
SET SESSION TRANSACTION ISOLATION LEVEL serializable;  

start transaction;

--建表
drop table AMOUNT;
CREATE TABLE `AMOUNT` (
`id`  varchar(10) NULL,
`money`  numeric NULL
)
;
--插入数据
insert into amount(id,money) values('A', 800);
insert into amount(id,money) values('B', 200);
insert into amount(id,money) values('C', 1000);
--测试可重复读,插入数据
insert into amount(id,money) values('D', 1000);

--设置事务
SET SESSION TRANSACTION ISOLATION LEVEL read uncommitted;  
SELECT @@tx_isolation;  
--开启事务
start transaction;

--脏读演示,读到其他事务未提交的数据
--案列1,事务一:A向B转200,事务二:查看B金额变化,事务一回滚事务
update amount set money = money - 200 where id = 'A';
update amount set money = money + 200 where id = 'B';

--不可重复读演示,读到了其他事务提交的数据
--案列2,事务一:B向A转200,事务二:B向C转100
SET SESSION TRANSACTION ISOLATION LEVEL read committed;  

--开启事务
start transaction;
--两个事务都查一下数据(转账之前需要,查一下金额是否够满足转账)
select * from amount;
--事务一:B向A转200
update amount set money = money - 200 where id = 'B';
update amount set money = money + 200 where id = 'A';

commit;
--事务二:B向C转100
update amount set money = money - 100 where id = 'B';
update amount set money = money + 100 where id = 'C';
commit;
--从事务二的角度来看,读到了事务一提交事务的数据,导致金额出现负数

--幻读演示
--案列3,事务一:B向A转200,事务二:B向C转200转100
SET SESSION TRANSACTION ISOLATION LEVEL repeatable read;  

--开启事务
start transaction;
--两个事务都查一下数据(转账之前需要,查一下金额是否够满足转账)
select * from amount;
--事务一:B向A转200
update amount set money = money - 200 where id = 'B';
update amount set money = money + 200 where id = 'A';

commit;
--事务二:B向C转200转100
update amount set money = money - 100 where id = 'B';
update amount set money = money + 100 where id = 'C';
commit;
--从事务二的角度来看,读到了事务一提交事务的数据,导致金额出现负数
  • serializable事务二会一直等着事务一提交再操作

 

 补充内容(隔离实现机制)

隔离性的实现

我们知道,如果要实现数据库事务最高隔离性,也就是最安全的隔离性,有个显而易见的实现就是当一个事务在执行的时候,其他全部事务都阻塞,等待这个事务执行完再执行,这在现代多核CPU环境下显然非常浪费计算资源。为了充分利用资源,必须支持并发,这里就涉及并发控制(Concurrency control)

这是一个非常大的主题,关系到数据库,有两个比较重要的方法,一个是用锁(lock),一个是称为多版本并发控制(MVCC)的方法。

通过锁的方式来实现隔离性

读写锁

读写锁的概念很平常,当你在读取数据的时候,应该先加读锁,读取完之后的某个时间再解开读锁,那么加了读锁的数据,应该需要有什么特性呢,应该只能读,不能写,因为加了读锁,说明有事务准备读取这个数据,如果被别的事务重写这个事务,那数据就不准确了。所以一个事务给这个数据加了读锁,别的事务也可以对这个数据加读锁,因为大家都是只读不写。

写锁则具有排他性(exclusive lock),当一个事务准备对一个数据进行写操作的时候,先要对数据加写锁,那么数据就是可变的,这时候,其他事务就无法对这个数据加读锁了,除非这个写锁释放。

两端式提交锁(Two-phase locking)

两段式提交分为两步:

1. 这个阶段只加锁,或者释放锁(读写锁)

2. 这个阶段只会释放锁

下面对应于不同隔离级别对加锁方式进一步分析:

* 未提交读(read uncommitted):这个级别加锁,其实并不需要用两端式加锁,每一个具体操作执行完,锁就可以释放了。

* 提交读(read committed):这个阶段其实也可以按照每个操作执行前加锁,执行之后释放锁的方式。

* 可重复读(repeatable read) : 这个级别,就要求读锁必须,到事务结束前最后时刻才能释放,这样才能保证读取到数据是不可变的,可重复读的。但是这样会阻塞其他事务对加锁的数据的写操作。

* 序列化读(serializable):这个级别要求,两段式提交的第一步,要在事务开始的时候,原子性的把需要的锁全部加好(这显然很难估算,除非很大力度的锁),在事务结束前最后时刻,把全部锁一次性释放。这样做的结果就是使很多数据在事务执行期能都被加锁,无法被其他事务所使用。

使用多版本并发控制(MVCC)

加锁的方式处理事务一个比较大的问题就是会造成死锁(dead lock),原因就是一个事务加锁的数据并不止只有一行。事务A对行C加写锁,事务B对行D加写锁,接着事务希望获取行D的锁,事务B希望获取行C的锁,这样很容就死锁了。

使用MVCC就可以避免很多情况下的加锁操作,使用数据冗余的方式来实现事务隔离(这真是个很好的设计啊)

什么是MVCC

MVCC提供的只是一种思路,具体的实现比较多样化。大体的思路是每一行保存冗余数据,读写的时间戳,也可以称为版本号,在对某一行数据继续update或者delete的时候,并不直接操作,而是复制多一份副本进行操作,这个就是所谓多版本(multiversion)

mysql innodb对于MVCC的实现

innodb对每一行保存两个系统版本号,一个更新操作的版本号,一个删除操作的版本号,这两个版本号的来源是事务的ID(transaction id),也就是说,当某个事务对这一行数据进行update,或者删除的时候,相应会把它的事务ID写入这行数据的更新操作的版本号,删除操作的版本号中。

事务ID是随时间推移而增长,而且不可重复的。一个事务打开之后:

* 对于select操作:每次只会select具有比当前事务ID更小的更新操作版本号的数据,而且这些数据要保证删除版本号为空,或者删除版本号大于当前事务ID。

* 对于update操作:对该行数据复制出一份副本,同时在更新操作版本号写入当前事务ID,同时把当前事务ID写入之前的删除操作的版本号中。

* 对于insert操作:写入新行,同时在更新操作版本号写入当前事务ID

* 对于delete操作:在删除操作版本号写入当前事务ID

mysql官方innodb的实现是用MVCC,官方声称默认的innodb的隔离级别是可重复读。看了不少资料说无法测试出这种级别下的幻读,实际上不对的,只是不能用之前加锁情况下举例的那个例子。用如下方式可以实现幻读:事务A开启,事务B接着开启,事务B select一段数据,接着事务A 在这段数据中间insert一条数据,接着事务B再select一次,就出现幻读了。

mysql官方文档提到串行隔离级别要在原来的基础说对每一个select操作执行[SELECT ... LOCK IN SHARE MODE](13.2.9 SELECT Syntax)

这样就可以读取的数据加读锁了,那么其他试图写入数据都必须阻塞。那么就可以实现序列化串行了。

可重复读,即是在上一个级别的基础上,保证不会在一个事务内两次select同一条数据会出现变化,即是别的事务对你select的对象进行update操作不会影响。但是,如果是insert操作,在这个隔离级别还是会受到影响。事务A开启事务,并select一段有范围的数据,然后事务B开启事务,在先前A事务select的那段有范围的数据中insert一条数据,然后提交事务,接着事务A再select出来这段数据,发现数据多了一条,这种情况叫幻读(Phantom Read)