如何在我的 Rails 应用程序中避免竞争条件?

Posted

技术标签:

【中文标题】如何在我的 Rails 应用程序中避免竞争条件?【英文标题】:How do I avoid a race condition in my Rails app? 【发布时间】:2011-03-03 11:54:16 【问题描述】:

我有一个非常简单的 Rails 应用程序,它允许用户注册他们参加一系列课程的情况。 ActiveRecord 模型如下:

class Course < ActiveRecord::Base
  has_many :scheduled_runs
  ...
end

class ScheduledRun < ActiveRecord::Base
  belongs_to :course
  has_many :attendances
  has_many :attendees, :through => :attendances
  ...
end

class Attendance < ActiveRecord::Base
  belongs_to :user
  belongs_to :scheduled_run, :counter_cache => true
  ...
end

class User < ActiveRecord::Base
  has_many :attendances
  has_many :registered_courses, :through => :attendances, :source => :scheduled_run
end

ScheduledRun 实例的可用名额有限,一旦达到限制,就不能再接受出席。

def full?
  attendances_count == capacity
end

attendances_count 是一个计数器缓存列,保存为特定 ScheduledRun 记录创建的出勤关联数。

我的问题是我不完全知道正确的方法来确保当 1 个或多个人同时尝试注册课程的最后一个可用名额时不会发生竞争情况。

我的考勤控制器如下所示:

class AttendancesController < ApplicationController
  before_filter :load_scheduled_run
  before_filter :load_user, :only => :create

  def new
    @user = User.new
  end

  def create
    unless @user.valid?
      render :action => 'new'
    end

    @attendance = @user.attendances.build(:scheduled_run_id => params[:scheduled_run_id])

    if @attendance.save
      flash[:notice] = "Successfully created attendance."
      redirect_to root_url
    else
      render :action => 'new'
    end

  end

  protected
  def load_scheduled_run
    @run = ScheduledRun.find(params[:scheduled_run_id])
  end

  def load_user
    @user = User.create_new_or_load_existing(params[:user])
  end

end

如您所见,它没有考虑 ScheduledRun 实例已达到容量的位置。

对此的任何帮助将不胜感激。

更新

我不确定在这种情况下这是否是执行乐观锁定的正确方法,但这是我所做的:

我在 ScheduledRuns 表中添加了两列 -

t.integer :attendances_count, :default => 0
t.integer :lock_version, :default => 0

我还在 ScheduledRun 模型中添加了一个方法:

  def attend(user)
    attendance = self.attendances.build(:user_id => user.id)
    attendance.save
  rescue ActiveRecord::StaleObjectError
    self.reload!
    retry unless full? 
  end

保存出勤模型后,ActiveRecord 会继续更新 ScheduledRun 模型上的计数器缓存列。这是显示发生这种情况的日志输出 -

ScheduledRun Load (0.2ms)   SELECT * FROM `scheduled_runs` WHERE (`scheduled_runs`.`id` = 113338481) ORDER BY date DESC

Attendance Create (0.2ms)   INSERT INTO `attendances` (`created_at`, `scheduled_run_id`, `updated_at`, `user_id`) VALUES('2010-06-15 10:16:43', 113338481, '2010-06-15 10:16:43', 350162832)

ScheduledRun Update (0.2ms)   UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481)

如果在保存新的出勤模型之前对 ScheduledRun 模型进行了后续更新,这应该会触发 StaleObjectError 异常。此时,如果尚未达到容量,则会再次重试整个过程。

更新 #2

根据@kenn 的回复,这里是SheduledRun 对象上更新的参加方法:

# creates a new attendee on a course
def attend(user)
  ScheduledRun.transaction do
    begin
      attendance = self.attendances.build(:user_id => user.id)
      self.touch # force parent object to update its lock version
      attendance.save # as child object creation in hm association skips locking mechanism
    rescue ActiveRecord::StaleObjectError
      self.reload!
      retry unless full?
    end
  end 
end

【问题讨论】:

你需要使用乐观锁。此截屏视频将向您展示如何操作:link text 你是什么意思,德米特里? 【参考方案1】:

乐观锁定是可行的方法,但您可能已经注意到,您的代码永远不会引发 ActiveRecord::StaleObjectError,因为在 has_many 关联中创建子对象会跳过锁定机制。看看下面的SQL:

UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481)

当您更新 parent 对象中的属性时,您通常会看到以下 SQL:

UPDATE `scheduled_runs` SET `updated_at` = '2010-07-23 10:44:19', `lock_version` = 2 WHERE id = 113338481 AND `lock_version` = 1

上面的语句显示了乐观锁定是如何实现的:注意 WHERE 子句中的lock_version = 1。当竞争条件发生时,并发进程会尝试运行这个精确的查询,但只有第一个成功,因为第一个自动将 lock_version 更新为 2,后续进程将无法find记录并引发ActiveRecord::StaleObjectError,因为同一条记录不再有lock_version = 1

因此,在您的情况下,一种可能的解决方法是在创建/销毁子对象之前触摸父对象,如下所示:

def attend(user)
  self.touch # Assuming you have updated_at column
  attendance = self.attendances.create(:user_id => user.id)
rescue ActiveRecord::StaleObjectError
  #...do something...
end

这并不是要严格避免竞争条件,但实际上它应该在大多数情况下都有效。

【讨论】:

谢谢肯恩。我没有意识到子对象的创建跳过了锁定机制。我也将整个事情包装在一个事务中,这样如果子对象创建失败,父对象就不会不必要地更新。【参考方案2】:

你不只需要测试@run.full?吗?

def create
   unless @user.valid? || @run.full?
      render :action => 'new'
   end

   # ...
end

编辑

如果您添加如下验证会怎样:

class Attendance < ActiveRecord::Base
   validate :validates_scheduled_run

   def scheduled_run
      errors.add_to_base("Error message") if self.scheduled_run.full?
   end
end

如果关联的scheduled_run 已满,则不会保存@attendance

我没有测试过这段代码……但我相信没问题。

【讨论】:

那行不通。问题是@run 表示的记录可能已经被另一个请求更新,导致@run 与数据库中表示的内容不一致。据我所知,乐观锁是解决这个问题的方法。但是,您如何将其应用于关联?

以上是关于如何在我的 Rails 应用程序中避免竞争条件?的主要内容,如果未能解决你的问题,请参考以下文章

避免 redis 竞争条件

如何在 Redux 应用程序中动态/任意创建额外的 reducer 或避免获取竞争条件

如果在调用 QObject::connect() 之前发出信号,如何避免竞争?

如何避免 VxWorks 中条件变量中的竞争条件

如何使用 PHP 单元测试避免竞争条件

如何动态锁定线程并避免竞争条件