数据库事务、隔离级别、锁

数据库事务

数据库事务(Transaction)是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。一方面,当多个应用程序并发访问数据库时,事务可以在应用程序间提供一个隔离方法,防止互相干扰。另一方面,事务为数据库操作序列提供了一个从失败恢复正常的方法。

它具有以下4个特性(ACID)

(1) 原子性(Atomicity):事务的原子性是指事务中的一系列操作不可拆分,只允许全部执行或者全部不执行。

(2) 一致性(Consistency):事务的一致性是指事务的执行不能破坏数据库的一致性,一致性也称为完整性。一个事务在执行后,数据库必须从一个一致性状态转变为另一个一致性状态。

(3) 隔离性(Isolation):事务的隔离性是指并发的事务相互隔离,不能互相干扰。

(4) 持久性(Durability):事务的持久性是指事务一旦提交,其对数据库的更新就是持久的。任何事务或系统故障都不会导致数据丢失。

不同的数据库使用事务的方式也不尽相同,这里以MySQL为例,它的auto-commit默认是ON的,即默认情况下,MySQL是提交事务的,每一条sql都是一个独立的事务。例如,你执行了insert语句,mysql默认会帮你开启事务,并且在这条插入语句执行完成之后,默认帮你提交事务。

mysql> insert into test (id, price) values (1, 100);

但是一般实际应用中事务会包含一组sql语句,这时我们也可以手动使用start transaction开启事务,执行一系列SQL后手动提交/回滚(commit/rollback)。MySQL会把start transaction与commit/rollback之间的语句当做一次事务来处理。如果用户不提交便退出了,那么事务将回滚。

mysql> start transaction;
mysql> insert into test (id, price) values (1, 100);
mysql> update test set price = 110 where id = 1;
mysql> commit; (or rollback;)

还有一种手动开启事务的方式是把auto-commit设置为OFF,这样的话,在执行了一组SQL后需要手动提交/回滚(commit/rollback)。一般来说,JDBC就是使用这种方式来实现事务。使用Java实现JDBC连接及事务的方式可以参考:

(1) Java实现JDBC连接及事务的方式

(2) Java SQL框架(java.sql.*)中常用接口详解

mysql> set session autocommit=0;
mysql> insert into test (id, price) values (2, 200);
mysql> insert into test (id, price) values (3, 300);
mysql> commit; (or rollback;)

数据库中常见的并发异常

脏写:一个事务可以修改(覆盖)另一个事务未提交的数据

脏读:一个事务读到了另一个事务未提交的数据

 

不可重复读:一个事务对同一数据的读取结果前后不一致。与脏读的区别在于,不可重复读读取的是事务已经提交的数据,而脏读是读到事务未提交的脏数据

 

覆盖丢失(丢失更新):一个事务对数据的更新覆盖了另一个事务对数据的已提交修改

幻读:在读取多行数据时,在同一个事务中如果两次读取相同的数据,但结果数(条数或COUNT数)却不一致。与不可重复读的区别在于,不可重复读针对的是一行数据,而幻读针对的是多行数据

 

事务的隔离级别

SQL92标准定义了四种隔离级别,由低到高依次为读未提交(Read uncommitted)、读提交(Read committed)、重复读(Repeatable read)、序列化(Serializable),这四个级别可以逐级解决脏写、脏读、不可重复读、覆盖丢失,幻读这几类问题。

读未提交(Read uncommitted):指一个事务的更新语句没有提交,但是别的事务可以读到这个改变。这是最低级别的隔离,甚至在PostgreSQL中,Read Uncommitted压根就没有实现,一般不建议使用也很难用到。

读提交(Read committed):指一个事务只能看到其他事务的已经提交的更新,看不到未提交的更新,消除了脏读消除了脏读和第一类丢失更新,这是大多数数据库的默认隔离级别,如Oracle,Sqlserver。

重复读(Repeatable read):指一个事务中进行两次或多次同样的对于数据内容的查询,得到的结果是一样的,但不保证对于数据条数的查询是一样的,只要存在读改行数据就禁止写,消除了不可重复读和第二类更新丢失,这是Mysql数据库的默认隔离级别。

