乐观锁与悲观锁

947

我理解乐观锁和悲观锁的区别,现在请问有人能够解释一下在什么情况下通常使用它们吗?

而且这个问题的答案是否会根据是否使用存储过程来执行查询而改变?

但是只是为了确认,乐观意味着“读取时不锁定表”,而悲观意味着“读取时锁定表”。


3
http://blog.couchbase.com/optimistic-or-pessimistic-locking-which-one-should-you-pick - Frank Myat Thu
3
那是一个很好的问题,特别是因为在串行化中,我读到“在任何技术类型中都应检测和考虑冲突,并且对于材料化和非材料化冲突的开销相似”。 - Little Alien
4
您可以在这里找到一个不错的解释,SO上讲述了什么是乐观锁定的根本概念 - Diego Mazzaro
2
我建议阅读马丁·福勒(Martin Fowler)的优秀著作《模式语言》:https://martinfowler.com/books/eaa.html - koppor
我认为并发控制比锁定更准确。 - Jason Law
13个回答

1250

乐观锁定是一种策略,您可以读取记录并注意版本号(其他方法包括日期、时间戳或校验和/哈希),然后在写回记录之前检查该版本是否已更改。当您写回记录时,您会过滤版本上的更新以确保其是原子性的(即在检查版本和将记录写入磁盘之间未被更新),并一次性更新版本。

如果记录是脏的(即与您的版本不同),则中止事务,用户可以重新启动它。

此策略最适用于高容量系统和三层架构,在这种情况下,客户端不能实际维护数据库锁定,因为连接是从池中获取的,您可能不会在一个访问到另一个访问中使用相同的连接。

悲观锁定是当您锁定记录以供您独占使用直到完成时。它比乐观锁定具有更好的完整性,但需要您小心应用程序设计以避免死锁。要使用悲观锁定,您需要直接连接到数据库(通常在两层客户端服务器应用程序中)或可在连接之外独立使用的事务ID。

在后一种情况下,您将使用TxID打开事务,然后使用该ID重新连接。DBMS维护锁并允许您通过TxID重新获取会话。这就是使用两阶段提交协议(如XACOM+ Transactions)进行分布式事务的工作方式。

191
乐观锁定并不一定使用版本号。其他策略包括使用(a)时间戳或(b)行本身的完整状态。后一种策略虽然难看,但避免了需要专门的版本列的情况,特别是在无法修改模式的情况下。 - Andrew Swan
3
乐观锁定的概念并不一定要求有一种100%可靠的方法来知道某些东西是否已经被更改;不能接受无法检测的更改,但是偶尔出现的错误报告可能不太糟糕,特别是如果接收此类报告的代码重新读取数据并检查它是否实际上已经更改。 - supercat
46
@supercat - 不同意乐观锁比悲观锁精度低于100%的说法,只要它检查事务中所有应保持不变的输入记录,它在这些相同记录上的精度与悲观锁(select for update样式)是一样的。主要区别在于,乐观锁仅在出现冲突时产生开销,而悲观锁在冲突时开销降低。因此,乐观锁在大多数交易不冲突的情况下最好,我希望这通常适用于大多数应用程序。 - RichVel
5
@Legends - 使用乐观锁定肯定是 Web 应用程序的适当策略。 - ConcernedOfTunbridgeWells
4
你应该提到选择也取决于读写比例:如果你的应用程序主要是由很多用户进行只读操作,并且有时写入数据,那么选择乐观锁定。例如,StackOverflow有很多人阅读页面,有时会有人编辑页面:在悲观锁定中,谁会获得锁?第一个人吗?在乐观锁定中,只要想要编辑页面的人拥有最新版本,就可以进行编辑。 - jehon
显示剩余12条评论

385

在处理冲突时,你有两个选择:

  • 你可以尝试避免冲突,这就是悲观锁定所做的。
  • 或者,你可以允许冲突发生,但是你需要在提交事务时检测它,这就是乐观锁定所做的。

现在,让我们考虑以下的丢失更新异常:

Lost Update

在Read Committed隔离级别下可能会发生丢失更新异常。

