【问题标题】:Safe way to increase field value in rails AR在 Rails AR 中增加字段值的安全方法
【发布时间】:2014-08-12 08:08:53
【问题描述】:

所以我有class Client 那个has_many :transactions。这两个字段都有货币化字段 (money-rails) gem。 INclass Transaction 我有after_create :add_customer_balance。它应该将此transaction.amount 添加到transaction.client 余额中。

我面临的问题是同时进行 2 笔交易的情况。我们来看看这种情况:

    Variant 1:
    time / process / code 
    0:01 / P1    / client = Client.find(1)
    0:01 / P2    / client = Client.find(1)
    0:02 / P1    / client.balance += 100
    0:02 / P1    / client.save # SQL: update clients set balance = 200 where id = 1
    0:03 / P2    / client.balance += 200
    0:03 / P2    / client.save # SQL: update clients set balance = 300 where id = 1

    Variant 2
    to,e / process / code 
    0:01 / P1    / client = Client.find(1)
    0:01 / P2    / client = Client.find(1)
    0:02 / P1    / client.update_all(...) # SQL: update clients set balance = balance + 100 where id = 1
    0:03 / P2    / client.update_all(...) # SQL: update clients set balance = balance + 200 where id = 1

    Result:
    Client.find(1).balance = 400

我的问题是:如何预防第一种情况?

我正在寻找可以增加平衡字段并立即将其保存到数据库的解决方案。

编辑

我尝试过increment!,但它似乎并不能阻止竞争条件。

def increment!(attribute, by = 1)
  increment(attribute, by).update_attribute(attribute, self[attribute])
end

【问题讨论】:

    标签: sql ruby-on-rails ruby activerecord ruby-on-rails-4


    【解决方案1】:

    您自己的交易在这里对您没有帮助。 save 进程包含在事务中(before_saveafter_save 和实际保存),但即使您在事务中包含了查找

    Client.transaction do
      client = Client.find(1)
      client.balance += 100
      client.save
    end
    

    那么你仍然处于危险之中。通过在findsave 之间添加对sleep 的随机持续时间调用,很容易看到这一点。当保存执行时,将在该行上获取一个排他锁。这将阻止调用 find 发生在其他事务中(因此他们只会看到保存后的值),但如果客户端行已经被检索,那么它不会强制它重新加载。

    这类问题有两种常见的方法

    悲观锁定。

    这看起来像

    Client.transaction do
      client = Client.lock.find(1)
      client.balance += 100
      client.save
    end
    

    它的作用是在检索点锁定行 - 在该客户端上调用 find 的任何其他尝试都将阻塞,直到事务结束。之所以称为悲观,是因为即使碰撞的风险很低,您也预计会出现最坏的情况并每次都锁定。存在性能损失,因为它阻止了读取该行的所有尝试,即使是那些不打算进行更新的尝试。如果它与

    并行运行,情况仍然如此
    client = Client.find(1) #no call to lock here!
    #some lengthy process
    client.balance += 1
    client.save
    

    那么你最终会得到坏数据:整个 find-lock-update 过程可能发生在获取行和更新行之间的休息时间。因此,您更新余额的所有地方都需要使用lock

    乐观锁定

    这样你就可以在你的模型中添加一个 lock_version 列(必须是整数类型并且默认为 0)。调用save 将执行表单查询

    UPDATE clients set .... lock_version = 5 where id = 1 and lock_version = 4
    

    每次保存时,lock_version 都会增加 1。如果没有更新任何行(即 lock_version 不匹配),则会引发 ActiveRecord::StaleObjectError。

    将此应用于您的示例

    0:01 / P1 / client = Client.find(1) #lock_version is 1
    0:01 / P2 / client = Client.find(1) #lock_version is 1
    0:02 / P1 / client.balance += 100
    0:02 / P1 / client.save # update clients
                            # set balance = 200, lock_version = 2 
                            # where id = 1 and lock_version = 1
    0:03 / P2 / client.balance += 200
    0:03 / P2 / client.save # update clients
                            # set balance = 300, lock_version =2
                            # where id = 1 and lock_version = 1
    

    第二次更新将不匹配任何行,因此引发异常。此时您应该重新加载客户端对象并重试。

    之所以称为乐观是因为我们假设大多数时候不会有同时更新:在快乐的情况下,开销是最小的。缺点是对save 的任何调用都可能导致 ActiveRecord::StaleObjectError - 处理所有这些可能有点痛苦

    这些文档位于http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.htmlhttp://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html

    【讨论】:

    【解决方案2】:

    如果我正确理解了您的问题,这听起来像是使用交易的教科书案例:

    事务是保护性块,其中 SQL 语句只有在它们都可以作为一个原子操作成功时才​​是永久的。典型的例子是两个账户之间的转账,只有在提款成功的情况下,您才能进行存款,反之亦然。事务强制执行数据库的完整性并保护数据免受程序错误或数据库故障的影响。因此,基本上,只要您有许多必须一起执行或根本不执行的语句,您就应该使用事务块。

    Active Record 支持事务,您可以阅读更多关于它们的信息here

    这是文档中的示例:

    ActiveRecord::Base.transaction do
      david.withdrawal(100)
      mary.deposit(100)
    end
    

    在这种情况下,如果从 david 的账户提款失败,则不会执行对 mary 账户的存款。同样,如果提款成功而存款失败,则提款被回滚并且不采取任何行动。要么一切正常,要么什么都不做,它以原子操作的形式发生——这意味着在事务完成之前没有其他东西可以访问数据库(成功或失败)

    【讨论】:

    • 准确无误,但这里的问题不是关于两个必须同时发生(或根本不发生)的操作,而是关于碰巧影响数据库中同一行的两个独立操作跨度>
    • 啊...好的,谢谢!从你的回答中学到了一些新东西,+1
    • 通过在 client.balance+= 100client.save 之间添加 sleep 10 并发出 2 个请求对其进行了测试。它没有用。
    • @FilipBartuzi,是的,弗雷德里克的回答是正确的 :)
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-04-30
    • 2014-07-30
    • 2011-01-03
    • 2022-12-22
    • 1970-01-01
    • 2020-03-05
    相关资源
    最近更新 更多