序列化(Serializable):事务执行的时候不允许别的事务并发执行。完全串行化的读,只要存在读就禁止写,但可以同时读,消除了幻读。这是事务隔离的最高级别,虽然最安全最省心,但是效率太低,一般不会用。

当然,最不严格的隔离级别就是不隔离。但是,任何一个支持事务的数据库都不允许出现“脏写”这样的问题。定义这4种隔离级别时,制定者主要围绕着基于锁的并发控制来说的,也就是用各种锁来实现。但是,现在的数据库隔离性的实现方式也不尽相同,除了常规的锁的实现,还有PostgreSQL,MySQL(InnoDB)等一些数据库使用的MVCC(后面会讲到)等一些其他技术。此外,各个数据库的默认的隔离级别也不一样,大部分数据库,如PostgreSQL,Oracle,Sqlserver的默认隔离级别是Read committed(PostgreSQL中,Read uncommitted甚至就没有实现),MySQL(Innodb)的默认隔离级别是Repeatable read。可见,不同的数据库的实现方式和数据库标准还是有很多出入的。

 

事务隔离性的实现

在介绍这部分之前,首先要介绍一个重要的知识点:数据库锁。

从并发事务锁定的关系上看,可以分为共享锁(读锁/S锁/Share Locks)排他锁(写锁/独占锁/X锁/Exclusive Locks)

(1)共享锁与其他锁的兼容性:排斥排他锁(别的事务不能修改),但不排斥其他共享锁(别的事务也可以读)。

(2)排他锁与其他锁的兼容性:排他锁不能和其他锁兼容(自己独占资源),如果数据资源上已经加了排他锁,就不能再放置其他的锁了。同样,如果数据资源上已经放置了其他锁,那么也就不能再放置排他锁了。

从实现上分,可以分为乐观锁悲观锁

(1)乐观锁:乐观锁是一种思想,由程序实现,不会存在死锁等问题。一般通过时间戳(timestamp)或版本号(version)来实现。核心思想是在读取数据时,记下这条数据的时间戳或版本号。当更新数据时,如果这个数据的时间戳或版本号和DB不一致(这个条件应该写在一个SQL的where里,避免其他事务的并发修改),则说明在这期间,数据已被其他事务更新;如果一致,则做更新并更新时间戳(一般为当前服务器时间)或迭代版本号。

(2)悲观锁:比如行锁和表锁。就是在处理某一行或某张表时,加上排他锁来独占资源,特定的SQL语句会触发行锁或表锁,下面会详细介绍。

介绍完锁后,我们来看看各个隔离级别在数据库标准是如何实现的。

读未提交(Read uncommitted)的实现:事务T在修改数据R之前必须先对其加X锁,直到事务结束才释放(一级封锁协议)。我们通过下图可以看出,它解决了脏写问题,但是脏读问题还存在。

读提交(Read committed)的实现:在一级封锁协议基础上增加事务T在读数据R之前必须先对其加S锁,读完后即可释放S锁(二级封锁协议)。我们通过下图可以看出,它解决了脏读问题,但是不可重复读问题还存在。

 

重复读(Repeatable read)的实现:在一级封锁协议的基础上增加事务T在读数据R之前必须先对其加S锁,直到事务结束才释放(三级封锁协议)。我们通过下图可以看出,它解决了不可重复读问题,但是幻读问题还是存在。

序列化(Serializable)的实现:在三级封锁协议的基础上,所有锁都变成表锁。我们通过下图可以看出,它解决了幻读问题。

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大;同时,不可重复读、覆盖丢失,幻读这些异常虽然会发生,但是在并发环境也有其一定的合理性,许多业务场合中也不需要考虑。所以,大部分数据库的默认隔离级别是Read committed。但是,我们仍需要一些手段来解决不可重复读、覆盖丢失,幻读这些问题。下面就是一些方法:

(1)显式添加共享锁和排他锁来解决不可重复读和覆盖丢失的问题。

一般来说,对于update,delete,insert语句,数据库会自动给涉及的数据集隐式的加上排他锁,对于select语句,数据库会自动给涉及的数据集隐式的加上共享锁(MVCC技术中对于select语句不加锁,后面会说),实际上就是二级封锁协议的内容。如果SQL中检索条件是索引,则使用行锁;如果检索条件是其他字段,则使用表锁。我们可以在一个事务中为select语句显式添加共享锁或排他锁:

mysql> start transaction;
mysql> select * from table where ... lock in share mode;
mysql> select * from table where ... for update;
mysql> commit; (or rollback;)

(2)使用乐观锁来解决覆盖丢失的问题。具体可以参考上面乐观锁的介绍,这里不再赘述。

(3)锁表解决不可重复读,覆盖丢失和幻读的问题。

mysql> start transaction;
mysql> lock tables T1 read;
mysql> lock tables T2 write;
mysql> commit; (or rollback;)

实际上的实现方式——多版本并发控制(MVCC)

上面的所有内容都是数据库的理论知识,而实际上不同的数据库的实现方式是“青取之于蓝而青于蓝”,即继承了理论,也有自己的扩展。

在MySQL中,我们可以做一个小试验,打开一个新事务T1,然后我们做一个update,先别提交;然后打开一个新事务T2,查询T1中要update的数据。如果按照上面的理论(MySQL默认是Repeatable Read),T2应该阻塞,等T1提交后才会显示查询结果。但是实际上,MySQL直接查出了结果,并且不是脏读。这个就要归功于MVCC。许多主流数据库,如MySQL,PostgreSQL等都支持MVCC。

MVCC是"Multi-Version Concurrency Control"的缩写。快照隔离是MVCC的一种实现方式。其核心思想是:数据库为每个数据项维护多个版本(快照),每个事务只对属于自己的私有快照进行更新,在事务真正提交前进行有效性检查,使得事务正常提交更新或者失败回滚。在另一个事务进行读取时,由隔离级别来控制到底取哪个版本。同时,在读取过程中,完全不加锁(除非用SELECT … FOR UPDATE强行加锁),也就是说,我们只需要使用X锁[注] + MVCC就可以解决脏读、不可重复读、幻读(MySQL和PostgreSQL在Repeatable Read隔离级别都不会出现幻读)等问题,同时,还极大的提升了性能。

尽管在MVCC的加持下Read Committed和Repeatable Read都可以得到很好的实现,但是,在覆盖丢失(丢失更新)的情况下,我们需要使用额外方法解决

上面提到的乐观锁是一个好的解决方法,但是当遇到复杂的逻辑时,可能这个方法会有瓶颈。这时,在MySQL和PostgreSQL中,我们可以使用Serializable级别来实现。

 (1)MySQL中Serializable隔离级别是依靠MVCC + 加更多的锁。简单来讲就是所有的读取要加上共享锁。

 (2)PostgreSQL的Serializable基于SSI(Serialized Snopshot Isolation)实现。策略大致如下:

            先提交者获胜:对于执行该检查的事务T,判断是否有其他事务已经将更新写入数据库,是则T回滚否则T正常提交。

            先更新者获胜:通过锁机制保证第一个获得锁的事务提交其更新,之后试图更新的事务中止。

        事务间可能冲突的操作通过数据项的不同版本的快照相互隔离,到真正要写入数据库时才进行冲突检测。因而这也是一种乐观并发控制。

 

[注] 这里面的X锁和上面的理论还是有区别的,上面的理论是指,对于update,delete,insert语句加的排他锁(行锁或表锁)。我们以MySQL(InnoDB)为例,行锁是通过给索引上的索引项加锁来实现的。

如果没有索引,InnoDB 将会通过隐藏的聚簇索引来对记录加锁。另外,根据针对 sql 语句检索条件的不同,加锁又有以下三种情形:

(1)Record lock:对索引项加锁。若没有索引项则使用表锁。

(2)Gap lock:对索引项之间的间隙加锁。

(3)Next-key lock:对记录记前面的间隙加锁。

当利用范围条件而不是相等条件获取排他锁时,InnoDB 会给符合条件的所有数据加锁。对于在条件范围内但是不存在的记录,叫做间隙。innoDB 也会对这个间隙进行加锁。另外,使用相等的检索条件时,若指定了本身不存在的记录作为检索条件的值的话,则此值对应的索引项也会加锁。

原文地址:https://www.cnblogs.com/storml/p/7833245.html