在上面的图表中,我们可以看到Alice认为她可以从她的账户中取出40元,但她没有意识到Bob刚刚改变了账户余额,现在这个账户只剩下20元。

悲观锁定

悲观锁定通过对账户进行共享或读取锁定来实现此目标,因此Bob无法更改该账户。

Lost Update Pessimistic Locking

在上面的图表中,Alice和Bob都会在他们都读取的account表行上获取读锁。当使用可重复读或串行化时,数据库在SQL Server上获取这些锁。
因为Alice和Bob都读取了PK值为1的account,所以在其中一个用户释放读锁之前,他们都不能更改它。这是因为写操作需要进行写/排他锁定获取,而共享/读锁定防止写/排他锁定。
只有在Alice提交她的事务并且account行上的读锁被释放后,Bob的UPDATE才会继续并应用更改。在Alice释放读锁之前,Bob的UPDATE将被阻塞。
乐观锁定
乐观锁定允许冲突发生,但在应用Alice的UPDATE时检测到版本已更改。

Application-level transactions

这次,我们有一个额外的version列。每次执行UPDATE或DELETE时,version列都会递增,并且在UPDATE和DELETE语句的WHERE子句中也会使用它。为了让它起作用,我们需要在执行UPDATE或DELETE之前发出SELECT并读取当前的version,否则我们将不知道要传递到WHERE子句或递增的版本值。

应用程序级事务

关系数据库系统出现在70年代末80年代初,当时客户端通常通过终端连接到大型机。这就是为什么我们仍然看到数据库系统定义SESSION设置等术语。
如今,在互联网上,我们不再在同一数据库事务的上下文中执行读写操作,ACID已经不再足够。
例如,考虑以下用例:

Application-level transactions and Optimistic Locking

没有乐观锁,即使数据库事务使用Serializable,也无法捕获此Lost Update。这是因为读取和写入在不同的HTTP请求中执行,因此在不同的数据库事务中执行。
因此,即使使用包含用户思考时间的应用程序级事务,乐观锁也可以帮助您防止Lost Updates。
结论
乐观锁是一种非常有用的技术,即使使用较不严格的隔离级别(如Read Committed),或者在后续数据库事务中执行读取和写入,它也能正常工作。
乐观锁的缺点是,在数据访问框架捕获OptimisticLockException时,将触发回滚,因此会失去当前执行事务所做的所有工作。
争用越多,冲突越多,事务中止的可能性就越大。回滚对于数据库系统来说可能是昂贵的,因为它需要撤消所有当前挂起的更改,这可能涉及表行和索引记录。
因此,当冲突经常发生时,悲观锁可能更合适,因为它减少了回滚事务的机会。

3
你会建议在哪些情况下选择乐观锁和悲观锁?这取决于 OptimisticLockException 发生的频率吗? - Stimpson Cat
3
根据@StimpsonCat的结论,如果经常出现异常,则最好采用悲观锁定。就我的情况而言,发生异常的机会非常小,因此我将选择乐观锁定。 - Dave Cruise
一旦Bob取款,数据库记录就会改变。因此,理想情况下,它应该反映在Alice的账户上。这意味着,当Alice查询金额时,应该更新的是最新的金额,而不是持久化上下文中的金额。我有什么遗漏吗?谢谢。 - Omkar Shetkar
2
点赞。虽然这份资料并不新颖,但是随着越来越多的一次性作业问题涌入系统,详细解释的答案正在成为SO中的稀缺资源。 - Abhijit Sarkar
我暂停了你的YouTube视频去网上搜索一些东西,偶然发现了你的答案。我知道这违反了评论准则,但仍然是非常有帮助的贡献! - EralpB
3
你可以在谷歌、StackOverflow、YouTube、GitHub、Twitter和LinkedIn上找到我,我无所不在。 - Vlad Mihalcea

263

乐观锁用于不预期发生许多冲突的情况。进行常规操作的成本较低,但如果确实发生冲突,则需要支付较高的代价来解决它,因为事务会被中止。

