18.3 SQL Server事务与锁详解之(事务篇)
SQL Server事务与锁详解之(上篇)- 事务
简介
在关系型数据库中,我们用事务来保证数据的一致性,事务是一个不可分割的工作单元,通过事务,我们可以让一组SQL语句要么全部执行成功(全部提交),要么全部不执行(全部回滚)。事务是数据库系统并发控制的基本单位,它是一个逻辑上的SQL操作序列。
但是使用事务的时候,需要注意一些问题:
比如:事务的隔离级别?事务的并发问题?事务的锁问题?死锁问题?事务的回滚问题?事务的日志问题?事务的性能问题等等。
本章我们将详细介绍事务与锁的相关知识。
事务的基本知识
事务ACID特性
事务作为单个逻辑工作单元具有ACID四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
原子性(Atomicity)
事务必须是原子工作单元;即对于其数据修改,要么全都执行,要么全都不执行。
一致性(Consistency)
事务必须使数据库从一个一致性状态变换到另外一个一致性状态。
数据库一致性,是数据库中的数据开始是正确的,随着状态转移,总是保持正确的状态。 用户在任意时刻的任何请求返回的都是正确的结果。 数据库以一定的形式存储数据,本质是对真实世界建模,因此这里的正确是指,数据满足真实世界各种约束(例如:完整性约束)。 事务一致性即事务开始前数据库处于一致状态,结束后数据库依旧满足一致性。即事务执行前后,数据库的完整性约束没有被破坏。
需要注意的是,事务一致性不算是事务本身具有的属性,而是通过事务AID属性来保证的。C(Consistency)是咱们要通过AID来达到的目的。AID是事务实实在在具有的属性。
然后需要注意的是,这里的一致性跟分布式数据库数据读取和写入时说到的一致性是不一样的
隔离性(Isolation)
事务的隔离性是指一个事务的执行不能被其他事务干扰,多个并发事务之间要相互隔离。
由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。 事务识别数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是第二个事务修改它之后的状态,事务不会识别中间状态的数据。 这称为可串行性(Serializability)。
持久性(Durability)
事务一旦提交,它对数据库中数据的改变就应该是永久性的。即使系统发生故障也不应该对其有任何影响。
我们对数据的操作,主要分两步:
第一步:将对数据的操作记录到事务日志文件中(持久化到磁盘事务日志中);
第二部:完成第一步后,再将对数据的操作写入到磁盘上数据库的数据分区中(将修改持久化到磁盘上数据库分区中);
此时如果在第一步完成从之前,系统发生故障(断电,系统异常,重启等)。等系统恢复了,数据库引擎会检查每个数据库的事务日志,进行恢复处理,由于提交指令还未记录到磁盘的事务日志中,数据库引擎会撤销这些事务所做的所有修改,这个过程也称为回滚。
如果完成第一步但还未完成第二步,此时系统发生了故障,系统恢复后,由于提交指令已记录到磁盘的事务日志中,系统会进行恢复处理,由于数据操作还未持久化到数据分区中,数据库引擎会重做这些事务所做的所有修改以持久化到数据分区了。
口说无凭,举个例:
经典银行转账问题,假设有两个账户A和B,A账户有1000元,B账户有0元,现在要将A账户的1000元转入B账户,过程主要涉及两条SQL语句:
比如
update account set money = money - 1000 where id = 1;--账户A减少1000元
update account set money = money + 1000 where id = 2;--账户B增加1000元
此时我们没将两个SQL拉到一个事务中,由于每个SQL执行需要一点时间,如果第一句执行成功后,数据库因为一些不可阻原因崩溃了,比如断电,系统异常什么的,导致第二句SQL没能执行,系统恢复过后,那么A账户就会少1000元,B账户也不会多1000元,这个问题就非常严重了。
这个过程就需要用事务来保障:
begin transaction;--开启事务
update account set money = money - 1000 where id = 1;--账户A减少1000元
update account set money = money + 1000 where id = 2;--账户B增加1000元
commit;--提交事务
此时如果执行完第一个update
语句就断电,那么系统恢复后,事务还没有提交,那么这个事务就会回滚,也就是说,A账户的1000元不会减少,B账户的1000元也不会增加,这样就保证了数据的原子性和一致性。
如果此事务成功执行commit
语句,那么A账户的1000元就会减少,B账户的1000元也会增加,这种改变会通过IO持久化到硬盘中,这样就保证了数据的持久性。
还有个问题就是,如果A账户向B账户转1500元,但是A账户只有1000元,可能的事务如下(账户表的money字段有大于等于0的检查约束):
begin transaction;--开启事务
Begin try
update account set money = money - 1500 where id = 1;--账户A减少1500元
update account set money = money + 1500 where id = 2;--账户B增加1500元
commit;--提交事务
end try
begin catch
rollback;--回滚事务
end catch
此时执行第一个update语句时,会检查money字段的约束,发现不满足,直接报错,然后被catch到,再手动回滚,A账户的1000元不会减少,B账户的0元也不会增加,这样就体现出了事务的一致性(事务执行后不破坏数据库的完整性约束)。
这里一定要手动捕获报错异常再回滚,因为如果运行时语句错误 (例如约束冲突)在批处理中发生,则SQL Server的默认行为是仅回滚报错的语句。 可以使用 SET XACT_ABORT
语句更改此行为。 在执行 SET XACT_ABORT ON
之后,任何运行时语句错误将导致当前整个事务的自动回滚。
示例:
set xact_abort on;--设置事务只要有语句报错就回滚整个事务而不只是回滚报错的语句
begin transaction;--开启事务
update account set money = money - 1500 where id = 1;--账户A减少1000元
update account set money = money + 1500 where id = 2;--账户B增加1000元
commit;--提交事务
事务异常与报错问题文章后续详解。
事务的隔离性,现在还体现不出,且看后面的内容。
事务分类
自动提交事务:默认情况下,每条SQL语句都是一个事务,执行完毕后会自动提交,遇到错误自动回滚该语句,这种事务称为自动提交事务。
显示事务:通过begin transaction
语句开启事务,通过commit
语句提交事务,通过rollback
语句回滚事务,这种事务称为显示事务。
隐式事务:使用Set IMPLICIT_TRANSACTIONS ON
将将隐式事务模式打开,不用Begin Transaction
开启事务,当一个事务结束,这个模式会自动启用下一个事务,只用Commit Transaction
提交事务、Rollback Transaction
回滚事务即可。
分布式事务:分布式事务是指事务的参与者分布在不同的数据库服务器中,这种事务需要通过分布式事务管理器来管理,比如Oracle的Oracle XA,MySQL的MySQL XA,SQL Server的MS DTC,DB2的DB2 XA,PostgreSQL的PostgreSQL XA等。
事务并发数据访问
同时访问同一资源的用户或事务被视为并发访问资源。 并发数据访问需要一些机制,来防止多个用户试图修改其他用户正在使用的资源时产生负面影响。
事务并发带来的一致性问题
如果并发访问同一资源的事务依次执行,这种情况称为串行执行,如果是这样,不会存在任何问题。但这也导致了系统的效率低下,因为一个事务必须排着队一个一个等待前一个事务执行完毕才能执行。
所以为了保证并发的效率,可以允许多个事务同时执行的情况,比如一个事务读取的时候,也允许其他事务读取或者修改数据,但是这样就会带来一些问题,比如脏读、不可重复读、幻读等。
丢失更新
当两个或多个事务查询同一行数据,然后根据查询到的值更新该行时,会发生丢失更新问题。 每个事务都不知道其他事务的存在。 最后的更新将覆盖由其他事务所做的更新,这将导致数据丢失。
丢失更新也分两类:
第一种:
执行顺序:
时间 | 事务1 | 事务2 |
---|---|---|
T1 | 读取账户中Money的值1000 | |
T2 | 读取账户中Money的值1000 | |
T3 | 将读取到的值减500:1000-500=500 | 将读取到的值加500:1000+500=1500 |
T4 | 将500写入到Money中 | |
T5 | 提交事务 | 将1500写入到Money中 |
T6 | 提交事务 |
我们希望的是一个事务将值减少500,另一个事务将值增加500,最后的结果是Money的值为1000,但是实际上,事务1将Money的值写入为500,事务2将Money的值写入为1500,最后的结果是Money的值为1500,事务2的修改覆盖了事务1的更新,这就是第一种丢失更新。
过程演示:
第一步,先将值修改为1000:
update dbo.account set Money=1000 where Id=1;
在SQL Server Management Studio中打开两个查询,分别执行如下事务1和事务2:
先执行事务1,然后5秒钟之内执行事务2,最后查看账户表的结果:
-- 事务1
USE SampleDb;
GO
begin transaction;--开始事务
declare @@money money;--声明变量保存Money的值
select @@money=Money from dbo.account where Id=1;--读取Money的值并保存到@@money中
set @@money=@@money-500;--将@@money的值减500
waitfor delay '00:00:05'--等待5秒,等待事务2执行,方便模拟并发情况
update dbo.account set Money=@@money where Id=1;--将@@money的值写入到Money中
commit transaction;--提交事务
-- 事务2,与事务1的代码一样,只是将减500改为加500
USE SampleDb;
GO
begin transaction;
declare @@money money;
select @@money=Money from dbo.account where Id=1;
set @@money=@@money+500;--将@@money的值加500
waitfor delay '00:00:05'
update dbo.account set Money=@@money where Id=1;
commit transaction;
第三步,执行事务1,并在5秒内执行事务2。
第四步,等两个窗口执行完成,查看结果:
select * from dbo.account where Id=1;
结果如下:
Id | Money |
---|---|
1 | 1500.00 |
上面过程使用了
Waitfor delay
语句,此语句用于模拟一个耗时操作。方便我们实验
第二种:
执行顺序:
时间 | 事务1 | 事务2 |
---|---|---|
T0 | 开始事务 | |
T1 | 开始事务 | |
T2 | 读取账户中Money的值1000 | |
T3 | 读取账户中Money的值1000 | |
T4 | 将读取到的值减500:1000-500=500 | 将读取到的值加500:1000+500=1500 |
T5 | 将1500写入到Money中 | |
T6 | 将500写入到Money中 | |
T7 | 提交事务 | |
T8 | 撤销事务,余额恢复至1000元 |
我们是一个事务将值减少500,另一个事务将值增加500,但是最后撤销了,最后的结果我们希望是Money的值为500,但是实际上,事务1将Money的值写入为500,事务2将Money的值写入为1500,但是事务2撤销了,导致值回到1000,事务2的撤销操作覆盖了事务1的更新,这就是第二种丢失更新。
这个过程无法在SQL Server上复现,因为SQL Server 执行Update语句总会给资源加排他锁(X锁),直到事务结束,在此期间,其他事务无法对该资源进行修改,所以无法模拟出来,SQL Server也不存在第二类丢失更新问题。
脏读(读了未提交的数据)
脏读是指一个事务读取了另一个事务修改了但是未提交的数据,这个数据可能是不正确的,因为另一个事务可能会回滚,所以这个数据也会被回滚。
执行顺序:
时间 | 事务1 | 事务2 |
---|---|---|
T0 | 开始事务 | 开始事务 |
T1 | Update语句将账户中Money的值加500 | |
T2 | 读取账户中Money的值为1500 | |
T3 | 撤销事务,余额恢复至1000元 | |
T4 | 提交事务(此时读取到的Money数据1500为脏数据,没有意义) |
此过程,事务2将Money的值加500,此时数据库中值为1500,但是还没提交,事务1读取到Money为1500,这个数据是脏数据,因为事务2最后被撤销了,所以这个数据也是无效,最后数据库中Money的值为1000。
过程演示:
第一步,先将值修改为1000:
update dbo.account set Money=1000 where Id=1;
第二步,开一个查询窗口执行事务1(5秒内执行事务2):
USE SampleDb;
GO
begin transaction;
update dbo.account set Money=Money+500 where Id=1;--将Money的值加500
waitfor delay '00:00:05'--更新后还未提交,等待5秒,等待事务2执行查询
rollback transaction;--此时事务2执行完毕,我回滚了,Money的值恢复为1000
第三步,再开一个查询窗口执行事务2:
USE SampleDb;
GO
set transaction isolation level read uncommitted;--设置事务隔离级别为读未提交
begin transaction;
select Money from dbo.account where Id=1;--事务1更新了还未提交,读取Money的值1500,这个值是脏数据
commit transaction;
此时事务2读取到的数据为事务1修改了还未提交的数据1500
第四步:等两个窗口执行完成后,查看一下表中的数据:
select Money from dbo.account where Id=1;
结果为:
1000
不可重复读(多次读取数据值不一致)
不可重复读是指在一个事务中,多次读取同一数据,但是每次读取的结果不一样,这是因为其他事务在前一个事务两次读取过程中修改了数据并提交了。
执行顺序:
时间 | 事务1 | 事务2 |
---|---|---|
T0 | 开始事务 | 开始事务 |
T1 | 读取账户中Money的值为1000 | |
T2 | Update语句将账户中Money的值加500 | |
T3 | 提交事务,余额Money变为1500元 | |
T4 | 再次读取账户中Money的值为1500 | |
T5 | 提交事务 |
此过程,事务1先读取到Money为1000,事务2将Money的值加500,然后提交,此时数据库中值为1500,所以事务1再次读取Money的值为1500,然后事务1发现两次读取同一个数据不一致,这就是不可以重复读问题。
过程演示:
第一步,将值修改为1000:
update dbo.account set Money=1000 where Id=1;
第二步,开一个查询窗口执行事务1(5秒内执行事务2):
USE SampleDb;
GO
begin transaction;
select Money from dbo.account where Id=1;--第一次读取Money的值1000
waitfor delay '00:00:05'--等待5秒,等待事务2执行更新并提交事务
select Money from dbo.account where Id=1;--再次读取Money的值1500
commit transaction;
第三步,再开一个查询窗口执行事务2:
USE SampleDb;
GO
begin transaction;
update dbo.account set Money=Money+500 where Id=1;--将Money的值加500
commit transaction;--提交事务,Money的值变为1500
事务1执行结果如下:
幻读(多次读取数据条数不一致)
幻读是指在一个事务中,多次读取数据,但是存在两次或多次读取的数据的条数不一致,这是因为另一个事务在前一个事务两次读取过程中插入了数据并提交了,事务1就感觉像出现了幻觉一样,这种情况称为幻读。
执行顺序:
时间 | 事务1 | 事务2 |
---|---|---|
T0 | 开始事务 | 开始事务 |
T1 | 第一次读账户表中Id<10数据条数为2 | |
T2 | Insert语句插入一条满足事务1查询条件(Id<10)的数据 | |
T3 | 提交事务 | |
T4 | 再次读账户表中Id<10数据条数为3,发现多了一条数据 | |
T5 | 提交事务 | |
T6 | 我们期望的是,事务1两次读取到的数据总量应该保持一致 |
此过程,事务1先读取到Id<10的数据条数为2,事务2向表中插入一条满足事务1查询条件的数据,然后提交,此时数据库中Id<10的数据条数为3,所以事务1再次读取Id<10的数据条数为3,然后事务1发现两次读取到的数据条数不一致,这就是幻读问题。
过程演示:
第一步,先查询一下账户表的数据如下:
select * from dbo.account;
第二步,开一个查询窗口执行事务1(5秒内执行事务2):
USE SampleDb;
GO
begin transaction;
select count(*) from dbo.account where Id<10;--第一次读取Id<10的数据条数为2
waitfor delay '00:00:05'--等待5秒,等待事务2执行插入并提交事务
select count(*) from dbo.account where Id<10;--再次读取Id<10的数据条数为3
commit transaction;
第三步,再开一个查询窗口执行事务2:
USE SampleDb;
GO
begin transaction;
insert into dbo.account values(500,'王麻子');--插入一条满足事务1查询条件(Id<10)的数据
commit transaction;--提交事务
事务1执行结果如下:
事务并发一致性问题解决方法
上面我们可以看到,当许多人同时修改和访问数据库中的数据时,就会出现并发一致性问题,那么如何解决这些问题呢?
所以必须实现一个控制系统,使得在并发环境下,数据库能够保持一致性,使一个人所做的修改不会对他人所做的修改或读取产生负面影响。 这称为并发控制。
并发控制类型
并发控制的方法分两种:
悲观并发控制
悲观并发控制是指在并发环境下,事务都比较悲观,总是假设最坏的情况会发生,即总是觉得有其他事务一起访问或修改数据,所以就会在访问数据之前,会先对数据加锁,使其他用户不能对数据进行会对该事务产生负面影响的访问,直到该事务完成对数据的访问,即使同一时间没有其他事务访问,也会对访问的资源加锁。
这种方法的优点是实现简单,缺点是并发效率低下,因为并发访问同一资源的事务大部分时间都在等待其他事务释放资源上的锁,而且锁需要系统维护,也是一种资源的开销。
SQL Server事务隔离级别中的读取未提交(Read Uncommitted)、读取已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)都是悲观并发控制的实现。直接使用即可,基本不用接触底层的锁机制。
乐观并发控制
乐观并发控制是指在并发环境下,事务都比较乐观,总是假设最好的情况会发生,即总是觉得没有其他事务会访问或修改数据,所以不会对数据加锁,而是在事务提交时,才去检查数据是否被其他事务修改过,如果没有被修改,则提交事务,如果被其他用户修改了,将产生一个错误。
一般情况下,收到错误信息的用户将回滚事务并重新运行事务。
这种方法的优点是并发效率高,缺点是实现难度大,主要在以下环境中使用:数据争用不大且偶尔回滚事务的成本低于读取数据时锁定数据的成本。
SQL Server事务隔离级别中的读已提交快照(Read Committed Snapshot)和快照(Snapshot)是乐观并发控制的实现。
也可以在客户端应用程序中实现乐观并发控制,过程一般是应用程序在更新数据时,先读取数据,然后再根据读取的数据进行修改,然后提交修改。如果在提交修改时,发现数据已经被其他用户修改(数据与原来的值不一样了),则提示用户数据已经被修改,让用户重新读取数据并修改,直到提交成功。
示例SQL一:
通过判断数据是否变化实现乐观并发控制
-- 乐观并发控制
-- 1. 读取数据
Begin Tran
declare @Name varchar(50);
select @Name = Name from T where Id = 1;
-- 2. 修改数据
UPDATE T SET NAME = '张三' WHERE ID = 1 AND NAME = @Name;--通过条件判断,如果数据已经被修改,则不会更新
-- 3. 提交修改
COMMIT;
示例SQL二:
通过行版本号实现乐观并发控制
-- 乐观并发控制
-- 1. 读取数据
Begin Tran
declare @RowVersion binary(8);
select @RowVersion = RowVersion from T where Id = 1;
-- 2. 修改数据
UPDATE T SET NAME = '张三', RowVersion = NEWID() WHERE ID = 1 AND RowVersion = @RowVersion;--通过条件判断,如果行版本已经被修改,则不会更新
-- 3. 提交修改
COMMIT;
注意要让所有事务修改行时,同时修改行版本号,这样才能保证行版本乐观并发控制的正确性。
事务的隔离级别
事务的隔离级别是指在并发环境下,多个事务之间的隔离程度。事务的隔离级别越高,系统并发能力越强,但是并发效率越低,反之,事务的隔离级别越低,系统并发能力越弱,但是并发效率越高。
该隔离级别定义一个事务必须与由其他事务进行的资源或数据更改相隔离的程度。 隔离级别从允许的并发副作用(例如,脏读或幻读)的角度进行描述。
SQL Server事务通过锁控制的隔离级别有四种:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable),属于悲观并发控制方式。
SQL Server还支持使用行版本控制的另外两个事务隔离级别。 一个是已提交读快照(Read Committed Snapshot)隔离级别,另一个是快照(Snapshot)隔离级别,属于乐观并发控制方式。
其实,读已提交快照隔离级别并不能算一种隔离级别,只是将读已提交(Read Committed) 的实现方式由加锁改成了行版本控制。但本文为了方便,还是将其归为隔离级别。
读未提交(Read Uncommitted)
读未提交(Read Uncommitted)是最低的事务隔离级别,它允许第脏读、第一类丢失更新、不可重复读和幻读,因此一个读未提交隔离级别的事务可以看见(select)其他事务修改了还未提交的数据,只能保证不读取物理上损坏的数据。
不能解决第一种丢失更新问题。
语法:
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务演示:
首先,先把数据重置为初始状态:
-- 重置数据
update account set Money=1000 where id=1
然后
第一步:打开一个窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
begin tran
update account set money = money + 500 where Id = 1;
waitfor delay '00:00:10';
rollback tran
该事务更新了数据,不提交事务,然后等待10s,等的是事务2执行。
第二步:打开另一个窗口,执行如下事务2,开启事务,查询数据。
事务2
需要先设置事务的隔离级别为读未提交。
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;--设置事务的隔离级别为读未提交
begin tran
select * from account where Id = 1;--查询数据,读取到脏数据1500
commit tran
事务2执行结果:
第三步:查看一下表中的数据,因为事务1回滚了,发现数据还是初始状态,没有被事务1更新。
select * from account where Id = 1;
结果:
Id | Money | UserName |
---|---|---|
1 | 1000 | 张三 |
分析:
此时,事务2的隔离级别为读未提交,可以看到事务2直接读取到了事务1未提交的数据1500,没有任何卡顿(阻塞)。。。但是此时读到的数据是未提交的脏数据。
所以读未提交隔离级别下的事务连脏读都避免不了,所以更是解决不了,不可重复读和幻读问题。
至于为什么啥问题都解决不了,我们下一篇讲锁的时候再分析。
读已提交(Read Committed)
读已提交(Read Committed)是 SQL Server 默认的事务隔离级别,它只允许不可重复读和幻读,不允许脏读。一个读已提交隔离级别的事务只能看见(select)其他事务已经提交的数据,解决了脏读。
不能解决第一种丢失更新问题。
语法:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
事务演示:
首先,先把数据重置为初始状态:
-- 重置数据
update account set Money=1000 where id=1
然后
第一步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
begin tran
update account set money = money + 500 where Id = 1;
waitfor delay '00:00:10';--等待事务2执行查询
rollback tran
该事务更新了数据,不提交事务,然后等待10s,等的是事务2执行。
第二步:打开另一个查询窗口,执行如下事务2,开启事务,查询数据,然后提交事务。
事务2
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;--设置事务的隔离级别为读已提交,默认就是读已提交,因此可以省略
begin tran
select * from account where Id = 1;
commit tran
事务2执行结果:
第四步:查看一下表中的数据,因为事务1回滚了,发现数据还是初始状态,没有被事务1更新。
select * from account where Id = 1;
结果:
Id | Money | UserName |
---|---|---|
1 | 1000 | 张三 |
分析:
此时,事务2的隔离级别为读已提交,可以看到事务2虽然读取到了数据,但是是阻塞过后的,也就是事务1执行waitfor
等待的这段期间,在这期间事务2要查询(select)该数据就被阻塞,直到事务1提交事务或者回滚事务,事务2才能查询到数据,所以事务2只能读取(select)到其他事务修改并提交或者回滚过后的数据,否则就阻塞。
如果将事务1最后的rollback
改为commit
,那么事务2就能读取到事务1提交的数据,也就是1500。
这就解决了脏读,但是解决不了不可重复读和幻读,不信大家可以自己尝试一下。。
至于怎么做到的,为什么又避免不了不可重复和幻读,我们下一篇讲锁的时候再分析。
可重复读(Repeatable Read)
可重复读(Repeatable Read)只允许幻读,不允许脏读和不可重复读。一个可重复读隔离级别的事务只能看见(select)其他事务已经提交的数据,解决了脏读,并且在该事务内多次查询同一数据时,结果始终是一致的,解决了不可以重复读。
可以解决第一种丢失更新问题。
语法:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
事务演示:
首先,先把数据重置为初始状态:
-- 重置数据
update account set Money=1000 where id=1
然后
第一步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;--设置事务隔离级别为可重复读
begin tran
select * from account where Id = 1;--第一次查询数据
waitfor delay '00:00:10';--等待10s,等待事务2执行更新数据并提交
select * from account where Id = 1;--第二次查询数据
commit tran
第二步:打开另一个查询窗口,执行如下事务2,更新数据,然后提交。
事务2
USE SampleDb;
GO
begin tran
update account set money = money + 500 where Id = 1;--更新数据
commit tran
事务1执行结果如下:
第三步:查看一下表中的数据,发现数据被事务2更新了。
select * from account where Id = 1;
结果:
分析:
可以看到,事务1两次查询结果一致,解决了不可重复读问题,同时事务2也成功更新数据。
怎么做到的呢,事务1在执行第一次select
查询之后,提交事务之前,事务2要执行update
更新同一个数据,就会被阻塞,直到事务1执行完所有操作后提交事务,事务2才能执行update
更新数据并提交,所以事务1两次查询结果一致,解决了不可重复读问题。
这就解决了脏读和不可重复读,但是解决不了幻读,不信大家可以自己尝试一下。。
至于怎么做到的,为什么又避免不了幻读,我们下一篇讲锁的时候再分析。
串行化(Serializable)
串行化(Serializable)是最高的事务隔离级别,在可重复读基础上增加了键范围锁来防止脏读、不可重复读和幻读。一个串行化隔离级别的事务只能看见(select)其他事务已经提交的数据,并且在该事务内多次查询同一数据时,结果始终是一致的。而且多次以同一条件查询的数据也是一致的。
同样可以解决第一种丢失更新问题。
语法:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
事务演示:
首先,先把数据重置为为如下状态:
-- 重置数据
truncate table account--清空表数据
--重新插入3条数据
insert into account values(1000,'张三')
insert into account values(1200,'李四')
insert into account values(1500,'王麻子')
查看一下数据:
select * from account
然后
第一步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;--设置事务隔离级别为串行化
begin tran
select * from account where Id < 5;--第一次查询Id<5数据
waitfor delay '00:00:10';--等待10s,等待事务2执行插入数据并提交
select * from account where Id < 5;--第二次查询Id<5数据
commit tran
第二步:打开另一个查询窗口,执行如下事务2,插入一条数据。
事务2
USE SampleDb;
GO
begin tran
insert into account values(2000,'赵六')
commit tran
事务1执行结果如下:
第三步:事务1,2执行完后查询一下表中的数据:
select * from account
结果如下:
分析:
可以看到事务1两次查询的多条数据一致,解决了幻读问题,同时事务2也成功插入了一条数据。
没有问题,整个过程是怎样的呢,事务1在执行第一次select
查询之后,提交事务之前,事务2要执行insert
插入数据,就会被阻塞,直到事务1提交事务,事务2才能执行insert
插入数据并提交,所以事务1两次查询结果一致,解决了幻读问题。
然后我们来分析一下这样一个问题:
插入一条不满足事务1查询条件的数据会被阻塞吗?
我们事务1执行的查询条件是Id<5
,然后事务2插入的数据正好是Id=4
,满足事务1的查询条件,事务2被阻塞呢,但是如果事务2插入的数据Id大于或等于5的数据,不满足事务1的查询条件,事务2还会不会被阻塞呢?
很多人说在序列化隔离级别下,事务会直接将表锁住来进行控制,来达到两个事务串行化顺序执行。如果是加表锁的话,事务2即使插入Id不满足事务1查询条件也会被阻塞吧。
我们来试试。
首先第一步:先插入一条数据
insert into account values(3000,'钱七')
然后表中的数据如下:
select * from account
此时如果再插入数据,Id必然大于5,不满足事务1的查询条件,我们再来看看事务2的插入操作会不会被阻塞:
第二步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;--设置事务隔离级别为串行化
begin tran
select * from account where Id < 5;--第一次查询Id<5数据
waitfor delay '00:00:10';--等待10s,等待事务2执行更新数据并提交
select * from account where Id < 5;--第二次查询Id<5数据
commit tran
第三步:打开另一个查询窗口,执行如下事务2,插入一条数据。
事务2:
USE SampleDb;
GO
begin tran
insert into account values(4000,'王八')--插入一条数据,可以预测插入的Id为6
commit tran
执行过后:
事务1的执行结果毫无疑问:
事务1执行过后,提交之前,这个过程会等待10秒,在这期间,我们执行事务2,插入一条数据,此时事务2没有被阻塞,插入成功,提交事务。测试中当事务2执行插入完成时,甚至事务1都还没有执行完。
查看一下表中的数据:
select * from account
事务2插入的数据Id为6,不满足事务1的查询条件,事务2没有被阻塞。
因此,串行化隔离级别下,事务读取操作不是通过加表锁来控制的,而是通过一种叫键范围锁的方式来实现的,后续讲锁时会提到。
上面说到的 “不是通过加表锁来控制的”不是事务不会给表加锁,而是不会给表加S锁来控制,比如事务会给表加IS锁。关于锁,请看后续文章
实际上串行化(Serializsable)隔离级别就是在可重复读(Repeatable Read)隔离级别的基础上,加了一个键范围锁(where)来实现的。
已提交读快照(Read Committed Snapshot)
将数据库选项READ_COMMITTED_SNAPSHOT
设置为ON
时(默认时OFF
),所有的读已提交隔离级别的事务都将使用已提交读快照隔离级别。不会读取其他事务修改但还未提交的数据,解决了脏读问题,但是允许不可重复读和幻读。
启用方式:
ALTER DATABASE [DBName] SET READ_COMMITTED_SNAPSHOT ON
或者
已提交读快照隔离级别是一个乐观并发控制的事务隔离级别。与读已提交对比就是减少了锁的阻塞,改用行版本和快照机制来控制,提高了并发性能,而且不会出现死锁。
但是值得注意的时,这是一个数据库选项,当把READ_COMMITTED_SNAPSHOT
设置为ON
时,所有的读已提交隔离级别的事务都将使用已提交读快照隔离级别,而不是只有某些事务使用已提交读快照隔离级别。
事务演示:
首先,先把数据重置为初始状态:
-- 重置数据
update account set Money=1000 where id=1
然后
第一步:启用数据库的读已提交快照选项:
ALTER DATABASE SampleDb SET READ_COMMITTED_SNAPSHOT ON
第二步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
begin tran
update account set money = money + 500 where Id = 1;
waitfor delay '00:00:10';--等待事务2执行查询
commit tran
该事务更新了数据,不提交事务,然后等待10s,等的是事务2执行。
第三步:打开另一个查询窗口,执行如下事务2。
事务2
需要先设置事务的隔离级别为读已提交。此时的读已提交使用行版本和快照机制来控制,而不是加锁。
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;--设置事务的隔离级别为读已提交
begin tran
select * from account where Id = 1;
commit tran
事务2查询到的数据:
Id | Money | UserName |
---|---|---|
1 | 1000 | 张三 |
第四步:等事务1,2执行完查看一下表中的数据: |
select * from account where Id = 1;
Id | Money | UserName |
---|---|---|
1 | 1500 | 张三 |
根据执行结果和过程分析:
事务2的隔离级别为读已提交,但是我们启用了数据库的READ_COMMITTED_SNAPSHOT
选项,这使所有的读已提交隔离级别的事务都使用快照和行版本方式控制并发。
事务1执行完Update
语句,然后不提交等待事务2执行查询,事务2直接没有被阻塞的查询(select)到数据1000
,并且是事务1执行Update
语句前的数据,这个不是事务1修改了而没有提交的脏数据1500
,一旦事务1提交更改,事务2就是再读取就是更改后的数据1500了,所以,避免了脏读,也不会阻塞,提高了并发性能。
但是不能解决不可重复读和幻读问题,大家可以将事务2改成下面这样试试,事务1不变:
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;--设置事务的隔离级别为读已提交
begin tran
select * from account where Id = 1;--第一次查询数据为1000
waitfor delay '00:00:10';--等待事务1提交事务
select * from account where Id = 1;--第二次查询数据为1500
commit tran
执行结果:
也不能解决第一种丢失更新问题,不信大家可以试试。
快照(Snapshot)
快照(Snapshot)是一个新增的隔离级别,也是一个乐观并发控制的事务隔离级别。可以避免脏读、不可重复读和幻读。快照隔离级别是通过在事务开始时创建一个快照来实现的,该快照包含了事务开始时存在的数据行版本,因此快照隔离级别的事务不会阻止其他事务修改和写入数据,写入数据的事务也不会阻止快照隔离级别事务的读取数据。在事务中,所有的查询都是基于该快照,而不是基于当前数据库的状态。这样就可以避免脏读、不可重复读和幻读。
需要注意的是,快照隔离级别的事务读取数据过后,如果其他事务修改了该数据,不管修改数据的事务有没有提交,虽然快照隔离级别的事务都不会读取到修改后的数据,但是如果快照事务要修改该数据,就会报错,然后回滚该快照事务,用户可以选择重新执行该事务,这样通过报错形式,防止丢失更新问题。报错如下:
Msg 3960, Level 16, State 1, Line 1
Snapshot isolation transaction aborted due to update conflict. You cannot use snapshot isolation to access table 'dbo.account' directly or indirectly in database 'SampleDb' to update, delete, or insert the row that has been modified or deleted by another transaction. Retry the transaction or change the isolation level for the update/delete statement.
使用事务的快照隔离级别需要先把数据库的ALLOW_SNAPSHOT_ISOLATION
设置为ON
时。才能使用。
启用方式:
ALTER DATABASE [DBName] SET ALLOW_SNAPSHOT_ISOLATION ON
或者
然后就可以通过如下方式来设置事务的快照隔离级别了:
SET TRANSACTION ISOLATION LEVEL SNAPSHOT
事务演示:
首先,先把数据重置为初始状态:
-- 重置数据
truncate table account--清空表数据
--重新插入3条数据
insert into account values(1000,'张三')
insert into account values(1200,'李四')
insert into account values(1500,'王麻子')
查看一下数据:
select * from account
然后
第一步:打开数据库的ALLOW_SNAPSHOT_ISOLATION
选项:
ALTER DATABASE SampleDb SET ALLOW_SNAPSHOT_ISOLATION ON
第二步:打开一个查询窗口,执行如下事务1(10秒内执行事务2)。
事务1:
USE SampleDb;
GO
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;--设置事务隔离级别为快照隔离级别
begin tran
select * from account where Id < 5;--第一次查询Id<5数据
waitfor delay '00:00:10';--等待10s,等待事务2执行更新数据并提交
select * from account where Id < 5;--第二次查询Id<5数据
commit tran
第三步:打开另一个查询窗口,执行如下事务2,插入一条数据。
事务2
USE SampleDb;
GO
begin tran
insert into account values(2000,'赵六')--插入一条数据,没有被阻塞
commit tran
事务1执行结果如下:
第四步:等事务1,2执行完后查询一下表中的数据
select * from account
分析:
很明显,快照隔离级别解决了幻读问题,当然也包括了脏读和不可重复读问题。
但是事务2的插入操作却没有被阻塞,也不会出现死锁,大大提高了并发效率,这是跟串行化隔离级(SERIALIZABLE)别不同的地方,串行化隔离级别会阻塞事务2的插入操作。
但是快照隔离级别也有缺点,就是如果快照隔离级别的事务读取(SELECT)数据过后,数据被其他事务修改了,那么该快照事务想要再修改数据时,就会报错,然后回滚该快照事务,好处是避免了丢失更新,但报错终归不是什么好事,需要额外方式解决,比如重新执行该快照事务,直到成功,这就需要客户端写更多代码处理了。
相比之下串行化隔离级别的事务,如果读取数据过后,其他事务要想修改数据,就会被阻塞,其他事务就必须等待该事务执行完毕,然后再做修改,如此一来虽说是并发效率就会大大降低,但是大家都排着队一个一个的成功修改了数据,而不会报错(死锁除外)。
两种隔离级别各有优缺点,根据实际情况选择合适的隔离级别。