3

我注意到 Rails 可能存在多台服务器的并发问题,并且希望强制我的模型始终锁定。这在 Rails 中是否可能,类似于强制数据完整性的独特约束?还是只需要仔细编程?

一号航站楼

irb(main):033:0* Vote.transaction do
irb(main):034:1* v = Vote.lock.first
irb(main):035:1> v.vote += 1
irb(main):036:1> sleep 60
irb(main):037:1> v.save
irb(main):038:1> end

二号航站楼,睡觉时

irb(main):240:0* Vote.transaction do
irb(main):241:1* v = Vote.first
irb(main):242:1> v.vote += 1
irb(main):243:1> v.save
irb(main):244:1> end

数据库启动

 select * from votes where id = 1;
 id | vote |         created_at         |         updated_at         
----+------+----------------------------+----------------------------
  1 |    0 | 2013-09-30 02:29:28.740377 | 2013-12-28 20:42:58.875973 

执行后

一号航站楼

irb(main):040:0> v.vote
=> 1

二号航站楼

irb(main):245:0> v.vote
=> 1

数据库结束

select * from votes where id = 1;
 id | vote |         created_at         |         updated_at         
----+------+----------------------------+----------------------------
  1 |    1 | 2013-09-30 02:29:28.740377 | 2013-12-28 20:44:10.276601 

其他示例

http://rhnh.net/2010/06/30/acts-as-list-will-break-in-production

4

4 回答 4

9

您是正确的,事务本身并不能防止许多常见的并发场景,增加一个计数器就是其中之一。没有强制锁定的通用方法,您必须确保在代码中任何必要的地方使用它

对于简单的计数器递增方案,有两种机制可以很好地工作:

行锁定

只要您在代码中重要的任何地方都执行此操作,行锁定就会起作用。知道重要的地方可能需要一些经验才能对 :/. 如果像上面的代码一样,您有两个资源需要并发保护的地方并且您只锁定一个,那么您将遇到并发问题。

您想使用with_lock表格;这会执行事务和行级锁(表锁显然比行锁的伸缩性要差得多,尽管对于行数很少的表没有区别,因为 postgresql(不确定 mysql)无论如何都会使用表锁。这看起来像这样:

    v = Vote.first
    v.with_lock do
      v.vote +=1
      sleep 10
      v.save
    end

with_lock创建事务,锁定对象代表的行,并在一个步骤中重新加载对象属性,从而最大限度地减少代码中出现错误的机会。但是,这不一定能帮助您解决涉及多个对象交互的并发问题。如果 a) 所有可能的交互都依赖于一个对象,并且您始终锁定该对象,并且 b) 其他对象每个都只与该对象的一个​​实例交互,例如锁定用户行并使用所有属于的对象 (可能间接)该用户对象。

可序列化事务

另一种可能性是使用可序列化事务。从 9.1 开始,Postgresql 有了“真正的”可序列化事务。这可以比锁定行执行得更好(尽管在简单的计数器递增用例中不太重要)

了解可序列化事务给您的最佳方式是:如果您采用应用程序中所有(isolation: :serializable)事务的所有可能排序,则可以保证您的应用程序运行时发生的情况始终与这些排序之一相对应。对于普通交易,这不能保证是真的。

但是,您必须做的交换是注意事务失败时会发生什么,因为数据库无法保证它是可序列化的。在计数器增量的情况下,我们需要做的就是retry

    begin
      Vote.transaction(isolation: :serializable) do
        v = Vote.first
        v.vote += 1
        sleep 10 # this is to simulate concurrency 
        v.save
      end
    rescue ActiveRecord::StatementInvalid => e
      sleep rand/100 # this is NECESSARY in scalable real-world code, 
                     # although the amount of sleep is something you can tune.
      retry
    end

注意重试前的随机睡眠。这是必要的,因为失败的可序列化事务具有不小的成本,所以如果我们不休眠,多个进程竞争相同的资源可能会淹没数据库。在高度并发的应用程序中,您可能需要在每次重试时逐渐增加睡眠。随机对于避免谐波死锁非常重要——如果所有进程的睡眠时间相同,它们可以彼此进入节奏,它们都在睡眠并且系统处于空闲状态,然后它们都尝试锁定同时,系统死锁导致除了一个之外的所有人再次休眠。

当需要可序列化的事务涉及与数据库以外的并发源的交互时,您可能仍需要使用行级锁来完成您需要的操作。这方面的一个例子是,当状态机转换根据对数据库以外的其他事物(如第三方 API)的查询来确定要转换到的状态时。在这种情况下,您需要在查询第三方 API 时使用状态机锁定表示对象的行。您不能将事务嵌套在可序列化事务中,因此您必须使用object.lock!而不是with_lock.

要注意的另一件事是,在事务内部使用之前,在外部获取的任何对象transaction(isolation: :serializable)都应该调用它们。reload

于 2014-05-16T06:22:41.853 回答
0

您可以像这样在模型中执行以下操作

class Vote < ActiveRecord::Base

validate :handle_conflict, only: :update
attr_accessible :original_updated_at
attr_writer :original_updated_at

def original_updated_at
  @original_updated_at || updated_at 
end 

def handle_conflict
    #If we want to use this across multiple models
    #then extract this to module
    if @conflict || updated_at.to_f> original_updated_at.to_f
      @conflict = true
      @original_updated_at = nil
      #If two updates are made at the same time a validation error
      #is displayed and the fields with
      errors.add :base, 'This record changed while you were editing'
      changes.each do |attribute, values|
        errors.add attribute, "was #{values.first}"
      end
    end
  end
end 

original_updated_at是设置的虚拟属性。handle_conflict更新记录时触发。检查updated_at数据库中的属性是否晚于隐藏的属性(在您的页面上定义)。顺便说一句,您应该在您的app/view/votes/_form.html.erb

<%= f.hidden_field :original_updated_at %>

如果存在冲突,则引发验证错误。

如果您使用的是 Rails 4,您将没有 attr_accessible 并且需要添加:original_updated_at到您vote_params的控制器中的方法中。

希望这能带来一些启示。

于 2013-12-29T02:29:03.670 回答
0

ActiveRecord 总是将保存操作包装在事务中。

对于您的简单情况,最好只使用 SQL 更新而不是在 Ruby 中执行逻辑然后保存。这是一个添加模型方法来执行此操作的示例:

class Vote
  def vote!
    self.class.update_all("vote = vote + 1", {:id => id})
  end

此方法避免了在您的示例中锁定的需要。如果您需要更一般的数据库锁定检查,请参阅 David 的建议。

于 2013-12-29T00:13:36.803 回答
0

简单的+1

Vote.increment_counter :vote, Vote.first.id

因为vote同时用于表名和字段,所以这就是 2 的使用方式

TableName.increment_counter :field_name, id_of_the_row
于 2017-09-11T09:12:53.807 回答