在用Rails写真实业务逻辑之前,很容易就把锁跟事务两个概念给混淆了,然而随着业务的推进,渐渐发现这两者其实根本不是一回事。只不过他们经常会放在一起使用,导致许多人会误以为他们就是一个东西(包括笔者)。

PS:原则上,事务跟锁应该不能完全算是Rails层面上的东西,更应该是数据库层面的东西。只不过本文的所有案例均是基于Rails,望读者理解。

事务

Rails的文档里面对事务的描述是这样的

Transactions are protective blocks where SQL statements are only permanent if they can all succeed as one atomic action.

最经典的应用场景莫过于转账,当你要给别人转账的时候数据库会做两个事情

  1. 你的钱少了。
  2. 别人的钱多了。

虽然我们固然都希望自己的钱没少,然后别人的钱多了,但是银行一般都不允许这样。这两个事情就是一个事务,它们要么两件都被完成,要么两件都完成不了。来一款简单的模型

class User < ApplicationRecord
  def transfer(other, money)
    self.balance -= money
    other.balance += money
    save!
    other.save!
  end
end

咋一看没啥问题,然而如果我给每个人的账户都加一个限额,比如1000元好了。

class User < ApplicationRecord
  validates :balance, numericality: { less_than_or_equal_to: 1000 }

  # ...
end

现在大家账户里面都有1000元

> SELECT * FROM users;
 id | nickname | balance
----+----------+---------
 58 | Lan      |  1000.0
 59 | Ruby     |  1000.0

然后A用户往B用户转500元试试看

> a = User.find_by(nickname: 'Lan')
> b = User.find_by(nickname: 'Ruby')
> a.transfer(b, 500)
=> .... ActiveRecord::RecordInvalid (Validation failed: Balance must be less than or equal to 1000)
> SELECT * FROM users;
 id | nickname | balance
----+----------+---------
 59 | Ruby     |  1000.0
 58 | Lan      |   500.0
(2 rows)

如我们所料,报错了,然而更不想看到的事情发生了,A的钱少了,B却没钱入账。为了规避这种情形需要把A付款,与B收款两个操作放在一个事务中去,合并为一个原子操作。要么两个都失败,要么两个都成功。

class User < ApplicationRecord
  validates :balance, numericality: { less_than_or_equal_to: 1000 }

  def transfer(other, money)
    User.transaction do
      self.balance -= money
      other.balance += money
      save!
      other.save!
    end
  end
end

好了,重新运行上面的脚本

> reload!
> a.transfer(b, 500)
=> .... ActiveRecord::RecordInvalid (Validation failed: Balance must be less than or equal to 1000)

现在是A的钱也不会少B也收不到钱,因为转账过程中出现异常(我把A的账户恢复到1000元再跑的),事务被回滚了

> SELECT * from users;
 id | nickname | balance
----+----------+---------
 59 | Ruby     |  1000.0
 58 | Lan      |  1000.0

再多说两句,这几种写法效果是一样的

  # case 1
  def transfer(other, money)
    self.transaction do
    end
  end
  # case 2
  def transfer(other, money)
    User.transaction do
    end
  end

  # case 3, other Class
  def transfer(other, money)
    Withdraw.transaction do
    end
  end

因为事务是基于每一个数据库链接的,而不是针对某一个模型 (Model) 或者模型所对应的某个实例,文档也有说明

Though the transaction class method is called on some Active Record class, the objects within the transaction block need not all be instances of that class. This is because transactions are per-database connection, not per-model.

锁机制一般用来避免多个用户对同一条记录的修改,在高并发的情形下会用得比较多。试想,当多个用户购买同一件商品,我们的观念都是“先到先得”。然而如果当用户A拍下商品之后,商品的下单流程结束之前,用户B也来拍商品了,而且这个时候在B看来(代码层面)商品还是可购买状态,那就会造成A跟B同时创建了对商品的订单。哪怕商品只有一件,大家都尝试去支付商品,并改变原来商品的销售状态,这种情形便会造成后台数据紊乱。要避免这种情形可以考虑引入锁机制。在Rails中锁大致分两种,乐观锁和悲观锁,其中悲观锁是数据库级别的锁,我们可以根据业务需要选择不同的锁级别。

乐观锁(Optimistic Locking)

乐观锁其实并不能算是数据库层面的锁,它主要依靠资源本身的一个字段lock_version来判定当前的实例中的数据是否是“陈腐”的。如果不喜欢lock_version这个字段名也可以用以下方式来设置自己喜欢的字段名

