简单记录Sql Server事务及隔离级别

2023-08-31

关于事务

含义

事务:将一系列操作看成一个整体执行,这些操作要么全部执行成功,要么全部失败

特性(ACID)

原子性:Atomicity,原子性是指事务是一个不可分割的工作单位,同一个事务中的操作要么全部执行成功,要么全部失败。

一致性:Consistency,事务前后的数据完整性必须保持一致。

隔离性:Isolation,隔离性是指多个用户并发访问数据库(同一张表)时,数据库开启的事务不能被其他事务所做的操作干扰,多个并发事务之间应当相互隔离。

如两个事务操作同一张表,事务1要么在事务2提交前执行,要么在事务2提交后执行。通常一个事务要等另一个事务提交后才会执行(提交),不会出现两个事务同时执行的情况。

持久性:Durability,持久性是指一个事务提交后,事务一系列操作对数据库的(数据)改变是永久性的,即使数据库发生故障也不影响(数据)的改变。

原子性、一致性、持久性可以归纳为可靠性。保证数据一致改变与不丢失。

隔离性,为了在并发场景下,"正确"读取数据。多事务并发执行时,可能会出现脏读不可重复读幻读问题,对于这些问题数据库也提供了不同的隔离级别应对——读未提交、读提交(默认)、可重复读、串行化。

事务操作

事务可靠演示

事务一旦开启,就一定要Commit(提交)或者Rollback(回滚)。提交操作会将事务的一系列更改真正更新到数据库中,回滚操作不会对数据库进行任何操作

Rollback将事务回滚,不会对数据库进行任何操作,执行前后数据库没有发生任何改变。

image-20230822181109514

提交会依次执行事务的一系列操作

image-20230822181321809

事务隔离演示

准备事务SetAge,并在事务中等待10s

我们可以用sp_lock命令,查看事务SetAge执行前后数据库锁的情况

image-20230822175200340

准备事务ReadAge,在事务SetAge执行期间,启用另一个窗口执行事务ReadAge

多测试几次,我们可以发现事务ReadAge总是在事务SetAge提交后才完成查询操作

准备下列语句,在事务SetAge执行期间,分别执行下列语句并观察执行情况

当我们在开启事务时,默认为当前表加上意向排他锁(IX锁:简单记录Sql Server常见锁类型 (logerlink.github.io))。当前事务执行中,其他事务或语句可立即查看(带锁查询要分情况)、插入数据(新数据、注意唯一限定),其他事务或语句对表内数据进行更新、删除操作需要等待当前事务提交(锁释放)后才会执行。

数据库隔离级别

读未提交

READ UNCOMMITTED。最低级别的隔离级别,所有事务都可以读取其他未提交事务的执行结果。该隔离级别性能最高,但很少用于实际应用

读提交

READ COMMITTED。大多数数据库系统的默认隔离级别。设置该隔离级别,可处理脏读问题

可重复读

REPEATABLE READ。保证同一个事务多次读取的数据是一样,但是不保证读取的数据行数是一样的。设置该隔离级别可处理脏读不可重复读问题

串行化

SERIALIZABLE。最高级别的隔离级别。强制事务排序,事务之间不可能相互冲突,从而解决幻读问题。设置该隔离级别可处理脏读不可重复读幻读问题。

由于串行化,是在每个读的数据上加锁,其他事务需要等待锁释放,可能会导致其他事务等待超时。串行化保证了事务的串行执行,数据稳定,但是对于并发事务的处理效率是非常低,实际应用中很少使用。

下列表格统计事务并发时不同隔离级别可能会出现的问题,"是"代表在该隔离级别下可能会出现的问题,"否"则相反

隔离级别脏读不可重复读幻读加锁读
读未提交—READ UNCOMMITTED
读提交—READ COMMITTED
可重复读—REPEATABLE READ
串行化—SERIALIZABLE

查看修改隔离级别

查看当前数据库的隔离级别

image-20230822170119342

修改数据库隔离级别

事务并发问题

多事务并发执行时,可能会出现脏读、不可重复读、幻读问题,我们先简单介绍这几个问题的场景再通过修改隔离级别一一解决问题。

脏读

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

设置数据库隔离级别为 READ UNCOMMITTED(读未提交)

image-20230822170712948

修改并查看初始数据

image-20230823101006637

准备事务SetAge,将Age值改为100

准备事务ReadAge,在事务SetAge执行过程中,启用另一个窗口执行事务ReadAge,并查看执行情况

image-20230822181947957

我们可以发现,事务ReadAge总是在事务SetAge提交后执行,事务ReadAge总是能拿到事务SetAge未提交的值——100。我们称这种现象为脏读,解决这个问题我们只要将数据库的隔离级别设为READ COMMITTED(读提交)、REPEATABLE READ(可重复读)、SERIALIZABLE(串行化)其中的一个即可

设置后再次执行上述操作,查看执行情况,我们可以发现事务ReadAge拿到的Age值还是初始值——5

image-20230823100411296

不可重复读

