LockModeType.OPTIMISTIC 和 Mysql 的默认隔离级别 REPEATABLE READ 不能一起工作?

LockModeType.OPTIMISTIC and Mysql's default isolation level REPEATABLE READ don't work together?

我正在尝试使用 Hibernate 学习 JPA 并使用 MySQL 作为数据库。

据我了解,

LockModeType.OPTIMISTIC: The entity version is checked towards the end of the currently running transaction.

REPEATABLE READ: All consistent reads within the same transaction read the snapshot established by the first such read in that transaction

hibernate 中的 LockModeType.OPTIMISTIC 是否无法使用 MySQL 的默认隔离级别?

假设我有以下代码:

tx.begin();
EntityManager em = JPA.createEntityManager();
Item item = em.find(Item.class, 1, LockModeType.OPTIMISTIC);
// Assume the item here has version = 0
// Read the item fields etc, during that another transaction commits and made item version increased to version = 1
tx.commit(); // Here Hibernate should execute SELECT during flushing to check version,
// i.e SELECT version FROM Item WHERE id = 1 
em.close();

我期望的是,在刷新期间,Hibernate 会抛出 OptimisticLockException,因为项目的版本不再是 0。但是,由于隔离级别,在同一个事务中,Hibernate 仍然会看到版本中的项目= 0 且不触发 OptimisitcLockExcpetion。

我搜索了一下,好像没有人提出过这样的问题,希望有人能帮我解开对OptimisticLock的困惑。

为了理解这一点,让我们快速了解一下休眠乐观锁定的工作原理:

  • 1: 开始新的交易

  • 2:通过 ID 查找实体(hibernate 发出 SELECT ... WHERE id=xxx;),例如可能有 version 计数 1

  • 3:修改实体

  • 4:将更改刷新到数据库(例如在提交事务之前自动触发):

    • 4.1: hibernate 发出一个 UPDATE ... SET ..., version=2 WHERE id=xxx AND version=1 其中 returns 更新行数
    • 4.2: hibernate 检查是否有一行实际更新,如果没有则抛出 StaleStateException
  • 5:异常时提交事务/回滚

使用 repeatable_read 隔离级别,第一个 SELECT 建立状态(快照),同一事务的后续 SELECT 读取该状态(快照)。然而,这里的关键是 UPDATE 确实 而不是 对已建立的快照进行操作,而是对行的已提交状态(可能已被其他已提交的事务更改)同时)。

因此,如果在此期间版本计数器已被另一个提交的事务更新,则更新实际上不会更新任何行,而休眠可以检测到这一点。

另见:
https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

如果您的问题实际上是 HBN 实现(或 JPA 规范)中是否存在与以下相关的缺陷 statement

If transaction T1 calls for a lock of type LockModeType.OPTIMISTIC on a versioned object, the entity manager must ensure that neither of the following phenomena can occur:

  • P1 (Dirty read): Transaction T1 modifies a row. Another transaction T2 then reads that row and obtains the modified value, before T1 has committed or rolled back. Transaction T2 eventually commits successfully; it does not matter whether T1 commits or rolls back and whether it does so before or after T2 commits.
  • P2 (Non-repeatable read): Transaction T1 reads a row. Another transaction T2 then modifies or deletes that row, before T1 has committed. Both transactions eventually commit successfully.

Lock modes must always prevent the phenomena P1 and P2.

那么答案是是的,你是对的:如果你基于某些实体状态执行计算,但你没有修改那些实体状态,HBN 只是问题select version from ... where id = ... 在事务结束时,因此由于 RR 隔离级别,它看不到来自其他事务的更改。但是,我不会说 RC 隔离级别在这种特殊情况下表现得更好:从 技术角度来看它的行为更正确 但从 业务角度来看它是完全不可靠的 因为它取决于时间,所以不要依赖 LockModeType.OPTIMISTIC - 它在设计上是不可靠的并使用其他技术,如:

  • 将来自不同域的数据存储在不同的实体中
  • 利用 @OptimisticLock annotation 来防止在不需要时增加版本(实际上这会通过 HBN 注释毒害您的域模型)
  • 将一些属性标记为updatable=false并通过JPQL更新来更新它们以防止版本增加

