瞎扯淡 从 ActiveRecord 看乐观锁

early · 2018年07月15日 · 最后由 StephenZzz 回复于 2019年10月09日 · 3482 次阅读

背景

乐观锁在并发控制中有非常广泛的使用,在并发更新数据时避免了互斥锁的使用,更新冲突较少时有着良好的性能表现。

在 Rails 中也集成了乐观锁的功能,由无所不能的 ActiveRecord 实现。使用的方式及其简单,只需要在对应的model中加入一个lock_version字段:

class CreateOrders < ActiveRecord::Migration[5.1]
  def change
    create_table :orders do |t|
      t.integer :lock_version, default: 0
      t.string :name
      t.integer :leave_count,default: 0
    end
  end
end

model数据更新的时候就会自动检测数据版本,只有持有最新的lock_version数据的更新操作能成功。

# p1 p2 持有同样的数据版本
p1 = Order.find(1)
p2 = Order.find(1)

p1.name = "zhangsan"
p1.save # 成功, lock_version字段值会自动增加

p2.name = "cuihua"
p2.save # Raises an ActiveRecord::StaleObjectError

当持有旧版本的更新操作会得到一个ActiveRecord::StaleObjectError异常。具体可以查看官方文档

提出疑问

那它是如何实现的呢?

包括官方文档在内的众多资料只是提供了如何在 Rails 中使用乐观锁的方法,只是反复提到Rails会自动检测数据版本是否过期,具体实现只字未提。作为一名搬砖工人,我对此感到非常失落和焦虑。即使是搬砖,也要知道搬的砖是怎么烧出来的。(主旨点明,本文完)

所以,不想被拖拉机替代的,接下来我们一起探寻它是如何实现的。这里有两个问题需要思考:

  • 文档中说的自动检测是如何实现的?
  • 异常由谁产生,数据库还是 ActiveRecord?

稍微注意会发现这两个问题的答案异常简单:

 pry(main)> p1 = Order.find(1)
 pry(main)> p2 = Order.find(1)
 pry(main)> p1.leave_count = 9
=> 9
 pry(main)> p1.save
   (0.2ms)  BEGIN
  SQL (0.5ms)  UPDATE `orders` SET `leave_count` = 9, `updated_at` = '2018-07-15 06:47:28', `lock_version` = 1 WHERE `orders`.`id` = 1 AND `orders`.`lock_version` = 0
   (2.3ms)  COMMIT
=> true

 pry(main)> p2.leave_count = 9
=> 9
 pry(main)> p2.save
   (0.3ms)  BEGIN
  SQL (0.4ms)  UPDATE `orders` SET `leave_count` = 9, `updated_at` = '2018-07-15 06:47:53', `lock_version` = 1 WHERE `orders`.`id` = 1 AND `orders`.`lock_version` = 0
   (0.1ms)  ROLLBACK
ActiveRecord::StaleObjectError: Attempted to update a stale object: Order.
from /home/dog/.rvm/gems/ruby-2.5.1@study/gems/activerecord-5.1.6/lib/active_record/locking/optimistic.rb:95:in `_update_row'

ActiveRecord 会创建一个巧妙的 SQL:

UPDATE `orders` SET `leave_count` = 9, `updated_at` = '2018-07-15 06:47:28', `lock_version` = 1 WHERE `orders`.`id` = 1 AND `orders`.`lock_version` = 0

UPDATE本质上是先SELECT到对应条件的数据,再执行数据更新。如果当前持有的lock_version过期了,对应的数据行不会查询到,也就不会有更新操作,数据库会返回更新数据行为 0,也不会产生异常。

通过查看源码,发现异常是由 ActiveRecord 抛出:

# 有删减
def _update_row(attribute_names, attempted_action = "update")
  return super unless locking_enabled?

  affected_rows = self.class._update_record(
    attributes_with_values(attribute_names),
    self.class.primary_key => id_in_database,
    locking_column => previous_lock_value
  )

  if affected_rows != 1
    raise ActiveRecord::StaleObjectError.new(self, attempted_action)
  end
end

疑问揭晓,非常简单巧妙。

进一步思考

这种实现是利用了数据库更新时的原子性,例如在 MySQL 中会有行锁,这是一个悲观锁。那么这样还能叫乐观锁吗?翻一翻乐观锁的定义

Optimistic concurrency control (OCC) is a concurrency control method applied to transactional systems such as relational database management systems and software transactional memory. OCC assumes that multiple transactions can frequently complete without interfering with each other. While running, transactions use data resources without acquiring locks on those resources. Before committing, each transaction verifies that no other transaction has modified the data it has read. If the check reveals conflicting modifications, the committing transaction rolls back and can be restarted.[1] Optimistic concurrency control was first proposed by H.T. Kung and John T. Robinson.

大意是在并发控制时不会有锁产生,在提交时会去检测数据是否已经被修改,如没有则直接更新提交,否则就回滚。这是一种理念,看看具体的一种实现CAS(比较交换)

CAS 的全称为compare and swap,可以这样理解:A(目标数据的地址),currentVersion(位于 A 的数据的最新版本号),holdVersion(更新者持有的数据版本号),B(新数据)。如果 holdVersion == currentVersion,就将 A 地址的数据更新为 B,否则更新失败。

这样来看,ActiveRecord 中的实现满足 CAS 的理念,可以说是非常简洁完美的实现。

ActiveRecord 中确实没有产生锁,但是它确实是依赖于数据库更新时的锁,也就是说有锁的参与,这个怎么理解?(不是无锁吗)

实际上,几乎所有 CAS 都是由 CPU 指令实现,由 CPU 保证执行的原子性,如果是单核 CPU 的话,指令反正可以理解为是一条一条顺序执行的,不会有冲突。

但是在多 CPU 的情况下呢?如何保证指令中比较交换等步骤的原子性?实际上,经查阅资料,这种情况下 CPU 硬件级别也会有一个锁,保证 CAS 指令执行的原子性,还是有锁的参与。不过层级不一样,这是更加底层的实现,越底层的锁,开销越小,上层并不知晓。

所以,对于乐观锁的理解,需要分层来看。在 ActiveRecord 这种应用层来说,它所的实现的就是乐观锁。只要当前层级的实现中没有锁,且满足乐观锁的理念,那么它就可以认为是乐观锁,尽管它底层可能依赖的是悲观锁。

有没有彻彻底底的乐观锁呢?

使用场景

从上面可以知道,当数据版本失效时去更新,会得到一个异常。这在代码中需要写一个异常捕获来捕捉这个特定的异常,以便进一步选择是重试还是直接返回失败。

如果数据会频繁更新,则数据冲突的可能性加大,可能会频繁重试。当业务逻辑读多写少,或对重试不敏感,且重试的代价较小时,乐观锁也许是一种较好的选择。

读多写少,那上悲观锁的几率也低,直接使用行锁得了。

pynix 回复

是的,一般读多写少怎么玩都行。乐观锁就是因为无锁,吞吐量大些,单次响应快(可能成功,可能失败)。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号