最近在 Hibernate 的 JIRA 中打开了一个错误报告,称 Hibernate 错误处理死锁场景。报告的基础是 /Pro Hibernate 3/ 书中的一例(第9章)。对于那些可能不熟悉术语“死锁”的人来说,基本概念是两个进程各自持有资源锁,而另一个进程需要完成处理。虽然这种现象并不限于数据库,但在数据库术语中,这个想法是第一个进程(P1)在一个特定的行(R1)上持有写锁,而第二个进程(P2)在另一行(R2)上持有写锁。现在,为了完成其处理,P1 需要获取 R2 的写锁,但不能这样做,因为 P2 已经持有其写锁。相反,P2 需要获取 R1 的写锁以完成其处理,但不能这样做,因为 P1 已经持有其写锁。因此,P1 和 P2 都无法完成其处理,因为它们都在无限期地等待对方释放所需的锁,而它们都无法这样做,直到其处理完成。在这种情况下,两个进程被称为死锁。
几乎所有数据库都支持通过指定在一段时间后超时锁来绕过这种场景;在超时期间后,其中一个进程被迫回滚并释放其锁,从而使另一个进程可以继续并完成。虽然这可行,但它并不理想,因为它要求进程在超时周期超过之前保持死锁状态。更好的解决方案是数据库主动寻找死锁情况,并立即强制其中一个死锁参与者回滚并释放其锁,大多数数据库实际上也支持这种做法。
现在回到《/Pro Hibernate 3/》示例。首先我要声明,我没有阅读这本书,所以不理解章节中的背景讨论,也不理解作者对特定示例代码的意图/期望。我只知道一个(可能误导)读者的期望。所以这个示例尝试做的是启动两个线程,每个线程都使用自己的Hibernate Session以相反的顺序加载相同的两个对象,并修改它们的属性。所以上述提到的读者期望这会导致死锁场景发生。但事实并非如此。或者更准确地说,在我运行的示例中,它通常不会,尽管结果并不一致。有时会报告死锁;但绝大多数运行实际上只是成功
。为什么会出现这种情况呢?
所以,这里就是示例代码中真正发生的事情。正如我之前提到的,示例尝试以相反的顺序加载相同的两个对象。示例使用了实体出版社
和订阅者
。第一个线程(T1)加载一个指定的出版社
并修改其状态;然后它被迫等待。第二个线程(T2)加载一个指定的订阅者
并修改其状态;然后它也被迫等待。然后两个线程都被从等待状态中释放出来。从那时起,T1加载了T2之前加载的相同的订阅者
并修改其状态;T2加载了T1之前加载的相同的出版社
并修改其状态。这里你需要记住的是,到目前为止,这两个Session实际上还没有被刷新,因此在这个时候还没有对数据库执行任何UPDATE语句。刷新是在每个Session在每个线程的第二加载和修改
序列之后发生的。因此,直到那时,两个线程(即相应的数据库进程)实际上并没有持有任何对底层数据的写锁。显然,结果将取决于底层线程模型允许两个线程实际上如何重新唤醒
,特别是UPDATE语句是否会被交错
。如果两个线程恰好交错它们对数据库的请求(即T1的UPDATE PUBLISHER先发生,T2的UPDATE SUBSCRIBER后发生等),那么将发生死锁;如果不交错,那么结果将是成功
。
有三种方法可以无歧义地确保数据库中的锁获取错误迫使这两个事务中的其中一个在示例中失败
- 在数据库中使用SERIALIZABLE事务隔离级别
- 在每次状态更改后刷新会话(以及示例代码的step1()和step2()方法的结束处)
- 使用锁定(无论是乐观锁定还是悲观锁定)
看起来很简单。然而,显然还不够简单,以至于不能让《/Pro Hibernate 3/》这本书的读者满意,他打开了之前提到的JIRA案例。毕竟,当他了解了这一切后,他给我发了一些情绪化的、充满误解的私人电子邮件。我不打算在这里详细讨论所有误解,但有一个误解我认为需要揭露,那就是许多没有太多数据库背景的开发者似乎在事务的各种概念上感到困惑。《i class="wikiEmphasis">隔离和锁定不是同一件事》。事实上,在很大程度 上,它们实际上具有完全相反的目标和目的。事务隔离旨在隔离或绝缘一个事务,使其不受其他并发事务的影响,从而保证在一个事务中执行的操作不会影响(根据所采用的精确隔离模式,程度有所不同)其他事务中执行的操作。另一方面,锁定本质上具有完全相反的目标;它试图确保事务中执行的一些操作确实会对其他并发事务产生一定的影响。实际上,锁定与事务本身几乎没有任何关系,除了它们的存在通常限于获取它们的那个事务之外,而且它们的存在或缺失可能会影响不同事务的结果。或许,尽管我不能肯定,但这种混淆可能源于许多数据库使用锁定作为其隔离模型的基础。但这只是实现细节,像Oracle、Postgres以及最新的SQL Server这样的数据库拥有非常复杂和现代的隔离引擎,这些引擎根本不基于锁定。