class Person < ActiveRecord::Base
  self.locking_column = :lock_person
end

顺带地构造一个people数据表

class CreatePeople < ActiveRecord::Migration[6.0]
  def change
    create_table :people do |t|
      t.string :name
      t.integer :lock_person

      t.timestamps
    end
  end
end

每次对数据的修改都会改变lock_person的值

> p = Person.first
=> #<Person id: 1, name: "Lan", lock_person: 0....

> p.touch
=> #<Person id: 1, name: "Lan", lock_person: 1....

> p.touch
=> #<Person id: 1, name: "Lan", lock_person: 2....

假设有多个人同时获取数据,并对数据进行修改,那么会出现什么情况?我分两个终端来模拟这种场景

> # Term1

> p_in_term1 = Person.first # 终端1
=> #<Person id: 1, name: "Lan", lock_person: 2 ...
> # Term2

> p_in_term2 = Person.first # 终端2
=> #<Person id: 1, name: "Lan", lock_person: 2 ...

可见此时被提取的实例中他们的lock_person字段是一致的。这个时候Term2先对数据进行调整,然后Term1也修改数据

> # Term2

> p_in_term2.update(name: 'Zhang')
=> true
> p_in_term2
=> #<Person id: 1, name: "Zhang", lock_person: 3 ...
> # Term1

> p_in_term1.update(name: 'Chen')
....
ActiveRecord::StaleObjectError (Attempted to update a stale object: Person.)
> p_in_term1
=> #<Person id: 1, name: "Chen", lock_person: 2 ...

这会提示Term1中的数据已经是老数据了,直接修改可能会覆盖掉Term2用户的修改,因为他的lock_person已然跟数据库中的不一致了。修改无法入库,要想入库则需要重新提取数据库中的最新记录,并在此基础上做修改

> p_in_term1.reload.update(name: 'Chen')
=> true
> p_in_term1
=> #<Person id: 1, name: "Zhang", lock_person: 4 ....

数据成功更新,并且版本号也往上提升了。对于操作并不是很频繁的场景(像上面这种),乐观锁还是很有用的,总的来说它就是在每一份数据里面存一份版本号,修改数据之前需要检查当前用例的版本号跟数据库中的版本号是否匹配,如果不匹配的话则报ActiveRecord::StaleObjectError异常,在写业务代码的时候可能需要加一些重试机制吧。这篇文章或许会是不错的参考。

不管怎么说这都是比较依赖于Ruby程序级别的监测,要是竞争激烈的场景,这层监测可能会失效,这时候使用悲观锁或许会是更好的选择。

悲观锁(Pessimistic Locking)

上面提到的乐观锁更多是依赖于Rails程序本身的监测,配合数据库中的字段值来决定是否能修改当前提取的数据,然而在一些场景下Rails程序本身的监测可能不够可靠,这种时候则需要依赖数据库级别的锁才能够保证数据的完整性,也就是悲观锁 。在Rails里面使用悲观锁不需要改变任何的字段,只要采用支持锁的数据库(PostgreSQL/MySQL)即可。这里我选用了PostgreSQL。

所谓数据库锁,就是在查询数据的时候加上锁字句把相关的记录锁住,在事务过程中这些记录都会保留这个锁,其他人如果想要操作相关的记录则需要等待这个锁释放才行。在Rails中,最简单的悲观锁则是直接Model.lock

> Person.lock.find_by(name: 'Lan')
  Person Load (0.9ms)  SELECT "people".* FROM "people" WHERE "people"."name" = $1 LIMIT $2 FOR UPDATE  [["name", "Lan"], ["LIMIT", 1]]
=> #<Person id: 1, name: "Lan" ....

关键是最后那个FOR UPDATE。只不过在这种情况下其实我们根本感觉不到锁的存在,模拟两个用户试试

> # Term1
> Person.lock.find_by(name: 'Lan')
=> #<Person id: 1, name: "Lan" ....
> # Term2
> p = Person.find_by(name: 'Lan')
=> #<Person id: 1, name: "Lan", ...

> p.update(name: 'Lan For Update')
=> true

> p
=> #<Person id: 1, name: "Lan For Update", ....

Term2对数据的操作基本是无碍的,因为Term1获得的锁在查询完成的时候就已经释放了。锁保留的时长取决于事务的生命周期长短,在事务失败或者完成的时候锁就会释放。鉴于上面对事务的学习我们可以弄一个耗时较久的事务

> # Term1

