Rails 请求之间的实例变量冲突

Rails Instance Variable Conflict between Requests

我有一组价格:@price_queue。

它作为 Prices.find(1).price_list 在 PostgreSQL 中持久存在并被播种。

发起交易时,交易采用@price_queue 中的下一个价格,并发送到支付处理器进行收费。

def create

  price_bucket = Prices.find(1)

  price_bucket.with_lock do                          
    @price_queue = price_bucket.price_list         
    @price = @price_queue.shift                    
    price_bucket.save                              
  end

      customer = Stripe::Customer.create(
          :email        => params[:stripeEmail],
          :card         => params[:stripeToken],
      )
      charge = Stripe::Charge.create(
          :customer     => customer.id,
          :amount       => @price * 100,
      )

      if charge["paid"]
        Pusher['price_update'].trigger('live', {message: @price_queue[0]})
      end

如果交易成功,它应该摆脱持有的@price。 如果失败,价格应该放回@price_queue.

  rescue Stripe::CardError => e
    flash[:error] = e.message
    @price_queue.unshift(@price)
    Pusher['price_update'].trigger('live', {message: @price_queue[0]})
    price_bucket.price_list = @price_queue
    price_bucket.save
    redirect_to :back
  end

我在测试时发现了一个主要错误,以毫秒为间隔,两个失败的事务然后一个通过。

price_queue = [100, 101, 102, 103, ...]

用户 1 获得 100(在 Stripe 仪表板上确认)

用户 2 获得 101(在 Stripe 仪表板上确认)

用户 3 获得 102(在 Stripe 仪表板上确认)

预期:

假设还没有取消移位

price_queue = [103, 104, ...]

用户 1 失败,放回 100

price_queue = [100, 103, ...]

用户 2 失败,将 101 放回

price_queue = [101, 100, 103, ...]

用户3通过,102消失

实际情况:

price_queue = [101, 102, 103, 104, ...]

正如我们所见,100 正在消失,尽管它应该回到队列中,101 回到队列中(很可能不是预期的行为),102 被放回队列中,即使它甚至不应该穿越救援路径。

我在 Heroku 上使用 Puma。

我尝试将价格存储在 session[:price]cookie[:price] 中,并将其分配给局部变量价格,无果。

我一直在阅读并认为这可能是由多线程环境引起的范围问题,其中@price 正在泄漏到其他控制器操作并被重新分配或变异。

如有任何帮助,我们将不胜感激。 (也可以随意批评我的代码)

这与实例变量泄漏或类似情况无关 - 只是此处发生的一些经典竞争条件。两个可能的时间表:

  • 请求1从数据库中获取价格(数组为[100,101,102])
  • 请求 2 从数据库中获取价格(数组是 [100,101,102] - 一个单独的副本)
  • 请求 1 锁定价格,删除价格并保存
  • 请求 2 锁定价格,删除价格并保存

这里重要的是请求 2 使用旧的价格副本,其中不包括请求 1 所做的更改:两个实例都将从数组中移出相同的值(请求可能是同一个 puma worker 上的不同线程,不同的 worker 甚至不同的 dynos - 没关系)

另一个失败场景是

  • 请求 1 获取价格,删除价格并保存。数据库中数组为[101,102,103,...],内存中数组为[101,102,103,...]
  • 请求 2 获取价格,删除价格并保存。数据库中的数组为[102,103,...]
  • 请求2的条带交易成功
  • 请求 1 的条带事务失败,因此它将 100 放回数组并保存。因为您还没有从数据库重新加载,所以这会覆盖请求 2 中的更改。

为了正确解决这个问题,我将获取和替换价格的逻辑分解为他们自己的方法——类似于

class Prices < ActiveRecord::Base

  def with_locked_row(id)
    transaction do
      row = lock.find(id)
      result = yield row
      row.save #on older versions of active record you need to tell rails about in place changes
      result
    end

  def self.get_price(id)
    with_locked_row(id) {|row| row.pricelist.shift}
  end

  def self.restore_price(id, value)
    with_locked_row(id) {|row| row.pricelist.unshift(value}
  end
end

然后你会做

  Prices.get_price(1) # get a price
  Prices.restore_price(1, some_value) #put a price back if the charging failed

此代码与您的原始代码之间的主要区别在于:

  • 我获取一个锁定的行并更新它,而不是获取一个行然后锁定它。这消除了我概述的第一个场景中的 window
  • 释放锁后,我不会继续使用同一个活动记录对象 - 一旦锁被释放,其他人可能会在你背后更改它。

您也可以通过乐观锁定(即没有显式锁定)来做到这一点。唯一会改变代码的是

    def with_locked_row(id)
      begin
        row = lock.find(id)
        result = yield row
        row.save #on older versions of active record you need to tell rails about in place changes
        result
      rescue ActiveRecord::StaleObjectError
        retry
      end
    end

您需要添加一个名为 lock_version 的默认值为 0 的非空整数列才能正常工作。

哪个性能更好取决于您体验到多少并发性,对此 table 还有哪些其他访问等等。就我个人而言,除非有令人信服的理由,否则我会默认使用乐观锁定。