简体   繁体   English

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

[英]How to avoid race condition in a rails model that records payments and running balance?

I have a simple model, Payments , which has two fields amount and running_balance . 我有一个简单的模型, Payments ,有两个字段amountrunning_balance When a new payment record is created, we look for the running_balance of its previous payment, say last_running_balance and save last_running_balance+amount as the running_balance of the current payment. 创建新的payment记录时,我们会查找其先前付款的running_balance ,例如last_running_balance ,并将last_running_balance+amount保存为当前付款的running_balance

Here are our three failed attempts to implement the Payments model. 以下是我们实施Payments模式的三次失败尝试。 For simplicity, assume the previous payment always exists and id s are increasing as payments are created. 为简单起见,假设先前的付款始终存在,并且id随着付款的创建而增加。

Attempt 1: 尝试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

Attempt 2: 尝试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

Attemp 3: 尝试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

These implementations may cause race conditions in the system as we are using sidekiq to create payments in the background. 这些实现可能会导致系统中的竞争条件,因为我们使用sidekiq在后台创建付款。 Suppose the last payment is payment 1 . 假设最后一笔付款是payment 1 When two new payments, say payment 2 and payment 3 are created at the same time, their running_balance might be computed based on the running balance of payment 1 because it might be the case that when payment 3 is figuring out its running balance payment 2 has not been saved to the database yet. 当两个新的支付,即payment 2payment 3同时创建时,他们的running_balance可能会根据payment 1的运行余额计算,因为可能是当payment 3计算其运行余额payment 2尚未保存到数据库中。

In particular, I am interested in a fix that avoids the running condition. 特别是,我对避免运行条件的修复感兴趣。 I am also keen on looking at other rails apps that implement similar payment systems. 我也热衷于查看实现类似支付系统的其他rails应用程序。

Update: this is the first version, for an actually working approach, see below: 更新:这是第一个版本,对于实际工作方法,请参见下文:

You can get rid of race conditions if you lock the last payment when calculating the last balance using pessimistic locking . 如果您使用悲观锁定计算最后余额时锁定最后一笔付款 ,则可以摆脱竞争条件。 For this to work you always need to wrap creating the payments with transaction block. 为此,您始终需要使用事务块包装创建付款。

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

The first query to get the last Payment will also lock the record (and delay further querying it) for the duration of the transaction that wraps it, ie until the moment the transaction is fully committed and the new record created. 获取最后一个Payment的第一个查询也将锁定记录(并延迟进一步查询它),直到包装它的事务的持续时间,即直到事务完全提交并创建新记录为止。 If another query meanwhile also tries to read the locked last payment, it will have to wait until the first transaction is finished. 如果另一个查询同时也尝试读取锁定的最后一笔付款,则必须等到第一笔交易完成。 So if you use a transaction in your sidekiq when creating the payment, you should be safe. 因此,如果您在创建付款时使用sidekiq中的交易,那么您应该是安全的。

See the above-linked guide for more info. 有关详细信息,请参阅上面链接的指南。

Update: it's not that easy, this approach can lead to deadlocks 更新:这并不容易,这种方法可能会导致死锁

After some extensive testing, the problem seems to be more complex. 经过一些广泛的测试,问题似乎更复杂。 If we lock just the "last" payment record (which Rails translate to SELECT * FROM payments ORDER BY id DESC LIMIT 1 ), then we may run into a deadlock. 如果我们只锁定“最后”支付记录(Rails转换为SELECT * FROM payments ORDER BY id DESC LIMIT 1 payment SELECT * FROM payments ORDER BY id DESC LIMIT 1 ),那么我们可能会遇到死锁。

Here I present the test that leads to deadlock, the actually working approach is further below. 在这里,我提出导致死锁的测试,实际工作方法在下面进一步说明。

In all tests below I'm working with a simple InnoDB table in MySQL. 在下面的所有测试中,我正在使用MySQL中的一个简单的InnoDB表。 I created the simplest payments table with just the amount column added the first row and the accompanying model in Rails, like this: 我创建了最简单的payments表,只有在Rails中添加第一行和附带模型的amount列,如下所示:

# 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

Now, let's open two Rails consoles, start a long-running transaction with last record lock and new row insertion in the first one and another last row lock in the second console session: 现在,让我们打开两个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>

The first transaction ended up with deadlock. 第一笔交易最终陷入僵局。 One solution would be to use the code from the beginning of this answer but retry the whole transaction when a deadlock occurs. 一种解决方案是使用本答案开头的代码,但在发生死锁时重试整个事务。

Possible solution with retrying deadlocked transaction: (untested) 重试死锁事务的可能解决方案:(未经测试)

With taking the advantage of the method for retrying lock errors by @MGPalmer in this SO answer : 这个SO答案中利用@MGPalmer重试锁定错误的方法的优势:

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

When a deadlock occurs, the transaction is retried, ie a fresh last record is found and used. 发生死锁时,将重试事务,即找到并使用新的最后一条记录。

Working solution with test 工作解决方案与测试

Another approach that I came across is to lock all records of the table. 遇到的另一种方法是锁定表的所有记录。 This can be done by locking the COUNT(*) clause and it seems to work consistently: 这可以通过锁定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>

By looking at the timestamps you can see that the second transaction waited for the first one to finish and the second insert already "knew" about the first one. 通过查看时间戳,您可以看到第二个事务等待第一个事务完成,第二个事务已经“知道”第一个事务。

So the final solution that I propose is the following: 所以我建议的最终解决方案如下:

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

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM