如何在我的 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 应用程序中避免竞争条件?的主要内容,如果未能解决你的问题,请参考以下文章
如何在 Redux 应用程序中动态/任意创建额外的 reducer 或避免获取竞争条件