悲观锁用于预计会发生冲突的情况。将违反同步的事务简单地阻塞。

要选择适当的锁定机制,必须估计读写的数量并做出相应的计划。


通常情况下,该语句是完美的,但在特殊情况下,如果您可以管理CAS操作并允许不精确性,正如@skaffman在答案中提到的那样,我会说这取决于实际情况。 - Hearen

105

乐观锁假设你读取时数据不会改变。

悲观锁则假设数据将发生改变,因此锁住它。

如果数据完全准确并不是必须的,使用乐观锁。虽然可能会出现偶尔的“脏”读取,但这很少导致死锁等问题。

大多数 Web 应用程序都可以接受脏读取——在极少数情况下,如果数据不能完全匹配,下次重新加载即可。

对于像许多金融交易等需要精确数据操作的场合,请使用悲观锁。重要的是确保精确读取数据,没有未显示更改——额外的锁定开销是值得的。

还有,Microsoft SQL Server 默认为页面锁定——基本上是你正在读取的行和旁边的几行。行锁定更准确但速度较慢。通常值得将事务设置为读取提交或无锁以避免在读取时发生死锁。


JPA乐观锁定允许您保证读取一致性。 - Gili
5
读一致性是一个独立的问题 - 在PostgreSQL、Oracle和许多其他数据库中,您可以获得数据的一致视图,而不受尚未提交的任何更新的影响,甚至不受排他行锁的影响。 - RichVel
1
我必须同意@RichVel的观点。一方面,如果您的事务隔离级别为“读取未提交”,我可以看到悲观锁定如何防止脏读。但是,如果没有提到大多数数据库(包括显然的MS SQL Server)具有默认的隔离级别“读取已提交”,这将防止脏读,并使得乐观锁定与悲观锁定一样准确,那么说乐观锁定容易受到脏读的影响是误导性的。 - antinome
Eric Brower说,银行家与其他人不同,更倾向于进行不当操作。你的专家似乎完全失控了。 - Little Alien
2
Eric Brewer是一位大师,他提出了CAP定理关于银行业务中的一致性。这与你所尊重的相反。 - Little Alien
显示剩余2条评论

29

我认为还有一种情况,悲观锁定会是更好的选择。

对于乐观锁定,在数据修改时每个参与者都必须同意使用这种类型的锁定。但如果有人在不关心版本列的情况下修改数据,这将破坏乐观锁定的整个理念。


1
试图使用乐观锁和悲观锁的人也可能会相互干扰。想象一种情况,一个乐观的会话正在读取记录并进行一些计算,而一个悲观的会话正在更新该记录,然后乐观的会话回来并更新了同一条记录,而没有注意到任何更改。只有当每个会话都使用相同的语法时,才能仅选择...进行更新。 - lusional

28

基本上有两个最受欢迎的答案。第一个基本上是这样说的:

乐观锁需要三层架构,您不一定需要为会话维护与数据库的连接,而悲观锁定是当您锁定记录以供您独占使用直到完成时。它比乐观锁定具有更好的完整性,您需要直接连接到数据库。

另一个答案是

乐观(版本)由于没有锁定而更快,但在争用高时(悲观)锁定表现更佳,最好预防工作而不是放弃并重新开始。

或者

当您遇到罕见的冲突时,乐观锁定效果最佳

正如此页面所述。

我创建了我的答案来解释“保持连接”与“低冲突”之间的关系。