一个事务开始读取到了某个值,过段时间,再次读取该值,发现与之前读取的值不一致,可能是这段时间内别的事务将该值修改了。

设置数据库隔离级别为 READ UNCOMMITTED(读未提交)READ COMMITTED(读提交)任意一个

修改并查看初始数据

image-20230823101006637

准备事务ReadAge,多次读取Age值

准备事务SetAge,将Age值改为100。在事务ReadAge执行过程中,启用另一个窗口执行事务SetAge,并查看执行情况

image-20230823102509152

我们可以发现,由于事务ReadAge没有操作数据库,事务SetAge立即执行。事务ReadAge前后两次读取Age值不一致,这就是不可重复读。

有个特别现象:如果事务ReadAge在第二次读取Age前有修改Age(修改为1000)操作,事务SetAge会等待事务ReadAge提交或回滚后才提交。此时事务ReadAge第二次读取的值则是当前事务修改后的Age值(1000),此时查询数据库发现数据库的值是事务SetAge修改的值(100)

image-20230823104215444

如何处理不可重复读问题?我们可以将数据库的隔离级别设置为REPEATABLE READ(可重复读)、SERIALIZABLE(串行化)其中的一个即可

设置后再次执行上述操作,查看执行情况,我们可以发现事务SetAge需要等待事务ReadAge提交后,才开始执行。事务ReadAge前后两次读取Age值一致

幻读

事务A将表内某批数据修改,在事务A提交前事务B往该表插入或删除一条数据,事务A再次查询数据会发现多或少一条数据,而这条数据就是事务B刚刚提交的数据。

幻读和不可重复读都是事务前后读取的数据不一致,但是幻读更侧重于插入、删除的数据(行数的变化)。

设置数据库隔离级别为 READ UNCOMMITTED(读未提交)、READ COMMITTED(读提交)、REPEATABLE READ(可重复读)任意一个

查看初始数据

image-20230823105524114

准备事务SetAge,将Age值都改为100

准备事务AddUser,插入一条Age为18的新数据。在事务SetAge执行过程中,启用另一个窗口执行事务AddUser,并查看执行情况

image-20230823110349353

我们可以发现,在事务SetAge执行过程中,执行事务AddUser,事务AddUser立即执行(IX锁插入无需等待)。事务SetAge将所有Age值改为100,执行后再次查询,发现有一条数据(事务AddUser新增的)的Age值没有变为100。可是明明已经全部修改,怎么还有一些数据没有改过来,这就是幻读。我们可以将数据库的隔离级别设置为SERIALIZABLE(串行化)解决幻读问题。

设置后再次执行上述操作,查看执行情况,我们可以发现事务AddUser需要等待事务SetAge提交后,才开始执行。事务SetAge在更改后正常读取数据——行数没有发生改变,值都改为了100.

image-20230823111258751

在执行事务SetAge的时候,我们使用sp_lock查看数据库锁发现,数据库隔离级别为SERIALIZABLE时,事务执行更新操作时,会给数据表加上排他锁(X)导致其他事务或语句对该表进行插入、删除、更新操作时需要等待当前事务提交(锁释放)后才会执行。其他隔离级别只会加上意向排他锁(IX),对该表进行插入操作立即执行,导致出现幻读。

image-20230823112620707

代码应用

环境:.Net6.0

image-20230831110952189

EF使用事务

ef常见的三种事务使用方式——SaveChanges、DBContextTransaction、TransactionScope

参考:EF的三种事务的应用场景和各自注意的问题(SaveChanges、DBContextTransaction、TransactionScope)ef事务虫儿Sound的博客-CSDN博客

SaveChanges

SaveChanges平时用的最多,仅针对一个数据库,不能控制多个数据库。如我们对数据库不同数据或表进行增删改操作后,调用SaveChanges,统一提交。

SaveChange 成功演示

SaveChangeTest接口执行后,没有异常,数据库操作全部成功。

image-20230823164021877

SaveChange失败演示:

SaveChangeTestError 接口执行后,程序出现异常,没有成功调用SaveChanges,所有的操作都无效,接口执行前后数据库数据不受影响。

image-20230823164458252

DBContextTransaction

仅针对一个数据库,不能控制多个数据库。依靠SaveChanges对数据库进行操作,支持多个SaveChanges提交或回滚,如一个事务存在三个SaveChanges,前两个SaveChanges执行成功,最后一个SaveChanges执行失败,此时主动调用Rollback方法则可以将该事务内部的一系列操作(包括已经成功的SaveChanges的操作)全部回滚。默认事务隔离级别为READ COMMITTED

DBContextTransaction常见格式

DBContextTransaction 成功演示

DBContextTransactionTest 接口执行后,没有异常,数据库操作全部成功。

image-20230823170158404

DBContextTransaction 失败演示

DBContextTransactionTestError 接口执行后,程序出现异常,事务执行回滚操作,所有的操作都无效,包括已经SaveChanges的操作,接口执行前后数据库数据不受影响。

image-20230823172245484

TransactionScope

