Java 持久化/锁定
锁定可能是最常被忽略的持久化考虑因素。大多数应用程序在开发过程中往往忽略了并发问题,然后在投入生产之前才勉强添加锁定机制。考虑到软件项目中很大一部分的失败或取消,或者从未获得大量用户群,也许这合乎逻辑。但是,锁定和并发对于大多数应用程序来说是一个关键问题,或者至少是一个非常重要的因素,因此可能应该在开发周期的早期阶段进行考虑。
如果应用程序将对同一个对象进行并发写入,那么锁定策略至关重要,以便防止数据损坏。有两种策略可以防止对同一对象/行进行并发修改:乐观 和 悲观 锁定。从技术上讲,还存在第三种策略,鸵鸟 锁定,或者不锁定,这意味着把头埋在沙子里,忽略这个问题。
实现乐观和悲观锁定的方式有很多。JPA 支持版本乐观锁定,但一些 JPA 提供者也支持其他乐观锁定方法,以及悲观锁定。
锁定和并发可能是一个令人困惑的因素,而且存在很多误解。在应用程序中正确实现锁定通常需要做的不仅仅是设置一些 JPA 或数据库配置选项(尽管对于那些认为正在使用锁定的应用程序来说,这确实是他们所做的全部)。锁定可能还需要应用程序级别的更改,并确保访问数据库的其他应用程序也以正确的锁定策略进行操作。
乐观锁定假设在您读取数据到写入数据之间,数据不会被修改。这是当今持久化解决方案中最常用且推荐的锁定方式。该策略涉及检查原始读取对象中的一个或多个值在更新时是否仍然相同。这验证了在读取和写入之间,该对象没有被其他用户更改。
JPA 支持使用乐观锁定的版本字段,该字段在每次更新时都会更新。该字段可以是数字或时间戳值。建议使用数字,因为数字值更精确、可移植、性能更高,并且比时间戳更容易处理。
@Version
注释或 <version>
元素用于定义乐观锁定版本字段。该注释定义在对象的版本字段或属性上,类似于 Id
映射。该对象必须包含一个属性来存储版本字段。
对象的版本属性会由 JPA 提供者自动更新,通常不应由应用程序修改。唯一的例外是,如果应用程序在一个事务中读取对象,将对象发送到客户端,并在另一个事务中更新/合并对象。在这种情况下,应用程序必须确保使用原始对象版本,否则在读取和写入之间的任何更改都将无法检测到。EntityManager
merge()
API 将始终合并版本,因此只有在手动合并时,应用程序才负责此操作。
当检测到锁定争用时,将抛出 OptimisticLockException
。这可能包装在 RollbackException 或其他异常中(如果使用 JTA),但它应该设置为异常的 cause
。应用程序可以处理异常,但通常应向用户报告错误,并让他们决定如何操作。
@Entity
public abstract class Employee{
@Id
private long id;
@Version
private long version;
...
}
<entity name="Employee" class="org.acme.Employee" access="FIELD">
<attributes>
<id name="id"/>
<version name="version"/>
...
</attributes>
<entity/>
- 在锁定方面,最常见的错误可能是锁定了错误的代码部分。无论使用何种形式的锁定,无论是乐观还是悲观,情况都是如此。基本情况是
- 用户请求一些数据,服务器从数据库中读取数据并将其发送到用户的客户端(无论客户端是 html、rmi 还是 web 服务)。
- 用户在客户端编辑数据。
- 用户将数据提交回服务器。
- 服务器开始一个事务,读取对象,合并数据并提交事务。
- 问题在于原始数据是在步骤 1 中读取的,但直到步骤 4 才获得锁定,因此在步骤 1 和 4 之间对对象所做的任何更改都不会导致冲突。这意味着使用任何锁定都没有什么意义。
- 关键点在于,当使用数据库悲观锁定或数据库事务隔离时,情况总是如此,数据库锁定只会在步骤 4 中发生,任何冲突都无法检测到。这是使用数据库锁定无法扩展到 web 应用程序的主要原因,因为要使锁定有效,必须在步骤 1 开始数据库事务,并在步骤 4 之前不提交。这意味着在等待 web 客户端时,必须保持一个活动数据库连接和事务处于打开状态,以及锁定,因为无法保证 web 客户端不会在数据上停留数小时、去吃午饭或消失,从而占用数据库资源并锁定所有其他用户的数据,这可能非常不可取。
- 对于乐观锁,解决方案相对简单,对象的版本必须与数据一起发送到客户端(或保存在 http 会话中)。当用户提交数据回来时,原始版本必须与从数据库中读取的对象合并,以确保步骤 1 和 4 之间发生的任何更改都会被检测到。
- 不幸的是,程序员经常聪明反被聪明误。使用乐观锁时,第一个问题是当出现
OptimisticLockException
时该怎么办。友好邻里超级程序员的典型反应是自动处理异常。他们只需创建一个新的事务,刷新对象以重置其版本,并将数据合并回对象并重新提交。瞧,问题解决了,是吗?
- 这实际上违背了锁定的初衷。如果你想要这样,你也可以不用任何锁定。不幸的是,
OptimisticLockException
应该很少被自动处理,你需要真正地打扰用户来解决这个问题。你应该向用户报告冲突,并要么说“对不起,发生了编辑冲突,他们需要重做他们的工作”,要么在最好的情况下,刷新对象并向用户展示当前数据和他们提交的数据,并帮助他们在适当的情况下合并这两个数据。
- 一些自动合并工具会比较两个冲突的数据版本,如果没有任何单个字段冲突,那么数据就会在没有用户帮助的情况下自动合并。这是大多数软件版本控制系统所做的。不幸的是,用户通常比程序更能决定何时发生冲突,仅仅因为两个版本的 .java 文件没有更改相同的代码行并不意味着没有冲突,第一个用户可能删除了另一个用户添加的方法引用的方法,以及其他一些可能导致通常的夜间构建偶尔中断的问题。
- 锁定可以防止大多数并发问题,但要注意不要过度分析所有可能的假设情况。有时,在一个并发应用程序(或任何软件应用程序)中,可能会发生不好的事情。用户现在已经习惯了这一点,我认为没有人认为计算机是完美的。
- 一个很好的例子是源代码控制系统。允许用户互相覆盖更改是一件坏事;因此,大多数系统通过对源文件进行版本控制来避免这种情况。如果用户提交对源自旧版本的文件的更改,源代码控制系统会引发冲突并让用户合并这两个文件。这本质上是乐观锁。但是,如果一个用户在一个文件中删除或重命名了一个方法,然后另一个用户在另一个文件中添加了一个新的方法或调用到该旧方法?我所知道的没有任何源代码控制系统会检测到这个问题,这是一个冲突,会导致构建失败。解决这个问题的办法是开始锁定或检查系统中每个文件(或者至少每个可能相关的文件)的锁。类似于对每个可能相关的对象使用乐观读锁,或者悲观地锁定每个可能相关的对象。这可以做到,但这可能会非常昂贵,更重要的是,它现在会在用户每次签入时都会引发可能的冲突,因此它完全没有用。
- 因此,总的来说,要注意不要过度偏执,以至于你牺牲了系统的可用性。
- 任何有效的锁定形式都需要所有访问相同数据的应用程序遵循相同的规则。如果你在一个应用程序中使用乐观锁,但在另一个访问相同数据的应用程序中不使用任何锁定,它们仍然会发生冲突。一个假的解决方案是配置一个更新触发器,始终递增版本值(除非在更新中递增)。这将允许新的应用程序避免覆盖旧应用程序的更改,但旧应用程序仍然能够覆盖新应用程序的更改。这仍然可能比不使用任何锁定好,也许旧应用程序最终会消失。
- 一个常见的误解是,如果你使用悲观锁,而不是添加一个版本字段,你就会没事。同样,悲观锁要求所有访问相同数据的应用程序使用相同的锁定形式。旧应用程序仍然可以读取数据(不锁定),然后在新应用程序读取、锁定和更新相同数据后更新数据,从而覆盖其更改。
- 也许,但很可能不是。大多数数据库默认使用已提交读事务隔离。这意味着你永远不会看到未提交的数据,但这并不能防止并发事务覆盖相同数据。
- 事务 A 读取行 x。
- 事务 B 读取行 x。
- 事务 A 写入行 x。
- 事务 B 写入行 x(并覆盖 A 的更改)。
- 两者都成功提交。
- 这是已提交读的情况,但使用可串行化,这种冲突就不会发生。使用可串行化,要么事务 B 会在 B 的选择上锁定并等待(可能很长时间),直到事务 A 提交。在一些数据库中,事务 A 可能不会等待,但在提交时会失败。但是,即使使用可串行化隔离,典型的 Web 应用程序仍然会发生冲突。这是因为每个服务器请求都在不同的数据库事务中运行。Web 客户端在一个事务中读取数据,然后在另一个事务中更新数据。因此,乐观锁实际上是典型 Web 应用程序中唯一可行的锁定选项。即使读写发生在同一个事务中,可串行化通常也不是解决方案,因为并发性影响和死锁的可能性。
- 参见 可串行化事务隔离
- 应该发生的是,合并应该触发
OptimisticLockException
,因为对象有一个非空的版本,并且大于 0,并且对象不存在。但这可能是 JPA 提供商特有的,有些可能会重新插入对象(这会在没有锁定的情况下发生),或者抛出不同的异常。
- 如果你调用了
persist
而不是合并,那么对象就会被重新插入。
- 最好的解决方案可能是添加一个。字段锁定是另一个解决方案,在某些情况下,悲观锁定也是解决方案。
- 参见 字段锁定
- 参见 级联锁定
- 参见 时间戳锁定
对于继承或多个表,我是否需要在每个表中都有一个版本?
[edit | edit source]- 简短的回答是不,只需要在根表中。
- 参见多个版本
高级
[edit | edit source]时间戳锁
[edit | edit source]时间戳版本锁由 JPA 支持,配置方式与数值版本锁相同,区别在于属性类型将为 java.sql.Timestamp
或其他日期/时间类型。在使用时间戳锁时要谨慎,因为不同数据库中的时间戳精度不同,有些数据库不存储时间戳的毫秒,或者不精确地存储。一般来说,时间戳锁不如数值版本锁高效,因此建议使用数值版本锁。
如果表中已经存在一个上次更新时间戳列,则经常使用时间戳锁,这也是自动更新上次更新列的一种便捷方式。时间戳版本值比数值版本更有用,因为它包含有关对象上次更新时间的相关信息。
时间戳版本锁中的时间戳值可以来自数据库,也可以来自 Java(中间层)。JPA 不允许配置此选项,但一些 JPA 提供程序可能提供此选项。使用数据库的当前时间戳可能非常昂贵,因为它需要对服务器进行数据库调用。
多个版本
[edit | edit source]一个对象在 JPA 中只能有一个版本。即使对象映射到多个表,也只有主表具有版本。如果任何表的任何字段发生更改,版本将被更新。如果您需要多个版本,您可能需要在对象中映射多个版本属性并手动维护重复版本,也许可以通过事件。从技术上讲,没有什么可以阻止您为多个属性添加 @Version
注解,并且一些 JPA 提供程序可能支持这一点。
级联锁定
[edit | edit source]锁定对象不同于锁定数据库中的行。对象可能比简单的行更复杂;对象可以跨越多个表,具有继承关系、关系和依赖对象。因此,确定对象何时更改并需要更新其版本可能比确定行何时更改更难。
JPA 确实定义了当对象的任何表发生更改时更新版本。但是关于关系还不清楚。如果 Basic
、Embedded
或外键关系 (OneToOne
、ManyToOne
) 发生更改,版本将被更新。但 OneToMany
、ManyToMany
和目标外键 OneToOne
呢?对于这些关系的更改,版本更新可能取决于 JPA 提供程序。
依赖对象的更改呢?JPA 没有为锁定定义级联选项,也没有直接的依赖对象概念,因此这不是一种选择。一些 JPA 提供程序可能支持这一点。模拟此操作的一种方法是使用 写锁定。JPA 定义了 EntityManager lock() API。您可以在根父对象中定义一个版本,当子对象(或关系)发生更改时,您可以使用父对象调用 lock API 以触发 WRITE
锁定。这将导致父版本的更新。您还可以通过持久化事件自动执行此操作。
级联锁定的使用取决于您的应用程序。如果在您的应用程序中,您认为一个用户更新对象的某个依赖部分,而另一个用户更新对象的另一个部分是一种锁定冲突,那么这就是您想要的。如果您的应用程序不认为这是一种问题,那么您不需要级联锁定。级联锁定的一大优势是您需要维护的版本字段更少,并且只需要更新根对象才能检查版本。这在批量写入等优化中可能会有所不同,因为如果依赖对象具有必须检查的自己的版本,则可能无法进行批量写入。
- TopLink / EclipseLink : 通过他们的
@OptimisticLocking
和@PrivateOwned
注解和 XML 支持级联锁定。
字段锁定
[edit | edit source]如果您在表中没有版本字段,乐观字段锁是另一种解决方案。字段锁定涉及在更新时比较对象中的某些字段。如果这些字段已更改,则更新将失败。JPA 不支持字段锁定,但一些 JPA 提供程序支持它。
当需要更精细的锁定级别时,也可以使用字段锁定。例如,如果一个用户更改对象的名称,而另一个用户更改对象的地址,您可能希望这些更新不发生冲突,并且只希望在用户更改相同字段时出现乐观锁错误。您可能还只关心对某些字段更改的冲突,并且不希望从其他字段的冲突中出现锁错误。
字段锁定也可以用于旧版模式,其中您无法添加版本列,或者与其他应用程序集成,这些应用程序访问相同的数据但未使用乐观锁定(注意,如果其他应用程序也未使用字段锁定,则您只能检测一个方向的冲突)。
字段锁定有几种类型
- 在更新中比较所有字段 - 这会导致 where 子句非常大,但会检测到任何冲突。
- 在更新中比较选定字段 - 这在只需要某些字段的冲突时很有用。
- 在更新中比较已更改字段 - 这在仅将相同字段的更改视为冲突时很有用。
如果您的 JPA 提供程序不支持字段锁定,则很难模拟,因为它需要更改更新 SQL。您的 JPA 提供程序可能允许覆盖更新 SQL,在这种情况下,可能可以使用 All
或 Selected
字段锁定(如果您有权访问原始值),但 Changed
字段锁定更难,因为更新必须是动态的。模拟字段锁定的另一种方法是 flush
您的更改,然后使用单独的 EntityManager
和连接刷新对象,并将当前值与原始对象进行比较。
使用字段锁定时,务必保留读取的原始对象。如果您在一个事务中读取对象并将其发送到客户端,然后在另一个事务中更新,那么您实际上并没有锁定。在读取和写入之间进行的任何更改都不会被检测到。您必须将读取的原始对象保留在 EntityManager
中,以使您的锁定生效。
- TopLink / EclipseLink : 通过他们的
@OptimisticLocking
注解和 XML 支持字段锁定。
读写锁定
[edit | edit source]有时希望锁定您未更改的内容。通常,这是在对一个对象进行更改时完成的,该更改基于另一个对象的状态,并且您希望确保另一个对象在提交时表示数据库的当前状态。这就是可串行化事务隔离为您提供的功能,但乐观的读写锁定允许以声明性和乐观的方式(并且没有死锁、并发和打开事务问题)满足此要求。
JPA 通过 EntityManager.lock()
API 支持读写锁。 LockModeType
参数可以是 READ
或 WRITE
。READ
锁定将确保对象的狀態在提交时不会发生更改。WRITE
锁定将确保此事务与任何其他更改或锁定对象的事务发生冲突。本质上,READ
锁定检查乐观版本字段,而 WRITE
锁定检查并递增它。
使用 Lock API 的示例
[edit | edit source]Employee employee = entityManager.find(Employee.class, id);
employee.setSalary(employee.getManager().getSalary() / 2);
entityManager.lock(employee.getManager(), LockModeType.READ);
写锁定也可以用于提供对象级锁定。如果您希望对依赖对象的更改与对父对象的任何更改或任何其他依赖对象的更改发生冲突,可以通过写锁定来实现。这也可用于锁定关系,当您更改 OneToMany 或 ManyToMany 关系时,您还可以强制父版本的递增。
使用 Lock API 进行级联锁定的示例
[edit | edit source]Employee employee = entityManager.find(Employee.class, id);
employee.getAddress().setCity("Ottawa");
entityManager.lock(employee, LockModeType.WRITE);
从概念上讲,人们可能会嘲笑或对没有锁定的想法感到震惊,但这可能是最常见的锁定形式。有些人称之为鸵鸟锁定,因为策略是将头埋在沙子里,忽略问题。大多数原型或小型应用程序通常没有锁定要求,在大多数情况下也不需要锁定,处理锁定争用发生时的操作超出了应用程序的范围,因此最好忽略这个问题。
一般来说,在 JPA 中始终启用乐观锁定可能是最好的选择,因为它在概念上很容易实现,但如果没有任何形式的锁定,冲突时会发生什么?本质上是最后写入者获胜,因此,如果两个用户同时编辑同一个对象,最后一个提交的用户将看到其更改反映在数据库中。这对于用户编辑相同字段的情况是正确的,但如果两个用户编辑同一个对象中的不同字段,则取决于 JPA 实现。一些 JPA 提供程序只更新更改的字段,而其他提供程序更新对象中的所有字段。因此,在一种情况下,第一个用户的更改将被覆盖,但在第二种情况下,他们不会被覆盖。
悲观锁定意味着在开始编辑对象之前获取对象的锁,以确保没有其他用户正在编辑该对象。悲观锁定通常通过使用数据库行锁来实现,例如通过 SELECT ... FOR UPDATE
SQL 语法。数据被读取和锁定,更改被执行,事务被提交,并释放锁。
JPA 1.0 不支持悲观锁定,但一些 JPA 1.0 提供程序支持。 JPA 2.0 支持悲观锁定。也可以使用 JPA 本地 SQL 查询来发出 SELECT ... FOR UPDATE
并使用悲观锁定。使用悲观锁定时,必须确保在锁定对象时刷新该对象,锁定可能过时的对象毫无用处。悲观锁定的 SQL 语法是特定于数据库的,不同的数据库具有不同的语法和支持级别,因此请确保您的数据库正确支持您的锁定要求。
- EclipseLink(截至 1.2): 支持 JPA 2.0 悲观锁定。
- TopLink / EclipseLink: 通过
"eclipselink.pessimistic-lock"
查询提示支持悲观锁定。
悲观锁定的主要问题是它们使用数据库资源,因此需要在编辑期间保持数据库事务和连接打开。这通常不适合交互式 Web 应用程序。悲观锁定也会出现并发问题并导致死锁。悲观锁定的主要优点是,一旦获取锁,就相当确定编辑将成功。这在高度并发应用程序中可能很理想,在这些应用程序中,乐观锁定可能会导致太多乐观锁定错误。
还有其他方法可以实现悲观锁定,它可以在应用程序级别或通过 可序列化事务 隔离来实现。
应用程序级别的悲观锁定可以通过在对象中添加一个 locked 字段来实现。在编辑之前,您必须将该字段更新为 locked(并提交更改)。然后,您可以编辑对象并将 locked 字段设置回 false。为了避免在获取锁时发生冲突,您还应该使用乐观锁定,以确保锁字段不会被另一个用户同时更新为 true。
JPA 2.0 添加了对悲观锁定的支持以及其他锁定选项。可以使用 EntityManager.lock()
API 获取锁,或将 LockModeType
传递给 EntityManager
的 find()
或 refresh()
操作,或设置 Query
或 NamedQuery
的 lockMode
。
JPA 2.0 锁定模式在 LockModeType 枚举中定义
- OPTIMISTIC(在 JPA 1.0 中为 READ)- 实体将在提交时检查其乐观锁定版本,以确保没有其他事务更新了该对象。
- OPTIMISTIC_FORCE_INCREMENT(在 JPA 1.0 中为 WRITE)- 实体将在提交时增加其乐观锁定版本,以确保没有其他事务更新(或读取锁定)该对象。
- PESSIMISTIC_READ - 实体在数据库中被锁定,阻止任何其他事务获取 PESSIMISTIC_WRITE 锁。
- PESSIMISTIC_WRITE - 实体在数据库中被锁定,阻止任何其他事务获取 PESSIMISTIC_READ 或 PESSIMISTIC_WRITE 锁。
- PESSIMISTIC_FORCE_INCREMENT - 实体在数据库中被锁定,阻止任何其他事务获取 PESSIMISTIC_READ 或 PESSIMISTIC_WRITE 锁,并且实体将在提交时增加其乐观锁定版本。这很不寻常,因为它同时执行乐观和悲观锁定,通常应用程序只使用一种锁定模型。
- NONE - 不获取锁,这是任何查找、刷新或查询操作的默认值。
JPA 2.0 还添加了两个新的标准查询提示。这些可以传递给任何 Query
、NamedQuery
或 find()
、lock()
或 refresh()
操作。
- "javax.persistence.lock.timeout" - 在放弃并抛出
PessimisticLockException
之前等待锁的毫秒数。 - "javax.persistence.lock.scope" - 有效范围在 PessimisticLockScope 中定义,可以是 NORMAL 或 EXTENDED。EXTENDED 还将锁定对象的所属连接表和元素集合表。
可序列化事务隔离保证在事务中读取的任何内容都不会被任何其他用户更新。通过使用可序列化事务隔离并确保在同一事务中读取要编辑的数据,您可以实现悲观锁定。重要的是要确保在事务中从数据库刷新对象,因为编辑缓存或可能过时的数据会破坏锁定的目的。
可序列化事务隔离通常可以在数据库中启用,一些数据库甚至将其作为默认值。它也可以在 JDBC Connection
上设置,或者通过本地 SQL 设置,但这特定于数据库,不同的数据库具有不同的支持级别。可序列化事务隔离的主要问题与使用 SELECT ... FOR UPDATE
相同(有关详细信息,请参见上文),此外,读取的任何内容都将被锁定,因此您无法决定只在某些时候锁定某些对象,而是一直锁定所有内容。对于具有公共只读数据的交易来说,这可能是一个重大的并发问题,并可能导致死锁。
数据库如何实现可序列化事务隔离在不同的数据库之间有所不同。一些数据库(例如 Oracle)可以以比典型悲观实现更乐观的意义上执行可序列化事务隔离。不是每个事务都需要在读取数据时对所有数据进行锁定,而是直到事务提交时才检查行版本,如果任何数据发生了更改,则会抛出异常并且不允许事务提交。