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

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

我有一个简单的模型 Payments,它有两个字段 amountrunning_balance。当一条新的 payment 记录被创建时,我们寻找它之前付款的 running_balance,比如 last_running_balance 并保存 last_running_balance+amount 作为当前付款的 running_balance .

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

尝试 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 实施类似支付系统的应用程序。

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

如果您在使用 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 中使用交易,您应该是安全的。

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

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

经过大量测试,问题似乎更复杂了。如果我们只锁定 "last" 支付记录(Rails 转换为 SELECT * FROM payments ORDER BY id DESC LIMIT 1),那么我们可能 运行 陷入僵局。

这里给出导致死锁的测试,下面是实际可行的方法。

在下面的所有测试中,我在 MySQL 中使用一个简单的 InnoDB table。我创建了最简单的 payments table,仅在 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 控制台,开始一个长 运行ning 事务,在第一个控制台会话中使用最后记录锁定和新行插入,在第二个控制台会话中使用最后一个行锁定:

# 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>

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

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

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

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

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

经过测试的工作解决方案

came across的另一种方法是锁定table的所有记录。这可以通过锁定 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>

通过查看时间戳,您可以看到第二个事务等待第一个事务完成并且第二个插入已经 "knew" 第一个。

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

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