如何在记录付款和 运行 余额的 rails 模型中避免竞争条件?
How to avoid race condition in a rails model that records payments and running balance?
我有一个简单的模型 Payments
,它有两个字段 amount
和 running_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 2
和 payment 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
我有一个简单的模型 Payments
,它有两个字段 amount
和 running_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 2
和 payment 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