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

时间:2016-03-11 06:59:05

标签: mysql ruby-on-rails sidekiq rails-activejob

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

以下是我们实施Payments模型的三次失败尝试。为简单起见,假设先前的付款始终存在且id s随着付款的创建而增加。

尝试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应用程序。

1 个答案:

答案 0 :(得分:4)

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

如果您在使用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