最近,我们有一些关于数据库事务的文章,有关强制执行四个 ACID 属性(原子性、一致性、隔离性、持久性)。在今天的文章中,我们将研究关系数据库(RDBMS)用于强制执行 ACID 属性的另一种机制,即对象锁定。具体来说,我们将了解它是什么,它在 RDBMS 事务中扮演什么角色,以及锁定可能导致的一些副作用。虽然数据库对象锁定可能是一个相当技术性和复杂的主题,但我们会以浅白的用语解释它并尽可能简单易明。
简单地说,对象锁定是一种防止同时访问数据库中数据的方法,以避免数据不一致。为了说明对象锁定的工作原理,假设两个银行柜员试图为两笔不同的交易更新同一个银行帐户。两个柜员都检索(即复制)账户记录。柜员 A 申请并保存一笔交易。柜员 B 将不同的交易应用到自己保存的副本,并保存结果,这一笔交易会覆盖柜员 A 输入的交易。现在记录不再反映第一笔交易,就好像它从未发生过一样!
修复方法是在任何用户修改记录时锁定该记录,使其他用户不能同时更改它。这可以防止记录被错误地覆盖,但一次只允许处理一条记录,从而锁定需要在同一实例中编辑记录的其他用户。因此,任何试图检索相同记录进行编辑的人都会因为锁定而被拒绝写入访问(取决于具体的实现,他们可能仍然能够以只读状态查看记录)。一旦记录被保存(或取消编辑),锁定就会被释放。通过防止保存记录以覆盖其他更改,数据完整性(ACID 中的 I)就得以保持。
上面的示例演示了一个记录级锁定的实例。现在想象一下,如果上面的两个银行柜员为两个不同的客户提供服务,但他们的帐户都在同一个账本中。在这种情况下,整个账本(或者,一个或多个数据库表)将需要被锁定以进行编辑。可以想象,锁定整个表会导致大量不必要的等待。如果柜员可以从账本中移除一页,其中包含当前客户的帐户(可能还有一些其他帐户),那么可以同时为多个客户提供服务,前提是每个客户的帐户都位于与其他客户不同的页上。如果两个客户在同一页上都有帐户,则一次只能服务一个客户。这类似于数据库中的页级锁定。
有四种类型的锁定。它们的粒度越来越细:
粒度锁定的使用使用可能导致称为“死锁”的情况。当使用增量锁定(锁定一个实体,然后锁定一个或多个附加实体)时,可能会发生死锁。例如,我和我的妻子经常在我们的个人帐户之间转账。如果我们每个人都要求柜员获取我们的个人帐户信息,以便我们可以将一些钱转入另一方的帐户,那么这两个帐户基本上会被锁定。然后,当我们的柜员试图将钱转入彼此的帐户时,他们都会发现另一个帐户“正在使用”,迫使他们等待帐户被释放。不知不觉中,两个柜员一直在等对方,直到对方放弃退回帐户,双方都无法完成交易!值得庆幸的是,有人已经设计了各种技术来规避此类问题。这些将在下一部分中讨论。
关系数据库系统(RDBMS)在修改(例如,更新或删除)表记录时采用各种锁定策略来强制实施事务 ACID 属性。有时,当两个并发事务无法进行时,可能会发生死锁,因为每个事务都在等待对方释放锁定。在本系列的第 1 部分中,我们知道了什么是关系数据库中的对象锁定、不同类型的锁定和死锁。在今天的后续文章中,我们将比较悲观锁定和乐观锁定的优缺点。
当使用悲观锁定,资源在事务中第一次访问时开始一直被锁定,直到事务完成,在此期间其他事务无法访问它。在大多数事务只是读取而不更新资源的情况下,排他锁可能会导致更多的锁争用(死锁)。回想一下第 1 部分中的银行示例,一旦在交易中访问该帐户,它就会被锁定。任何在其他交易中使用该帐户的尝试都会导致其他进程被延迟,直到帐户锁定被释放,或者该进程交易将被取消并回滚到之前的状态。
当使用乐观锁定,资源在第一次被事务访问时实际上并没有被锁定。相反,资源的初始状态会被持久化。其他事务仍然能够访问该资源,这使得冲突更改的可能性成为已知风险。在提交事务时,当持久化存储中的资源即将更新时,资源的状态会再次从存储中读取,并与在事务中首次访问资源时保存的状态进行比较。如果两种状态不同,则意味着进行了冲突更新,因此事务将会回滚。在我们的银行示例中,帐户的金额将在首次访问时保存。如果交易更改了帐户金额,则将在金额即将更新之前再次从存储中读取该金额。如果交易开始后金额发生了变化,交易本身就会失败,否则新的金额将保持不变并被保存。
既然我们已经介绍了两种类型的锁定是什么,问题就变成了应该使用哪种。在大多数情况下,乐观锁定更有效并提供更高的性能。与此同时,悲观锁定提供了更好的数据完整性,但是,锁定的管理更难,因为遇到死锁的可能性更大。在悲观锁定和乐观锁定之间进行选择时,请考虑以下三个准则:
在关系数据库系统(RDBMS)中,死锁是两个并发事务无法进行的情况,因为每个事务都在等待另一个释放锁定。在本系列的第 1 部分中,我们知道了什么是关系数据库中的对象锁定、不同类型的锁定和死锁。然后,在第 2 部分中,我们比较了悲观锁定和乐观锁定的优缺点。在本篇文章中,我们将探讨导致死锁的几个原因,以及避免死锁或至少将死锁减至最少的策略。
死锁在某种程度上是不可避免的,但只要两个事务的其中一个及时结束,死锁就会很少发生并且不会导致灾难。事实证明,阻塞问题的最常见来源之一是冗长且低效的 SQL 语句,这些语句会导致数据库在运行时“挂起”。这些可以通过两个步骤来补救:
例如,如果由于执行 DELETE 语句而获取锁,并且紧随其后的是执行完整表扫描的 SELECT 语句,你应该确定是否可以在它们之间执行 COMMIT 语句。这应该有助于更早地释放锁。
阻塞问题的另一个常见原因是睡眠会话已经失去了对事务嵌套级别的跟踪。例如,如果应用程序取消 SQL 语句或超时但未发出 COMMIT 或 ROLLBACK 语句,则资源可能会无限期地保持锁定状态。处理此问题的一些方法包括:
一个鲜为人知的死锁来源是不会一次性取得所有结果行的应用程序。这是一个问题,因为当查询已发送到服务器时,应用程序必须能够取得所有结果行以完成查询。如果没有发生这种情况,表会被锁定,这会导致其他用户阻塞。因此,请尝试对你的应用程序进行编码,以便它们取得所需的全部行,而不是将其分散到多次迭代中。