> Person.transaction do
*   Person.lock.find_by(name: 'Lan')
*   sleep 100000
* end
   (0.2ms)  BEGIN
   Person Load (0.4ms)  SELECT "people".* FROM "people" WHERE "people"."name" = $1 LIMIT $2 FOR UPDATE  [["name", "Lan"], ["LIMIT", 1]]


可以看到它在等待,并且从时间上看有好长一段时间这个事务都无法完成,那么现在我们模拟两个用户来试试

> # Term2

> p = Person.find_by(name: 'Lan')
=> #<Person id: 1, name: "Lan", ...
> p.update(name: 'Update Lan In Term2')
   (0.3ms)  BEGIN


> # Term3

> p = Person.find_by(name: 'Lan')
=> #<Person id: 1, name: "Lan", ...
> p.update(name: 'Update Lan In Term3')
  (0.3ms)  BEGIN


每一个终端的最后面我加了几个空行,代表它们都卡住了。因为锁至今还在Term1手上,其他人想要操作则需要等到Term1的锁释放。不过正常来说我们的业务逻辑不会等待那么久的(除非死锁),这里只是做个演示。在正常写业务的时候,如果其他人也要更新数据,也应该给数据上锁才对,以防你更新之前别人把原来的数据改了,也就是说在Term2,跟Term3里面,更改数据之前也需要这样写

Person.lock.find_by(name: 'Lan')

上面不这样写是顺便验证一下FOR UPDATE锁会锁住单纯想更新指定记录的行为。这是比较常规的锁行为,强度也较高,然而并不是所有的场景都适用,根据业务逻辑的不同我们可能会需要一些强度不同的锁,可以通过类似这样的方式去设置

> Person.lock('FOR NO KEY UPDATE').find_by(name: 'Jayce')
  Person Load (4.0ms)  SELECT "people".* FROM "people" WHERE "people"."name" = $1 LIMIT $2 FOR NO KEY UPDATE  [["name", "Jayce"], ["LIMIT", 1]]
=> #<Person id: 2, name: "Jayce" ...

> Person.lock('FOR SHARE').find_by(name: 'Jayce')
  Person Load (1.3ms)  SELECT "people".* FROM "people" WHERE "people"."name" = $1 LIMIT $2 FOR SHARE  [["name", "Jayce"], ["LIMIT", 1]]
=> #<Person id: 2, name: "Jayce" ...

可见他们的查询语句都有所不同,区别也比较细微。笔者现在也还没能很好地理解它们,就不在这里详细展开了。

另外,其实下面两种写法效果是类似的

Person.transaction do
  Person.lock.find_by(name: 'Lan')
  sleep 100000
end
p = Person.lock.find_by(name: 'Lan')
p.with_lock do
  sleep 100000
end

with_lock是开启事务的时候同时锁住记录,所以我一开始也是混淆了transactionwith_lock,因为写法太类似了。区别在于transaction本身并不会给记录加锁,需要使用者在事务块中为对应的记录加锁,而with_lock本身就是针对记录的,在代码块开始的时候就会一步到位开启事务块并给记录上锁。

小结

鉴于上面的实验,在一些操作并不是很频繁的场景,比如多人编辑同一个记录的情况下乐观锁就够用了,主要是利用代码级别的限制,如果表单提交时的lock_version无法跟数据库现有的匹配那么就无法提交对数据的更改,需要获取最新的数据库记录(以及lock_version)才能在最新记录的基础上进一步对数据进行修改。

而对于像多人下单这种密集操作的场景还是要用数据库级别的悲观锁才比较保险,然而上面的实验也看到,记录一旦锁上,其他人想要操作就需要等到锁释放才行,在一些场景下可能体验不是特别好。数据库提供了不同级别的锁供我们选择,可以从中选择最适合自身业务的锁,以带来更好的用户体验。

结语

这篇文章主要谈谈笔者对事务跟锁的理解,以及他们在Rails中的表现形式。随着业务的升级,他们两者在数据库管理系统中的ACID上扮演着重要角色,理解他们有助于编写出可靠性更高的业务代码。

参考资料:

  1. A Guide to Optimistic Locking: https://blog.engineyard.com/a-guide-to-optimistic-locking
  2. Optimistic vs. Pessimistic locking: https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking
  3. ActiveRecord::Locking::Pessimistic: https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html
  4. ActiveRecord::Locking::Optimistic: https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
  5. Rails 中乐观锁与悲观锁的使用: https://ruby-china.org/topics/28963