为了了解哪种策略最适合您,不要考虑DB每秒的事务处理量,而是考虑单个事务的持续时间。通常,您会打开事务,执行操作并关闭事务。这是一个短小精悍的经典事务,符合ANSI的想法,并且可以避免锁定。但是,在实现许多客户同时预订相同房间/座位的票务预订系统时,该如何操作呢?
您浏览报价,填写表格,提供大量可用选项和当前价格。这需要很长时间,选项可能会过时,所有价格在您开始填写表格并按下“同意”按钮之间无效,因为您所访问的数据没有锁定,某个更敏捷的人干扰更改了所有价格,您需要以新价格重新开始。
相反,您可以在阅读选项时将所有选项锁定。这是一种悲观的情况。您可以看到它为什么很糟糕。您的系统可能会被一个单一的小丑拖垮,他只需开始预订并去吸烟。在他完成之前,没有人可以预订任何东西。您的现金流降至零。这就是为什么现实中使用乐观的预订。那些磨蹭太久的人必须以更高的价格重新开始他们的预订。
在这种乐观的方法中,您需要记录所有读取的数据(如我的重复读取),并使用您的数据版本(我想以您显示的报价购买股票,而不是当前价格)到达提交点。此时,将创建ANSI事务,锁定DB,检查是否有更改,并提交/中止您的操作。在我看来,这是MVCC的有效仿真,它也与乐观CC相关,并假定在中止的情况下您的事务会重新启动,即您将进行新的预订。此处的交易涉及人类用户决策。
我远没有理解如何手动实现MVCC,但我认为具有重启选项的长时间运行的事务是理解该主题的关键。如果我有任何错误,请纠正我。我的回答受this Alex Kuznecov chapter的启发。

17

在大多数情况下,乐观锁定更有效并且具有更高的性能。在选择悲观锁定和乐观锁定时,请考虑以下因素:

  • 如果有很多更新操作且用户尝试同时更新数据的可能性相对较高,则悲观锁定非常有用。例如,如果每个操作可以一次更新大量记录(银行可能会在每个月底向每个账户添加利息收入),并且两个应用程序同时运行这些操作,它们将发生冲突。

  • 对于包含经常更新的小表的应用程序而言,悲观锁定也更为适合。在这些所谓的热点情况下,冲突非常可能发生,因此乐观锁定在回滚冲突事务方面浪费了努力。

  • 如果冲突的可能性非常低-存在许多记录但相对较少的用户,或非常少的更新和主要是读取类型的操作,则乐观锁定非常有用。


9
假设在一个电商应用中,用户想要下订单。这段代码将被多个线程执行。在“悲观锁定”中,当我们从数据库获取数据时,我们会对其进行锁定,以防其他线程修改它。我们处理数据、更新数据,然后提交数据。之后,我们释放锁定。这里的锁定持续时间很长,我们已经从一开始就锁定了数据库记录。
在“乐观锁定”中,我们获取数据并处理数据,而不进行锁定。因此,多个线程可以同时执行到目前为止的代码。这将加快速度。当我们更新时,我们锁定数据。我们必须验证没有其他线程更新了该记录。例如,如果我们有100个库存项目,并且我们必须将其更新为99(因为您的代码可能是`quantity=quantity-1`),但如果另一个线程已经使用了1,则应为98。我们在这里遇到了“竞态条件”。在这种情况下,我们重新启动线程,以便我们从头开始执行相同的代码。但这是一项昂贵的操作,你已经完成了最后一步,然后重新开始。如果我们有几个竞争条件,那没关系。如果竞争条件很高,将有很多线程需要重新启动。我们可能会陷入循环中。如果竞争条件很高,我们应该使用“悲观锁定”。

8

一种使用乐观锁的情况是使应用程序利用数据库来允许其中一个线程/主机“声明”任务。这是一种我经常使用的技术。

我能想到的最好的例子是使用数据库实现任务队列,多个线程同时声明任务。如果任务的状态为“可用”,“已声明”,“已完成”,则可以通过数据库查询执行类似于“将状态设置为'已声明',其中状态为'可用'”。如果多个线程以这种方式尝试更改状态,则除第一个线程外,所有线程都会因为脏数据而失败。

请注意,这只涉及使用乐观锁的用例。因此,作为“当您不希望发生许多冲突时使用乐观锁”的替代方案,它也可以在您预期发生冲突但希望正好一个事务成功时使用。


7

关于乐观锁和悲观锁,已经有很多好的评论了。

需要考虑的一个重要点是:

在使用乐观锁时,我们需要注意应用程序如何从这些故障中恢复。

特别是在异步消息驱动架构中,这可能会导致消息处理顺序混乱或更新丢失。

必须仔细考虑故障情况。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接