可针对不同的数据库,可以控制多个数据库。依靠SaveChanges对数据库进行操作,支持多个SaveChanges提交或回滚。如一个事务存在三个SaveChanges,前两个SaveChanges执行成功,最后一个SaveChanges执行失败或异常,导致没有执行Complete方法,此时事务会将该事务内部的一系列操作(包括已经成功的SaveChanges的操作)全部回滚,当然也可以使用Try...Catch捕获异常后手动回滚(Transaction.Current.Rollback)。TransactionScope事务正常结束最后一定要调用Complete方法。默认事务隔离级别为Serializable(串行化)

特别注意:如果使用该事务来处理多个数据库(多个DBContext)时,必须手动开启msdtc服务,这样才可以将多个DB的SaveChange给放到一个事务中,如果失败, 则多个数据库的数据统一回滚。开启msdtc服务的步骤: cmd命令→net start msdtc

TransactionScope常见格式

TransactionScope 成功演示

TransactionScopeTest 接口执行后,没有异常,数据库操作全部成功。

image-20230824152801143

TransactionScope 失败演示

TransactionScopeTestError 接口执行后,程序出现异常,事务执行回滚操作,所有的操作都无效,包括已经SaveChanges的操作,接口执行前后数据库数据不受影响。

image-20230824153032056

EF使用事务及隔离级别

一般情况下,SaveChanges会默认使用当前数据库的隔离级别。DBContextTransaction默认的隔离级别为READ COMMITTED,可以通过System.Data.IsolationLevel指定不同的隔离级别。TransactionScope默认的隔离级别为SERIALIZABLE,可以通过System.Transactions.IsolationLevel指定不同的隔离级别。如何指定不同的隔离级别,如下示例

准备工作

User类:

DMSession类:

GetLevelName方法:

SaveChanges

SaveChanges会默认使用当前数据库的隔离级别。

设置数据库隔离级别为默认值READ COMMITTED

设置初始数据

准备接口ReadAgeByChange,分两次读取数据

准备接口SetAge,执行ReadAgeByChange后,执行SetAge接口,观察接口执行情况

image-20230830172914006

我们可以发现,执行接口ReadAgeByChange后,执行SetAge接口。SetAge接口立即执行,接口ReadAgeByChange两次查询的值不一致,成功复现不可重复读问题

DBContextTransaction

DBContextTransaction默认的隔离级别为READ COMMITTED,可以通过System.Data.IsolationLevel指定不同的隔离级别。

(翻阅之前的文档,文档提到DBContextTransaction默认的隔离级别REPEATABLE READ(可重复读),但我经过测试发现DBContextTransaction默认的隔离级别为READ COMMITTED。可能是版本问题或者参考的文档有误,所以该文章以测试为准)

设置数据库隔离级别READ UNCOMMITTED(读未提交)

设置初始数据

准备接口ReadAgeByTransaction,分两次读取数据

准备接口SetAge,执行ReadAgeByChange后,执行SetAge接口,观察接口执行情况

image-20230830180229735

我们可以发现,执行接口ReadAgeByTransaction后,执行SetAge接口。SetAge接口立即执行,接口ReadAgeByTransaction两次查询的值不一致,成功复现不可重复读问题,而且我们前面已经设置数据库隔离级别为READ UNCOMMITTED(读未提交),执行时获取当前事务隔离级别却是READ COMMITTED

接下来我们在c#代码中修改事务的隔离级别为REPEATABLE READ(可重复读),再次执行接口ReadAgeByTransaction、SetAge,观察接口执行情况

image-20230830174134587

修改事务的隔离级别后,执行接口ReadAgeByTransaction、SetAge。接口SetAge会等待接口ReadAgeByTransaction执行完后才执行,接口ReadAgeByTransaction中两次读取的值都是一样的,避免了不可重复读问题

TransactionScope

设置数据库隔离级别READ UNCOMMITTED(读未提交)

设置初始数据

准备接口ReadAgeByTransactionScope,分两次读取数据

准备接口SetAge,执行ReadAgeByTransactionScope后,执行SetAge接口,观察接口执行情况

image-20230830181136808

我们可以发现,执行接口ReadAgeByTransactionScope后,执行SetAge接口。接口SetAge会等待接口ReadAgeByTransaction执行完后才执行,接口ReadAgeByTransactionScope中两次读取的值都是一样的,避免了不可重复读问题,而且我们前面已经设置数据库隔离级别为READ UNCOMMITTED(读未提交),执行时获取当前事务隔离级别却是SERIALIZABLE

接下来我们在c#代码中修改事务的隔离级别为READ COMMITTED(读已提交),再次执行接口ReadAgeByTransaction、SetAge,观察接口执行情况

image-20230830182128240

修改事务的隔离级别后,执行接口ReadAgeByTransactionScope、SetAge。接口SetAge立即执行,SetAge接口立即执行,接口ReadAgeByTransaction两次查询的值不一致,成功复现不可重复读问题。

EF查看事务隔离级别

EF查看当前的事务隔离级别和事务超时时间(默认1分钟),TransactionScope可以直接使用Transaction.Current?.IsolationLevel获取当前事务的隔离级别,其他不行

image-20230831110612086