作为多用户应用程序开发人员所面临的最为复杂的领域之一,基于服务器的系统必须处理好锁定策略的影响。如果实现得不好,将导致包括性能问题、死锁以及异常的应用程序行为等各类问题。本文的目的在于列出基本问题、常用策略和模式以及有关使用锁定策略的时机和方法的一般性建议。
本文假设开发人员已经掌握所使用的 SQL,因而不会全面介绍使用对象关系(Object Relational,OR)映射工具的复杂细节。
数据库管理系统在事务范围之内提供锁定机制。采用读取或更新锁可防止应用程序的不一致行为;锁的实际使用取决于开发人员使用的 SQL 语句类型以及在数据库连接中指定的隔离级别。但是,仅仅依靠数据库本身的锁定是不够的。在某些场景中,可能需要长时间的“逻辑锁”以防止对某个用户正在使用的数据进行更改。以下将称之为会话锁定,以区别于仅存在于事务范围内的显式数据库锁定功能,而后者则被称为事务锁定。
带用户界面的系统中的会话锁定通常是用于解决众所周知的“居间更新”问题,在这种情况下,用户读取一条记录,更改并更新数据库,但另一个用户也在同时读取相同记录并进行更改。在此场景中,其中一个用户所做的更改将丢失。这也适用于基于消息的系统中,其中有多个更新需要提交,并且提交的顺序非常重要。必须注意,这是通过约定锁定,因此并不存在物理锁,其解决方法取决于对应用程序的理解和锁定策略的选用。
有两种常用方法来处理这种问题。一种是使用逻辑锁的悲观方法,由所有可能访问数据的应用程序理解并遵从,用于阻止访问业务事务中使用的某行(或多行)数据。这些数据可被“锁定”一段时间,因此使用时需谨慎。
另一种乐观方法则不锁定任何数据,而是维护正在使用的数据的相关信息,这样就可以在用户会话期间得知数据是否已被更改(例如,通过保存数据行的时间戳或序列号信息)。如果应用程序在更新时看到该值已经更改,则不会进行更新,并通知用户需要重试其操作。
在使用悲观会话锁定中需要注意一些问题,例如清理废弃的锁等,这将在稍后进行介绍。
事务锁(数据库锁)是单个事务工作单元内出现的锁。这一部分讨论使用数据库锁定的乐观和悲观方法的优点及缺点。
在悲观锁定方案中,通过使用 SQL SELECT FOR UPDATE 语句对数据行进行显式锁定,然后更改数据,并执行 SQL UPDATE。在这种情况下,显然所有数据行都能成功地更新,因为它们在其他更新尝试之前已被物理锁定。直到事务提交之后,这些锁才会被解除。同乐观锁定策略相比,物理数据库锁需要持续较长的时间,因此应用程序性能和吞吐量很可能会受到影响。同时,还可能出现死锁的情况。可以使用尽量低的隔离级别(将在稍后讨论),并且始终按相同顺序访问数据库表以及在获取锁之前执行尽量多的业务处理,以便缩短锁持续时间,从而减少死锁问题,死锁还可能在锁升级时出现,即如果同时保持的锁过多,可能引起数据库管理器将行级锁更改成页级锁甚至表级锁。再次强调,缩短锁的生存期非常重要。
进行特定的关键性更新时,应始终使用悲观锁定方式。例如,在从主键序列表中检索并缓存下一批可用的键值时。在短时间的事务中很少使用该操作,因为此操作会导致更新失败的风险。
需要注意的是,在某些情况下给定的 SELECT 语句不能使用 FOR UPDATE 子句。这时,可能需要修改应用程序,或使用乐观锁定方式。
与悲观锁定方案不同,应用程序显式地锁定需要更新的数据行,在乐观锁定中只有在更新数据行时才由数据库管理器进行实际的锁定。应用程序使用通常不带 FOR UPDATE 子句的 SQL SELECT 语句读取要更新的数据行。更改数据后,重新写入相应的行。通过在某些方面严格限定这些更新,以确保只有那些处于最初读取状态的数据行才更新。这可以通过使用数据行中包含的时间戳或序列号来进行,也可将数据行的每个列字段添加到 UPDATE 的 WHERE 子句中。推荐使用前面两种方法,第三种选择的效率不是很高。此外,某些列,如 BLOB 类型,不适用于这种严格限定的更新。应注意,某些 OR 映射工具(例如实体 Bean)会使用这种严格限定的更新。
如果在单条 SQL UPDATE 语句中更新多行记录,则很难知道哪些数据行更新成功以及哪些更新失败,因此乐观锁定应只适用于处理 UPDATE 语句中的单个数据行,而不是多个数据行。不过,您可以使用乐观锁定技术循环访问一组行并单独更新每个数据行。
乐观事务锁定通常以其高性能和死锁发生可能性较低的特点而被首选使用。但是,在实际使用中应根据具体情况进行分析,有时也需要悲观锁定方案。如果乐观锁异常会引起大量的回滚,那么应该重新考虑您的策略。
即使在乐观锁定方案中,在事务结束(提交时)之前物理锁仍然会保持在更新的数据行上,因此建议在更新数据库之后应尽快完成事务。
会话锁是指那些跨越多个工作事务单元的锁,它们涵盖某些业务流程的整个持续期间。这一部分讨论使用会话锁定的乐观和悲观方法的具体内容及其优缺点。
在此方案中,用户从数据库中检索数据而不加任何锁。检索的数据中包含可使用户能判断数据库中的记录行将来是否更改的足够信息,这可通过使用表中的时间戳或序列号,或以最初读取访问的每个数据元素值来进行保存得以实现。用户可以查看数据并进行更改,然后调用事务来提交这些更改。
在尝试提交更新时,应用程序将检查确认数据没有被其他用户更改。如果已更改,则更新将被拒绝,并通知用户已经发生居间更新。如果有多个数据行需要更新,则某些可能会更新成功,某些则会更新失败。一切由应用程序而不是数据库管理系统决定。该模式在 Martin Fowler 的著作Patterns of Enterprise Application Architecture 中描述为乐观离线锁(请参阅参考资料)。
在理论上,更新事务可以使用乐观或悲观锁定进行更新。但是,如果在整个会话期间可能出现居间更新,那么使用悲观事务锁定来更新单个数据行是没有任何价值的,这时使用乐观事务锁定方案就已足够,因为它基于数据初始访问时的数据行状态。
如果需要更新一系列的记录,且应用程序需要知道哪些行(如果有的话)发生了居间更新,那么就需要循环访问数据行,以确认所有行仍处于所要求的初始状态。如果要求要么全部更新、要么全部不更新数据行,则需要在实际进行更改之前使用 SELECT FOR UPDATE 来读取每一行(即对整个数据行集合使用悲观事务锁定),以防止结果不一致。
乐观会话锁定通常用在当多个并发用户很少有机会争用同一数据时。一个典型的例子是财务计划系统,每个财务顾问准确地拥有相应的帐户,并且某个顾问只能看到其所拥有的帐户。如果常常发生冲突,那么更新就会被拒绝,用户会对系统非常失望。缓解冲突问题的一种可能方法是将数据行标记为开放、可能更新。它较悲观会话锁更弱一些,相关方约定发出警告,而不是阻止。如果应用程序发现设置了警告标记,它将通知用户该数据已被使用,所做的任何更改可能都会丢失。然后,用户可以选择是否继续进行更新。
本例中的模式是当第一个事务读取某个数据行时,它将加上时间戳和用户标识,这类似于悲观会话锁定的处理方式(稍后讨论)。如果另外的事务检索数据并发现标记已被其他用户设置,那么它将按约定向用户发出数据正在被使用的警告消息。因此,第二个用户可以得知其所做更改可能会丢失,进而选择继续还是退回。
乐观会话锁定还可用于消息顺序无法保证的事件驱动或基于消息的系统中。在通知复制数据库更改的消息中可能包含数据的当前版本号或时间戳。如果接收方的数据已发生具有更高序列号(或更新的时间戳)的更改,则较早的更改可能会被拒绝。
在本方案中,通过逻辑锁(而不是物理锁)来对在较长时间中使用的数据行进行标记。这通常用在当存在居间用户界面交互和数据库锁定不足的时候。有几种常用方法可实现这一模式,其中包括在表中添加额外的列(例如锁标记、锁用户 ID、锁时间戳等),使用独立的锁表,或者使用外部锁机制,例如内存中的散列表(因明显的伸缩性原因,不推荐使用)。这在 Fowler (op. cit.) 中描述为悲观离线锁。悲观会话锁不会阻塞锁(因此不会引起死锁),当第二个尝试锁定数据行的事务在读取数据时将发现锁,它会承认锁,并通知用户当前数据不可用。
悲观会话锁通常为写入锁,它们允许其他用户读取锁定的数据,除非遇到不能读取的情况。例如,当数据处于重要的重组过程中时,访问数据可能获得无效的结果。
如果只有单一类型的锁关联到给定表,那么可以添加额外的列到原始表中来控制锁定。但是,如果同一个表关联多种类型的锁,例如读取和写入锁,或不同的表分组被锁定为粗粒度集合(稍后讨论),那么可以使用锁表来关联到主表。
使用悲观会话锁时还需注意一些问题:
锁可以存在于多次用户交互之间,这意味着它们可能会丢弃,因此需要制定锁的超时策略。
在某些情况,可能需要强制覆盖会话锁,例如医院系统中的急诊,但这种覆盖需要进行记录以便进行审核。
如上所述,由于可以删除悲观会话锁,因此获得锁的应用程序始终需要在更新数据之前检测锁是否有效。在此方面,它需要以乐观锁的方式进行处理。
悲观会话锁定是一种序列化形式,会严重地影响系统的可用性,须小心使用。它只能用于以下情况:常常出现冲突时且乐观会话锁定会引起严重的用户争用问题时,或业务会话中并发访问数据会导致完整性问题时。
如前面所提到的,会话锁定模式中的一个特例是需要对表集合施加单一的锁。这在 Fowler (op. cit.) 中被称为粗粒度锁。允许在同一父表上存在多个粗粒度锁集合。例如,在财务系统中的客户级别上:一个粗粒度锁会锁定该客户的所有帐户,另一个锁则可锁定全部保险策略。在此情况下,为每个数据行添加额外列用于锁定目的会产生额外的开销,而更有效的方式是,将锁表与父表的粗粒度锁集合关联并为此表添加列来保存会话锁定信息。
粗粒度锁定可以是乐观的,也可以是悲观的。
锁定的另一个特例是需要锁定当前数据库行中不存在的项目。例如,在医院计划中,某些极其复杂的计划过程中可能需要协调多种资源(医生、护士、病房等)以在相同时间期间可用。一种乐观计划方法可使用户选择资源,然后尝试在单个事务中对它们全部进行安排。如果出现错误(例如键重复),事务将需要回滚,用户将获得有关事务失败部分的信息,然后根据需要重新选择资源。这种方法也可用于解决航空订票系统中的类似问题。
如果出现大量冲突或冲突恢复过于复杂,可使用悲观方法,系统通过独立的分布式锁定机制来锁定可用于创建数据行的键。一种解决方法是数据库中的锁表,允许用户插入引用其他项的锁。采用以数据库为中心进行计划的一个问题是,在事务内部无法解锁项目(在提交或回滚处理之前)。理想的方式是能锁定资源、确认其可用性并在不再使用时解除其锁定。但是,存在这些限制,下面显示了实现以数据库为中心的锁定方法的模式:
User begins by asking for available resources Begin read transaction: System searches for resource availability End read transaction System may use rules to prioritize possible options System presents options to user User makes a selection Begin Update transaction: Validate that selected resources are still available Lock all resources using read for update (pessimistic locking) either on the item to updated, or on a related namespace lock in a locking table for new inserts If any failures, rollback transaction and return to user to make a new selection Otherwise update all resources End Update transaction (Commit) |
JDBC 定义了以下四种隔离级别:
TRANSACTION_SERIALIZABLE
在 DB2 中称为 Repeatable Read,是最强的隔离级别。数据库表中受当前事务影响的所有行都将被锁定,其他事务不能插入、删除或更新,以确保相同的查询能返回完全相同的结果。(但其他事务可读取锁定行)。所有被引用行(不仅是被检索行)都将被锁定,由于存在大量数据行被锁定的可能性,因此也存在较大的锁升级为完全的表级锁的可能性。在某些数据管理系统中,优化工具可以根据可能出现的锁升级要求来立即对某个表加锁。在此隔离级别中不允许出现幻象行,也不允许未提交读取。
TRANSACTION_REPEATABLE_READ
在 DB2 中称为 Read Stability。在此隔离级别中可以出现幻象行,但只有那些满足预定义条件的数据行才会被锁定。但是,这些行被确保能在工作单元期间保持稳定,只有在提交之后,才会解除锁定。在此隔离级别上,其他工作单元创建的未提交行不可见。
TRANSACTION_READ_COMMITTED
在 DB2 中称为 Cursor Stability。数据行只是在游标定位它们时才锁定,一旦获取下一行之后,锁将被解除。被更改的数据行在事务结束之后被解除锁定。在此隔离级别上允许进行幻象行和不可重复读取,但未提交行不可见。
TRANSACTION_READ_UNCOMMITED
事务之间未隔离,只读游标可访问其他事务未提交的更改。但是,更新游标的行为与 TRANSACTION_READ_COMITTED 相同。
注意,SELECT 语句获取的锁为共享锁,它们不会阻止其他事务读取数据(在其他事务使用的隔离级别的约束内)。
通常只有在少数例外情况下才使用 Read Committed 之外的其他隔离级别。但是,对于实体 Bean 则可不遵循此规则,由于该模型本身的特性,多数悲观锁定访问都需要至少 Repeatable Read。此外,在只读表上频繁使用的查询也可使用 Read Uncommitted。
必须谨慎使用悲观会话锁定,因为它在业务流程(包括用户思考时间)中序列化数据访问。
如果使用悲观会话锁定,它通常只用于阻止其他用户进行更新,而不是阻止对锁定数据的读取。
无论是逻辑锁还是物理锁,都应该作为可靠的集中式资源在数据中维护。在设计评审中,必须进行锁定模式的评审。
系统级的锁定表通常被认为是系统中的热点。但是,这可能并不是主要的问题,因为锁表通常较小,并且可能由数据库管理器完整地保存在内存中。这最适用于短时间锁,例如事务内的名称空间锁。它还可用于长时间的会话锁,但需要进行清理以避免表过度增长。
会话锁还可保存在被锁定的表中(或表集合的根表中)。如果在单个表中需要维护多种锁类型,那么可将其移动到其独立的子表或全局锁表中。
悲观事务锁应遵循最佳时间以防止死锁,包括减少锁保持期间的业务逻辑,并按常规的顺序访问数据库表。
乐观事务锁不能用于在单个 UPDATE 语句中更新数据行集合,因此在这种情况下,很难进行错误处理。
很明显,锁定是非常复杂的问题,需要人们清楚地认识和理解,这是非常重要的。为了提供可供讨论的标准语言,本文介绍了事务锁定和会话锁定的概念,并讨论了不同的锁定模式及其优缺点。尽管我们提供了一些一般性建议,但由于锁定使用更多地依赖于具体的应用程序以及用户的使用方式,因此很难概括出硬性的使用规则。本文的最重要一点是,锁定并不仅仅是数据库管理系统就能帮您完成的事。它也是设计的组成部分,并影响整个系统的总体可用性。因此,我们无法预先确定是使用乐观锁定策略还是悲观锁定策略,通常需要提出并讨论这些设计问题以确保解决方法能满足功能和非功能性需求。