8.2 事务处理
无论采用哪种并发控制模型,对于事务的理解是至关重要的。事务是SQL Server 中任务的基本单位。典型地,它是由几个读取和修改数据库的SQL 命令所组成的,但是直到COMMIT 命令被执行以后,修改操作才被认为是终结了(至少对于一个显式事务来说是这样的)。一般地,当本章论及一个修改操作或者一个读取操作时,实际上是在讨论执行该数据访问/读取操作的事务,而不一定是指一个单独的SQL 语句。
当论及写者阻塞读者时,意思是只要执行写操作的事务仍然是活动的,其他进程是不可能读取被修改的数据的。
关于事务控制的技术性细节以及ANSI 事务属性和事务隔离级别的讨论,可以参考《Microsoft SQL Server 2005技术内幕:T-SQL 程序设计》一书。在本卷中将深入研究事务隔离级别以及各种不同隔离级别中的锁定类型(会和T-SQL 程序设计卷稍有重复)。本卷中也不会提及处理事务的编程方面的内容,除介绍隐式事务以及显式事务之间的区别外。
隐式事务是任何单独的INSERT、UPDATE 或者DELETE 语句。也可以把SELECT 语句看作隐式事务,尽管没有相关SELECT 语句的记录会写到事务日志(transaction log)里去。无论影响了多少行(how many rows are affected),语句必须表现出一个事务的全部ACID 属性(会在下一个小节中介绍)。显式事务是以BEGIN TRAN 语句开头,以COMMIT TRAN 或者ROLLBACK TRAN 语句结尾的。这里所举的大多数例子采用了显式事务,因为这是唯一可以展示在事务进行中的SQL Server 状态的方法。譬如,许多类型的锁只在事务的持续时间(duration)内保留。我们可以开始一个事务,做一些操作,通过元数据来察看SQL Server 持有什么锁,然后结束这个事务。当事务结束时,锁就被释放了,也就无法再观察到它们了。
ACID属性
事务处理保证了SQL Server 数据库的一致性(consisitency)以及可复原性(recoverability)。它保证了所有事务都是作为一个单独的任务单元被执行的——即使发生了硬件故障或者一般的系统故障。这样的事务被认为具有ACID 属性:原子性(atomicity)、一致性(consistency)、隔离性(isolation)以及持久性(durability)。除了保证显式多语句事务能够维持ACID 属性以外,SQL Server 还确保隐式事务同样维持ACID 属性。
下面是一个用伪代码写的显式ACID 事务的例子:
BEGIN TRANSACTION DEBIT_CREDIT
Debit savings account $1000
Credit checking account $1000
COMMIT TRANSACTION DEBIT_CREDIT
接下来逐个分析ACID 的属性。
原子性(Atomicity)
SQL Server 保证事务的原子性。原子性指的是每个事务被视为全部或者什么也没有——不是提交(commit)就是中止(abort)。如果一个事务提交了,它造成的所有效果都会被保留。如果中止了,其所有效果都会被撤销。在之前的DEBIT_CREDIT 例子中,如果储蓄户头的提款反映到数据库中而支票户头的存款没有,那么这笔资金就会从数据
库中彻底丢失——也就是说,储蓄户头的资金被扣除了但是支票户头上却没有增加。如果相反的情况发生(支票户头的存款到位而储蓄户头却没被提款),客户的支票户头就会神奇地增加,而实际上却没有相应的客户现金存款或者转帐的事情发生。正是由于SQL Server 的原子性,取款和存款都必须完成,否则,两个事件都不会完成。
一致性(Consistency)
一致性属性确保事务不允许系统到达一个不准确的逻辑状态——数据必须总是保持逻辑上的正确。即使在发生系统故障时,约束和规则也必须得到承兑。在存款取款(DEBIT_CREDIT)的例子中,逻辑规则是:钱是不能凭空产生或销毁的,对于每个(收支)条目必须有一个相应的抵衡条目产生(一致性一般是被原子性、隔离性以及持久性所涵盖的,并且在大多数情况下会在概念上与它们产生重复)。
隔离性(Isolation)
隔离性会将并发事务与其他未完成(incomplete)事务的更新操作分隔开。在DEBIT_CREDIT 的例子中,当该事务正在执行的时候,别的事务是无法看到进行中的任务的。譬如说,如果其他事务要读取取款以后的储蓄户头上的余额,而接着DEBIT_CREDIT 事务被中止了,其他事务就会在一个逻辑上根本不存在的余额上进行操作。
SQL Server 会在事务之间自动实现隔离。它采用锁定数据或者新建行版本来使多个并发用户能够操作数据,以防止导致不正确结果的副作用发生,并且如果用户想要序列化他们的请求时(也就是将请求排队等候并且一次只处理一个的方式)使其与预期值不同。这个可串行化特性也是SQL Server 所支持的隔离级别中的一种。SQL Server 支持多种隔离级别,因此用户能够在锁定多少数据和持有多长时间的锁之间进行适当权衡(tradeoff)并且选择是否允许用户访问以前版本的行数据。这种权衡也就是大家熟知的并发性 vs. 一致性(concurrency vs. consistency)。
持久性(Durability)
当事务提交以后,SQL Server 的持久性属性就会确保该事务的作用持续存在(即使发生系统故障)。如果在事务进行过程中发生了系统故障,事务就会被完全撤销,不会在数据上遗留部分作用。譬如说,如果在事务的中间过程(事务提交之前)发生了停电,整个事务就被回滚(rollback)到系统重启时的状态。如果在事务的提交确认被发送到调用的应用程序之后立刻发生了停电,数据库会确保该事务的存在。预写式日志(write-ahead logging)以及SQL Server 启动恢复阶段(recovery phase)的事务自动回滚/前滚机制能够确保持久性。
事务总是全部支持这四种ACID 属性的。事务可能还会表现出一些另外的行为。一些人把这些行为叫做“依赖性问题”或者“一致性问题”,而我并不认为它们是问题(problems)。它们仅仅是可能存在的行为,而用户能够决定允许哪些和阻止哪些。用户对于隔离级别的选择决定了下列这些行为中哪种是被允许的。
n 丢失更新(Lost updates) 当两个进程读取相同数据并且都处理该数据(修改它的值),然后都尝试更新原来的数据成新的值时,这种行为就会产生了。第二个进程可能完全覆盖掉第一个所完成的更新。譬如,假设在接收室(receiving room)中有两个职员负责接收部件并在存货数据库中添加新的装运记录。职员A 和职员B 都接收小部件的装运。他们都检查了当前的库存量并且看到当前储存着25 个小部件。职员A 的装运量为50 个小部件,因此他将25 加上50 以后更新了当前的值为75。职员B 的装运量为20 个小部件,因此她将25 加上她原先读到的20 以后更新了当前值为45,把职员A 所处理的50 个新部件给完全覆盖掉了。职员A 的更新丢失了。丢失更新是这些行为当中惟一一个用户可能在所有情况下都想避免的行为。
n 脏读(Dirty reads) 这种行为在一个进程读取未提交数据时会产生。如果一个进程修改了数据但是尚未提交修改,而另一个正在读取数据的进程会读到这个修改从而导致一种不一致的状态发生。譬如,职员A 已经将原来的25 个小部件值更新为75,但是在提交之前,一个销售员看到了当前值为75并在第二天发送了60 个小部件给客户。如果职员A 意识到小部件存在缺陷并将其送回生产商的话,销售员其实就是完成了一次“脏读”,并且基于这个未提交数据采取了行动。默认情况下,脏读是不允许的。谨记:更新数据的进程是无法控制别的进程在它提交之前读取其数据的。这是由读取数据的进程来决定是否想要读取未必会被提交的数据。
n 不可重复读(Non-repeatable reads) 这种行为又被叫做“不一致分析”(inconsistent analysis)。如果一个进程在同一事务中分别以两个读操作读取相同资源时可能会得到不同的值,这就是不可重复读。这种情况可能发生在第一个进程执行两次读操作的间隔内别的进程修改数据的时候。在接收室的例子中,假设一个主管进来对当前库存做了一次抽查,并且在她的计算器上做加法。当她完成以后,为了再一次确认结果,她返回到第一个职员那里。然而,如果职员A 在主管的两次查询之间接收了新的小部件的话,两次得到的总额就会不同,而这样的读就是不可重复的。
n 幻影(Phantoms) 这种行为产生于一个数据集内的部分数据被修改时。这只发生在查询中带有一个谓词(predicate)的时候——譬如WHERE count_of_widgets < 10 这样的。如果在同一事务中使用相同谓词的两次SELECT 操作返回不同数量的结果集,幻影就产生了。举个例子,(接着前面的例子),主管仍在对库存进行抽查。这次她在接收室里来回踱步并且留意哪个职员拥有的小部件数量少于10 个。当完成这个名单以后,她回来并告诉每个人这个低数量的统计。然而,如果在她第一次“漫游”时一个拥有少于10 个部件的职员在休息以后回到工作岗位但却没有被主管抽查到,即使他符合谓词中的标准,这个职员也不会成为主管名单上的一员了。这个额外的职员(或者行 )就被看作是一个幻影。
事务的行为取决于隔离级别。如前所述,可以通过使用SET TRANSACTION ISOLATION LEVEL <isolation_level> 命令来设定一个恰当的隔离级别以决定上述四种行为中哪些是被允许的。并发控制模型(乐观的或者悲观的)决定了隔离级别是如何实现的——或者更明确地讲,决定了SQL Server 是如何确保用户所不想要的行为不发生的。
隔离级别(Isolation Levels)
SQL Server 2005 支持五种隔离级别来控制读操作的行为。其中的三个只在悲观并发模型中可用,一个只在乐观并发模型中可用,剩下的一个在两种模式下都是可用的。现在先来了解一下这些级别,但是对于隔离级别的一个全面理解还需要建立在对锁定以及行版本控制有一定的理解的基础之上。在下面的关于隔离级别的描述中,会提到某个级别是由锁或者行版本所支持的,但是请记住,锁定和行版本控制将在本章稍后再进行深入讨论。
未提交读(Uncommitted Read)
对于未提交读隔离级别来说,除了丢失更新以外,以上提到的所有行为都有可能发生。用户的查询可能读到未提交的数据,并且不可重复读以及幻影都是有可能存在的。未提交读是通过使读操作不占有任何锁来实现的,而且由于SQL Server 不再尝试获取锁,也就不会被其他进程所占有的相冲突的锁阻塞住了。用户的进程能够读取其他进程已经修改过但尚未提交的数据。尽管该模式通常并不是理想的选择,但是由于未提交读,用户就不会卡在等待锁上,并且读操作不占有任何锁而可能影响到其他正在读取或者写入数据的进程。
当采用未提交读时,用户是放弃了对于高一致性数据(strongly consistent data)的把握而趋向于支持系统的高并发能力,使用户不会再互相锁定住对方。这样,何时才应该选择未提交读呢?显然,每笔数据都须保证平衡的金融交易是不适合的。而对于某些决策支持分析(decision support analysis)来说可能会很合适(譬如说,当需要察看销售走势时),因为没有必要做到完全精确而且更高的并发性所带来的均衡也是相当值得的。
未提交读是针对阻塞太频繁问题的一种悲观的解决方案,因为它只是忽略了锁而不保障事务的一致性。
已提交读(Read Committed)
SQL Server 2005 支持两种已提交读的隔离级别(数据库引擎的默认级别)。这种隔离级别既可以是乐观的也可以是悲观的,取决于数据库的READ_COMMITED_ SNAPSHOT 设置。因为这个数据库选项默认是关闭的,所以该隔离级别在默认情况下是采用悲观并发控制。除非另有说明,当本书中提到“已提交读”隔离级别的时候,指的是此隔离级别的这两种版本。(为了加以区别)当提到悲观实现时采用的是“已提交读(锁定)”,而对于乐观实现采用的则是“已提交读(快照)”。
已提交读隔离级别保证了一个操作不会读到别的程序已经修改但尚未提交的数据。(也就是说,它决不会读到逻辑上根本未曾存在过的数据)对于已提交读(锁定),如果别的事务正在更新数据并因此在数据行上持有排他锁(exclusive locks),用户的事务就必须等待这些锁释放以后才能使用这个数据(无论是读取还是修改)。同样地,事务必须至少在要被访问的数据上加上共享锁(share locks),这意味着该数据无法被其他人使用。共享锁不会妨碍其他人读取数据,但是它会使其他人需要等待才能更新数据。默认情况下,共享锁在数据处理过后就被释放了——它们无需在事务的持续时间内(甚至无需在语句的持续时间内)被保留(也就是说,如果获取了共享行锁,即使语句可能还需要处理更多行数据,每个行级锁可以在当前行被处理完毕以后马上释放掉)。
已提交读(快照)也能确保一个操作不会读到未提交数据,但不是通过迫使其他进程等待的方式。对于已提交读(快照),每当一行数据被更新以后,SQL Server 就会生成该行数据前一次已提交值的一个版本(version)。被修改的数据仍旧被锁定着,但是其他进程可以看到该数据在更新操作开始之前的版本。
可重复读(Repeatable Read)
可重复读是一种悲观的隔离级别。它在已提交读的基础上增加了新的属性:确保当事务重新访问数据或查询被再一次执行时数据将不再发生改变。换句话说,在一个事务中执行相同的查询两次是不会看到由其他用户的事务所造成的任何数据上的改变的。然而,可重复读隔离级别还是允许幻影行的出现。
在某些情况下,防止不可重复读是用户向往的一种安全措施。但是世上没有免费的午餐。这种额外的安全措施所带来的开销是事务中所有的共享锁必须保留到事务完成(COMMIT 或者ROLLBACK)为止。(排他锁必须总是保留到事务结束为止,无论采用何种隔离级别或者并发模型,这样事务才能在需要时被回滚。如果锁较快就释放了,就不太可能完成撤销工作,因为其他并发事务有可能已经使用了同一数据,并且修改了它的值)。只要事务是打开的,没有其他用户可以修改被该事务所访问的数据。显然,这会严重降低并发性和性能。如果事务不保持简短或者如果编写应用程序时没有能够注意到这样潜在的锁竞争问题,当一个进程正在等待锁被释放时,SQL Server 就会表现出“挂起”(hang)的状态。
注意 用户可以通过会话(session)选项LOCK_TIMEOUT 来控制SQL Server 等待锁被释放的时长。这是一个SET 选项,因此只能在一个单独的会话中支配其行为。无法对整个SQL
Server 设置一个LOCK_TIMEOUT
值。更多相关内容,请参考SQL
Server 联机丛书中的LOCK_TIMEOUT
章节。
快照(Snapshot)
快照隔离是一种乐观隔离级别。类似于已提交读(快照),如果当前版本被锁定住时,它允许进程读取已提交数据的早期版本。快照隔离和已提交读(快照)的区别与早期版本该有多早这个问题相关(在行版本控制这一小节将了解具体细节)。尽管快照隔离所避免的行为和可串行化所避免的是相同的,但是快照隔离并不是真正意义上的可串行化隔离级别。对于快照隔离,可能会有两个事务在同时执行,并引起一个在任何序列化执行中都不可能产生的结果。表8-1 展示了两个同时进行的事务的例子。如果它们并行地运行,最终会交换pubs数据库中titles表里两本书的价格。然而,不存在一种序列化执行的方式最终导致数值的交换,无论是先运行事务1 然后再进行事务2 还是先运行事务2再进行事务1,任何序列顺序最终将导致两本书拥有相同的价格。
表8-1 两个同时进行而无法依次(serially)运行的事务
|
时刻 |
事务 1 |
事务 2 |
|
1 |
USE pubs DECLARE @price money BEGIN TRAN |
USE pubs DECLARE @price money BEGIN TRAN |
|
2 |
SELECT @price = price FROM titles WHERE title_id = 'BU1032' |
SELECT @price = price FROM titles WHERE title_id = 'PS7777 |
|
3 |
UPDATE titles SET price = @price WHERE title_id = 'PS7777' |
UPDATE titles SET price = @price WHERE title_id = 'BU1032 |
|
3 |
COMMIT TRAN |
COMMIT TRAN |
可串行化(Serializable)
可串行化也是一种悲观隔离级别。可串行化隔离级别在可重复读的基础上增加了新的属性:确保在重新执行查询时,SQL Server不会在中间的过渡时期(interim)增加新的行。换句话说,如果在同一事务中相同的查询被执行两次的话,幻影就不会出现了。可串行化也因此成为最健壮的悲观隔离级别,因为它防止了之前所述的所有可能的不合人意的行为——也就是说,它不允许未提交读、不可重复读和幻影,并且还保证了事务依次执行。
防止幻影是另一种合人所愿的安全措施。但是需要再次强调的是,天下是没有免费午餐的。这种额外的安全措施的开销与可重复读的差不多——事务中的所有共享锁必须保留到事务完成为止。另外,执行可串行化隔离级别不仅需要锁定已读数据,还需要锁定那些不存在的数据!举例来说,假设在一个事务中执行了一个SELECT 语句来读取所有邮政编码(Zip Code)介于98 000 和98 100 之间的客户数据,并且在第一次执行以后没有记录满足这个条件。为了实现可串行化隔离级别,就必须锁住那些潜在记录的范围(ZIP Code 介于98 000 和98 100 之间),这样如果相同的查询再一次执行,仍然不会有记录满足这个条件。SQL Server 采用了一种称为“键范围锁”(key-range lock)的特殊锁来处理这种情况。键范围锁要求限定范围值的那个字段上存在索引(在这个例子中就是包含ZIP Code 的那个字段)。如果在该字段上没有索引,可串行化隔离就会申请一个表级锁(table lock)。在锁定章节中将会深入探讨不同种类的锁。可串行化隔离级别之所以得其名是因为同时运行多个可串行化的事务等价于每次执行其中的一个(也就是依次地、序列化地)。
举例来说,假设事务A、B、C 同时运行于可串行化级别上并且每个事务都试图更新相同范围内的数据。如果事务在该数据范围中占有锁的顺序依次为B、C和A,同时运行这三个事务的结果是和它们按照B、C、A 的序列顺序运行的结果一致的。可串行化并不意味着这个顺序是预先知道的。可以将这个顺序看作是概率事件(chance event)。即使在一个单用户的系统上,事务进入队列的顺序也根本就是随机的。如果批的顺序对于应用程序来说十分重要,应该将其实现为一个纯批处理系统。可串行化仅仅意味着存在一种依次运行事务的方式以得到与同时运行它们时相一致的结果。在接下来有关快照隔离级别的讨论中将会展示一个事务不是可串行化的例子。
附加信息 关于对ANSI 隔离级别的正式定义的有趣批评,请阅读本书的参考内容中所包含的一份名为A Critique of ANSI SQL Isolation Levels的技术报告(由微软研究中心出版)。
表8-2 总结了每种隔离级别可能存在的行为,并标注了用于实现该级别的并发控制模型。可以看到“已提交读”和“已提交读(快照)”在它们所允许的行为方面完全一致,但实现这些行为的方式是不同的——一个是悲观的(锁定),而另一个则是乐观的(行版本控制)。可串行化以及快照对于所有的行为也具有相同的No 值 ,但是其中一个是悲观的而另一个则是乐观的。
表8-2 每种隔离级别所允许的行为
|
隔离级别 |
脏读 |
不可重复读 |
幻影读 |
并发控制模型 |
|
未提交读 |
Yes |
Yes |
Yes |
悲观 |
|
已提交读 |
No |
Yes |
Yes |
悲观 |
|
已提交读(快照) |
No |
Yes |
Yes |
乐观 |
|
可重复读 |
No |
No |
Yes |
悲观 |
|
快照 |
No |
No |
No |
乐观 |
|
可串行化 |
No |
No |
No |
悲观 |




