我有一个简单的模型Payments
,它有两个字段amount
和running_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 2
和payment 3
)时,可能会根据running_balance
的运行余额计算其payment 1
,因为可能是这种情况当payment 3
计算出其余额payment 2
时尚未保存到数据库中。
特别是,我对避免运行状况的修复感兴趣。我也热衷于查看实现类似支付系统的其他rails应用程序。
答案 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