UPD.

Taking the P2 as example, if I really need T1 (only read row) to fail if T2 (modify/delete row) commits first, the only workaround I can think of is to use LockModeType.OPTIMISTIC_FORCE_INCREMENT. So when T1 commits it will try to update the version and fail. Can you elaborate more on how your provided 3 points at the end can help with this situation if we keep using RR isolation level?

短篇小说:

LockModeType.OPTIMISTIC_FORCE_INCREMENT 似乎不是一个好的解决方法,因为它将 reader 变成了 writer,因此递增版本将同时失败 writers 和其他 [=20] =].但是,在您的情况下,发布 LockModeType.PESSIMISTIC_READ 可能是可以接受的,对于某些数据库,它会转换为 select ... from ... for share/lock in share mode,而这又会仅阻止 writer 并阻止(或失败)current reader,因此你会避免我们正在谈论的现象。

长话短说:

当我们开始考虑一些“业务一致性”时,JPA 规范不再是我们的朋友,问题是他们根据“被拒绝的现象”和“必须有人失败”来定义一致性,但没有给我们任何线索和 API 如何从业务角度以正确的方式控制行为。让我们考虑以下示例:

class User {
  @Id
  long id;
  @Version
  long version;
  boolean locked;
  int failedAuthAttempts;
}

我们的目标是在失败的 AuthAttempts 超过某个阈值时锁定用户帐户。我们问题的纯 SQL 解决方案非常简单明了:

update user
  set failed_auth_attempts = failed_auth_attempts + 1,
  locked = case failed_auth_attempts + 1 >= :threshold_value then 1 else 0 end
where id = :user_id

但 JPA 使一切复杂化......乍一看,我们的天真实现应该是这样的:

void onAuthFailure(long userId) {
  User user = em.find(User.class, userId);
  int failedAuthAttempts = user.failedAuthAttempts + 1;
  user.failedAuthAttempts = failedAuthAttempts;
  if (failedAuthAttempts >= thresholdValue) {
    user.locked = true;
  }
  em.save(user);
}

但该实现有明显的缺陷:如果有人主动暴力破解用户帐户,由于并发性,并非所有失败的身份验证尝试都会被记录(这里我没有注意它可能是可以接受的,因为我们迟早会锁定用户帐户).如何解决此类问题?我们可以这样写吗:

void onAuthFailure(long userId) {
  User user = em.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE);
  int failedAuthAttempts = user.failedAuthAttempts + 1;
  user.failedAuthAttempts = failedAuthAttempts;
  if (failedAuthAttempts >= thresholdValue) {
    user.locked = true;
  }
  em.save(user);
}

?其实没有。问题是对于持久性上下文中不存在的实体(即“未知实体”)休眠问题 select ... from ... where id=:id for update,但对于已知实体,它问题 select ... from ... where id=:id and version=:version for update 并且由于版本不匹配而明显失败。所以我们有以下棘手的选项来使我们的代码“正确”工作:

  • 产生另一个交易(我相信在大多数情况下这不是一个好的选择)
  • 通过 select 查询锁定实体,即 smth。像 em.createQuery("select id from user where id=:id").setLockMode(LockModeType.PESSIMISTIC_WRITE).getFirstResult()(我相信在 RR 模式下可能无法工作,而且在刷新调用后会丢失数据)
  • 将属性标记为不可更新并通过 JPQL 更新(纯 SQL 解决方案)更新它们

现在假设我们需要将另一个业务数据添加到我们的用户实体中,比如“SO 声誉”,我们应该如何更新新字段,同时牢记有人可能会暴力破解我们的用户?选项如下:

  • 继续编写“棘手的代码”(实际上这可能会导致我们产生违反直觉的想法,即我们总是需要在更新之前锁定实体)
  • 跨不同实体拆分来自不同域的数据(听起来也违反直觉)
  • 使用混合技术

我相信这个 UPD 不会对你有太大帮助,但它的目的是证明在不了解目标模型的情况下不值得讨论 JPA 域中的一致性。