拍卖/银行类应用中的乐观锁定或悲观锁定(Rails/MySQL)

3
我正在使用Rails 3.1和MySQL 5.1设计一个类似拍卖的Web应用程序。用户将有账户余额,因此很重要的一点是,如果他没有足够的资金,他不应该竞标拍卖品。
显然,我将赢得拍卖的内容封装到一个交易中,类似于以下内容:
交易1:
ActiveRecord::Base.transaction do
    a = Account.where(:id=>session[:user_id]).first
    # now comes a long part of code with various calculations and other table updates, i.e. time pases
    a.balance -= the_price_of_the_item
    a.save!
end

顺便说一下,我目前正在使用乐观锁定,因此我的所有表都有 lock_version 列。
在执行此类事务时,用户可以通过另一个输入放置其他出价,因此每当他们放置出价时,一段代码会检查当前可用余额是否足够。
这里再次相同:
事务2:
ActiveRecord::Base.transaction do
    a = Account.where(:id=>session[:user_id]).first
    raise ActiveRecord::Rollback if a.balance < the_price_of_the_bid + Bids.get_total_bid_value_for_user(session[:user_id])
    # now process the bid saving
end

显然,我需要确保两个事务不会重叠,否则在第一个事务正在处理过程中,第二个事务可能会读取余额,导致我的账户余额变为负数(出价已保存,然后第一个事务提交,那么用户可能已经用他不再拥有的资金进行出价)。
需要注意的一点是,第二个事务不对Account做任何更改,仅仅是读取账户。我想问题归结为一个问题:如何在运行事务1时防止选择性SELECT语句的任何读取?
如何让事务2等待事务1完成?这是否可能使用乐观锁和可用的MySQL事务隔离级别之一来实现,还是我需要在这里使用悲观锁?如果悲观锁是唯一的答案,在每个事务中读取账户记录后添加a.lock!是否足够?
当然,设计标准是:
  • 即使意味着编写更多代码,我也要寻找最高效的解决方案。
  • 数据一致性至关重要

只是想了解您的数据。假设某人的余额为10。他们能否创建两个每个10的出价(此时我们不知道任何一个或多个是否会获胜)?如果不能,那么如何跟踪这一点? - Frederick Cheung
不行,否则我就冒着他们赢得两个竞标的风险,这将导致他们账户上的余额为-10。跟踪是在交易2中完成的,它检查可用余额,只有用户在账户上有足够的钱,他的竞标才会被接受并保存。 - KKK
那段代码是否还会检查当前是否有任何未完成的竞标?(或者账户余额已经反映了这一点) - Frederick Cheung
余额仅跟踪实际余额。 在以下代码行之后: raise ActiveRecord::Rollback if a.balance < the_price_of_the_bid 还会有进一步的代码,通过读取Bids表来检查现有的未决竞标(这可能包括正在处理事务1的竞标)。 - KKK
2个回答

2

我已经连续阅读了将近10个小时的各种帖子和文档,并试着使用Rails控制台进行试错,现在我想总结一下我的发现:

乐观锁:对于满足我的要求并没有用处,只有在实际保存账户余额记录时才会出现锁定。但是,下注不会更新账户记录,因此它不会触发乐观锁定,除非我在账户记录中维护一个字段,跟踪我所有未完成竞标的当前承诺资金,因此每次竞标时都会更新账户记录(但我不想那样做,因为每次保存竞标时都需要进行另一个数据库更新)。

所以,这让我只能使用悲观锁定。为了简单起见,我决定在用户记录上放置一个锁定,因此我的事务1的代码已更改为:

事务1:

ActiveRecord::Base.transaction do     
    u = User.find(session[:user_id],:lock=>true)
    a = Account.where(:id=>session[:user_id]).first 
    a.balance -= the_price_of_the_item
    ... some more code here ...
    a.save!      
end      

还有第二个交易:

ActiveRecord::Base.transaction do
    u = User.find(session[:user_id],:lock=>true)
    raise ActiveRecord::Rollback if a.balance < the_price_of_the_bid + Bids.get_total_bid_value_for_user(session[:user_id])          
    # now process the bid saving
    ....
end

此外,我决定将MySQL事务隔离级别设置为SERIALIZABLE。


1

根据你的问题,我理解如下:

  1. 一个账户有一个余额
  2. 一个账户有许多投标
  3. 一个投标有一个价值
  4. 一个既没有赢也没有输的投标,“开放”状态
  5. 当一个投标被赢得时,它的价值会从账户余额中扣除。
  6. 只有在“开放”投标的价值总和小于余额时,才能进行投标。

因此,您已经确定了重要的交易:

  1. 投标
  2. 赢得投标

这是我的做法:

1.

account = Account.find_by_id(session[:user_id])
# maybe do some stuff here

transaction do
  account.lock!

  bid_amount = account.bids.open.sum(:value)
  if bid_amount + this_value > account.balance
    raise "you're broke, mate"
  end

  account.bid.create!(:value => this_value)
end

我们在短时间内对账户进行了行锁定,但是假设您正在使用正确的数据库,这应该是可以的。

如果第一个部分正确,那么下一个部分就容易多了。

2.

class Bid
  def win!
    transaction do
      account.lock!
      account.decrement(:balance, self.value)
      account.save!
      close!
    end
  end
end

特别是如果您执行了 SQL 更新 SET balance = balance - ?,则不需要在第二个上进行锁定。

但通常情况下,请在代码的最小部分周围进行锁定。

实际上,您不应该锁定超过 100 毫秒。


我认为第一部分存在“脏读”的风险: 在事务开始之前,您读取了账户余额, 因此,如果另一个进程在以下代码之间更新了账户余额 account = Account.find_by_id(session[:user_id]) 和 transaction do
account.lock!
那么 if bid_amount + this_value > account.balance 将是不正确的。换句话说,如果第二部分在第一部分的查找和锁定之间执行,则第一部分将使用错误的账户余额。不是吗?
- KKK
我相信 lock! 会重新加载,因此在那个时候重新加载余额。 - Matthew Rudy
lock! 绝对会重新加载:https://github.com/rails/rails/blob/master/activerecord/lib/active_record/locking/pessimistic.rb - mrm

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接