如何在记录付款和运行余额的 Rails 模型中避免竞争条件?

Posted

技术标签:

【中文标题】如何在记录付款和运行余额的 Rails 模型中避免竞争条件?【英文标题】:How to avoid race condition in a rails model that records payments and running balance? 【发布时间】:2016-06-26 08:02:35 【问题描述】:

我有一个简单的模型Payments,它有两个字段amountrunning_balance。当创建新的payment 记录时,我们会查找其先前付款的running_balance,例如last_running_balance,并将last_running_balance+amount 保存为当前付款的running_balance

这是我们实施Payments 模型的三个失败尝试。为简单起见,假设之前的付款始终存在,并且ids 随着付款的创建而增加。

尝试 1:

class Payments < ActiveRecord::Base
    before_validation :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.last
        self.running_balance = p.running_balance + amount
    end
end

尝试 2:

class Payments < ActiveRecord::Base
    after_create :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.where("id < ?", id).last
        update!(running_balance: p.running_balance + amount)
    end
end

尝试 3:

class Payments < ActiveRecord::Base
    after_commit :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.where("id < ?", id).last
        update!(running_balance: p.running_balance + amount)
    end
end

这些实现可能会导致系统出现竞争条件,因为我们使用sidekiq 在后台创建付款。假设最后一次付款是payment 1。当同时创建两个新付款时,比如说payment 2payment 3,它们的running_balance 可能会根据payment 1 的运行余额计算,因为可能是payment 3 正在计算它的运行余额payment 2尚未保存到数据库中。

特别是,我对避免运行条件的修复感兴趣。我还热衷于研究实现类似支付系统的其他 Rails 应用程序。

【问题讨论】:

我可能不会存储派生数据。但是,如果我这样做了,那将只是一个简单的 UPDATE 查询。 这听起来更像是建模的缺陷。我会考虑是否应该创建类似 Account 的模型来跟踪余额,而不是在付款之间创建奇怪的相互依赖关系。 @max 不幸的是,我们必须在每次付款时显示运行余额。鉴于有数千笔付款,如果不计算和存储每笔付款的运行余额,我看不出有什么办法。 嗨@Strawberry 我很高兴听到可以完成这项工作的原子更新。 在这种情况下,请考虑遵循以下简单的两步操作: 1. 如果您还没有这样做,请提供适当的 DDL(和/或 sqlfiddle),以便我们可以更轻松地复制问题。 2. 如果您尚未这样做,请提供与步骤 1 中提供的信息相对应的所需结果集。 【参考方案1】:

更新:这是第一个版本,对于实际可行的方法,请参见下文:

如果您在使用pessimistic locking 计算最后余额时锁定最后一笔付款,则可以摆脱竞争条件。为此,您始终需要使用交易块包装创建付款。

class Payments < ActiveRecord::Base
  before_create :calculate_running_balance

  private
  def calculate_running_balance
    last_payment = Payment.lock.last
    self.running_balance = last_payment.running_balance + amount
  end
end

# then, creating a payment must always be done in transaction
Payment.transaction do
  Payment.create!(amount: 100)
end

获取最后一个Payment 的第一个查询还将在包装它的事务期间锁定记录(并延迟进一步查询它),即直到事务完全提交并创建新记录的那一刻。 如果另一个查询同时也尝试读取锁定的最后一笔付款,则必须等到第一笔交易完成。因此,如果您在创建付款时在 sidekiq 中使用交易,您应该是安全的。

有关详细信息,请参阅上面链接的指南。

更新:没那么容易,这种方法会导致死锁

经过一些广泛的测试,问题似乎更加复杂。如果我们只锁定“最后一个”付款记录(Rails 将其转换为SELECT * FROM payments ORDER BY id DESC LIMIT 1),那么我们可能会遇到死锁。

这里我介绍导致死锁的测试,实际工作的方法在下面。

在下面的所有测试中,我都在 mysql 中使用一个简单的 InnoDB 表。我创建了最简单的payments 表,只在amount 列中添加了第一行和Rails 中的随附模型,如下所示:

# sql console
create table payments(id integer primary key auto_increment, amount integer) engine=InnoDB;
insert into payments(amount) values (100);
# app/models/payments.rb
class Payment < ActiveRecord::Base
end

现在,让我们打开两个 Rails 控制台,在第一个控制台会话中使用最后一个记录锁定和新行插入以及第二个控制台会话中的另一个最后一行锁定开始一个长时间运行的事务:

# rails console 1
>> Payment.transaction  p = Payment.lock.last; sleep(10); Payment.create!(amount: (p.amount + 1));  
D, [2016-03-11T21:26:36.049822 #5313] DEBUG -- :    (0.2ms)  BEGIN
D, [2016-03-11T21:26:36.051103 #5313] DEBUG -- :   Payment Load (0.4ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1 FOR UPDATE
D, [2016-03-11T21:26:46.053693 #5313] DEBUG -- :   SQL (1.0ms)  INSERT INTO `payments` (`amount`) VALUES (101)
D, [2016-03-11T21:26:46.054275 #5313] DEBUG -- :    (0.1ms)  ROLLBACK
ActiveRecord::StatementInvalid: Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction: INSERT INTO `payments` (`amount`) VALUES (101)

# meanwhile in rails console 2
>> Payment.transaction  p = Payment.lock.last; 
D, [2016-03-11T21:26:37.483526 #8083] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-11T21:26:46.053303 #8083] DEBUG -- :   Payment Load (8569.0ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1 FOR UPDATE
D, [2016-03-11T21:26:46.053887 #8083] DEBUG -- :    (0.1ms)  COMMIT
=> #<Payment id: 1, amount: 100>

第一个事务以死锁告终。一种解决方案是使用此答案开头的代码,但在发生死锁时重试整个事务。

重试死锁事务的可能解决方案:(未经测试)

利用@M.G.Palmer 在this SO answer 中重试锁定错误的方法:

retry_lock_error do
  Payment.transaction 
    Payment.create!(amount: 100)
  end
end

当发生死锁时,重试事务,即找到并使用新的最后一条记录。

带测试的工作解决方案

我came across 的另一种方法是锁定表的所有记录。这可以通过锁定 COUNT(*) 子句来完成,并且它似乎始终如一地工作:

# rails console 1
>> Payment.transaction  Payment.lock.count; p = Payment.last; sleep(10); Payment.create!(amount: (p.amount + 1));
D, [2016-03-11T23:36:14.989114 #5313] DEBUG -- :    (0.3ms)  BEGIN
D, [2016-03-11T23:36:14.990391 #5313] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM `payments` FOR UPDATE
D, [2016-03-11T23:36:14.991500 #5313] DEBUG -- :   Payment Load (0.3ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1
D, [2016-03-11T23:36:24.993285 #5313] DEBUG -- :   SQL (0.6ms)  INSERT INTO `payments` (`amount`) VALUES (101)
D, [2016-03-11T23:36:24.996483 #5313] DEBUG -- :    (2.8ms)  COMMIT
=> #<Payment id: 2, amount: 101>

# meanwhile in rails console 2
>> Payment.transaction  Payment.lock.count; p = Payment.last; Payment.create!(amount: (p.amount + 1));
D, [2016-03-11T23:36:16.271053 #8083] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-11T23:36:24.993933 #8083] DEBUG -- :    (8722.4ms)  SELECT COUNT(*) FROM `payments` FOR UPDATE
D, [2016-03-11T23:36:24.994802 #8083] DEBUG -- :   Payment Load (0.2ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1
D, [2016-03-11T23:36:24.995712 #8083] DEBUG -- :   SQL (0.2ms)  INSERT INTO `payments` (`amount`) VALUES (102)
D, [2016-03-11T23:36:25.000668 #8083] DEBUG -- :    (4.3ms)  COMMIT
=> #<Payment id: 3, amount: 102>

通过查看时间戳,您可以看到第二个事务等待第一个事务完成,而第二个插入已经“知道”第一个事务。

所以我提出的最终解决方案如下:

class Payments < ActiveRecord::Base
  before_create :calculate_running_balance

  private
  def calculate_running_balance
    Payment.lock.count # lock all rows by pessimistic locking
    last_payment = Payment.last # now we can freely select the last record
    self.running_balance = last_payment.running_balance + amount
  end
end

# then, creating a payment must always be done in transaction
Payment.transaction do
  Payment.create!(amount: 100)
end

【讨论】:

您好 BoraMa,感谢您的回复。我有一个关于指南中没有提到的锁定机制的问题。如果另一个查询也尝试读取锁定的最后一次付款,并且它一直等到第一笔交易完成,它是否仍然获得最新的最后一次付款记录? 您好子林,您对这个问题提出了很好的观点。我做了一些测试,发现了一些问题和解决方案,请看我更新的答案。 BoraMa,感谢您的详细说明。事实上,我们已经采用了您的第一个解决方案,到目前为止它运行良好。同时,我正试图围绕您的第一个测试用例。在这个SO answer 中,当两个事务试图以相反的顺序锁定两个锁时,就会发生死锁。我发现很难将相同的逻辑应用于您的测试用例。如果您能教我在这种情况下如何推理死锁,我将不胜感激。 子林,坦率地说,我自己很想完全了解这里发生了什么,因为我不知道。我认为死锁行为与 MySQL 不仅锁定给定记录而且在与此类似的情况下锁定其他记录以及它们之间的“间隙”这一事实有关。请参阅here 和here 以获得更深入的解释。也许,在这种特殊情况下(order by id desc limit 1),锁定顺序可能不完全一致。但我们需要一些 MySQL 专家来确认这一点,因为这基本上是我的一个疯狂猜测:)。 只是一个后续。到目前为止,我们很少遇到死锁。至于我们的应用程序,我们在抛出死锁异常时使用sidekiq回填作业(类似于帖子中的retry lock error)。

以上是关于如何在记录付款和运行余额的 Rails 模型中避免竞争条件?的主要内容,如果未能解决你的问题,请参考以下文章

成功付款后履行订单 Stripe Rails

rails回调未得到执行

如何避免 Rails 脚手架将模型放入命名空间

如何保存模型,而在Rails的运行回调

如何在 Rails 中使用 PostgreSQL 的 BETWEEN 来避免重复时间戳?

Rails 4 - 如何在活动记录查询中为包含()和连接()提供别名