如何在Rails应用程序中避免比赛情况?

时间:2022-11-07 06:48:23

I have a really simple Rails application that allows users to register their attendance on a set of courses. The ActiveRecord models are as follows:

我有一个非常简单的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

A ScheduledRun instance has a finite number of places available, and once the limit is reached, no more attendances can be accepted.

ScheduledRun实例有有限的可用位置,一旦达到了限制,就不能接受更多的参与者。

def full?
  attendances_count == capacity
end

attendances_count is a counter cache column holding the number of attendance associations created for a particular ScheduledRun record.

attendances_count是一个计数器缓存列,它包含为特定的ScheduledRun记录创建的出席关联的数量。

My problem is that I don't fully know the correct way to ensure that a race condition doesn't occur when 1 or more people attempt to register for the last available place on a course at the same time.

我的问题是,当一个或更多的人试图同时注册一门课程的最后一个可用位置时,我不完全知道确保不发生种族状况的正确方法。

My Attendance controller looks like this:

我的出勤控制器是这样的:

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

As you can see, it doesn't take into account where the ScheduledRun instance has already reached capacity.

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

Any help on this would be greatly appreciated.

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

Update

更新

I'm not certain if this is the right way to perform optimistic locking in this case, but here's what I did:

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

I added two columns to the ScheduledRuns table -

我在ScheduledRuns表中添加了两列—

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

I also added a method to ScheduledRun model:

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

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

When the Attendance model is saved, ActiveRecord goes ahead and updates the counter cache column on the ScheduledRun model. Here's the log output showing where this happens -

当考勤模型被保存时,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)

If a subsequent update occurs to the ScheduledRun model before the new Attendance model is saved, this should trigger the StaleObjectError exception. At which point, the whole thing is retried again, if capacity hasn't already been reached.

如果在保存新的考勤模型之前对ScheduledRun模型进行后续更新,那么这将触发staleobjecexception异常。此时,如果还没有达到容量,整个过程将再次尝试。

Update #2

更新# 2

Following on from @kenn's response here is the updated attend method on the SheduledRun object:

下面是@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

2 个解决方案

#1


13  

Optimistic locking is the way to go, but as you might have noticed already, your code will never raise ActiveRecord::StaleObjectError, since child object creation in has_many association skips the locking mechanism. Take a look at the following SQL:

乐观锁定是可行的,但是正如您可能已经注意到的,您的代码永远不会引发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)

When you update attributes in the parent object, you usually see the following SQL instead:

更新父对象中的属性时,通常会看到以下SQL语句:

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

The above statement shows how optimistic locking is implemented: Notice the lock_version = 1 in WHERE clause. When race condition happens, concurrent processes try to run this exact query, but only the first one succeeds, because the first one atomically updates the lock_version to 2, and subsequent processes will fail to find the record and raise ActiveRecord::StaleObjectError, since the same record doesn't have lock_version = 1 any longer.

上面的语句显示了如何实现乐观锁:注意,lock_version = 1在WHERE子句中。当竞争条件发生时,并发进程尝试运行这个查询,但只有第一个成功,因为第一个自动更新lock_version 2,和后续的流程将无法找到记录,提高ActiveRecord::StaleObjectError,因为相同的记录没有lock_version = 1。

So, in your case, a possible workaround is to touch the parent right before you create/destroy a child object, like so:

因此,在您的例子中,一个可能的解决方案是在创建/销毁子对象之前触摸父对象,如下所示:

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

It's not meant to strictly avoid race conditions, but practically it should work in most cases.

它并不是要严格地避免比赛条件,但实际上它应该在大多数情况下起作用。

#2


0  

Don't you just have to test if @run.full??

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

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

   # ...
end

Edit

编辑

What if you add a validation like:

如果您添加如下验证:

class Attendance < ActiveRecord::Base
   validate :validates_scheduled_run

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

It won't save the @attendance if the associated scheduled_run is full.

如果相关的scheduled_run已满,则不会保存@ participation。

I haven't tested this code... but I believe it's ok.

我还没有测试这个代码……但我相信没关系。

#1


13  

Optimistic locking is the way to go, but as you might have noticed already, your code will never raise ActiveRecord::StaleObjectError, since child object creation in has_many association skips the locking mechanism. Take a look at the following SQL:

乐观锁定是可行的,但是正如您可能已经注意到的,您的代码永远不会引发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)

When you update attributes in the parent object, you usually see the following SQL instead:

更新父对象中的属性时,通常会看到以下SQL语句:

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

The above statement shows how optimistic locking is implemented: Notice the lock_version = 1 in WHERE clause. When race condition happens, concurrent processes try to run this exact query, but only the first one succeeds, because the first one atomically updates the lock_version to 2, and subsequent processes will fail to find the record and raise ActiveRecord::StaleObjectError, since the same record doesn't have lock_version = 1 any longer.

上面的语句显示了如何实现乐观锁:注意,lock_version = 1在WHERE子句中。当竞争条件发生时,并发进程尝试运行这个查询,但只有第一个成功,因为第一个自动更新lock_version 2,和后续的流程将无法找到记录,提高ActiveRecord::StaleObjectError,因为相同的记录没有lock_version = 1。

So, in your case, a possible workaround is to touch the parent right before you create/destroy a child object, like so:

因此,在您的例子中,一个可能的解决方案是在创建/销毁子对象之前触摸父对象,如下所示:

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

It's not meant to strictly avoid race conditions, but practically it should work in most cases.

它并不是要严格地避免比赛条件,但实际上它应该在大多数情况下起作用。

#2


0  

Don't you just have to test if @run.full??

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

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

   # ...
end

Edit

编辑

What if you add a validation like:

如果您添加如下验证:

class Attendance < ActiveRecord::Base
   validate :validates_scheduled_run

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

It won't save the @attendance if the associated scheduled_run is full.

如果相关的scheduled_run已满,则不会保存@ participation。

I haven't tested this code... but I believe it's ok.

我还没有测试这个代码……但我相信没关系。