如何避免Rails中的竞赛条件

265 阅读7分钟

当两个用户同时读取和更新一个数据库记录时,你可能会遇到一些不理想的关键问题。比方说,由于某种原因,一个顾客在电子商务网站的结账页面上点击了支付按钮。有可能出现这样的情况:一个特定的客户为同一个订单被收取两次费用,因为两次收取订单的请求几乎是在同一时间进行的。这种情况被称为 "竞赛条件"。

当两个或更多的线程可以访问共享数据并试图同时改变它时,就会发生竞赛条件。因为线程调度算法可以在任何时候在线程之间进行交换,你不知道线程试图访问共享数据的顺序。因此,数据变化的结果取决于线程调度算法(即,两个线程都在 "竞速 "访问/改变数据)。当一个线程做 "检查-然后-行动"(例如,"检查 "值是否为X,然后 "行动 "做一些取决于值是否为X的事情),而另一个线程在 "检查 "和 "行动 "过程中对值做了一些事情时,问题就会出现。-堆栈溢出

在这篇文章中,我们将看看锁定、数据库约束和唯一性,这些都是用于避免Rails应用程序中的竞赛条件的一些方法。

锁定

当你的系统允许多个用户对相同的记录进行操作时,你要避免一个用户在不看的情况下覆盖另一个用户的修改的情况。

例如,在一个电子商务系统中,你经常有多个管理员管理产品库存。当两个管理员试图更新同一个产品时,就会出现竞赛条件:

  • 管理员1添加了一个新产品 "家庭用长椅健身器材"。
  • 管理员2看到了这个新产品,并决定编辑产品的名称,使其更具描述性,并将其改为 "RELIFE REBUILD YOUR LIFE 坐起来的长椅可调整的锻炼折叠长椅健身设备,用于家庭健身房腹部锻炼的新版本。"
  • 同时,管理员1注意到最初的名字没有描述性,并决定将其重新命名为 "可调节锻炼折叠式长椅健身器材,用于家庭健身"。
  • 管理员1管理员2之后的几毫秒内保存了新名称,推翻了管理员2的更具描述性的命名。

锁定是防止这种情况发生的一种方法。

优化的锁定

优化锁允许多个用户访问同一记录进行编辑,并假定与数据的冲突最小。它通过检查另一个进程是否在记录被打开后对其进行了修改;如果发生了这种情况,会抛出一个ActiveRecord::StaleObjectError异常,并忽略更新。

要在Rails中实现乐观锁定,请在你想放置锁的表中添加一个lock_version 列。在更新记录之前,Rails会自动检查该列。对记录的每一次更新都会增加lock_version 列,并且锁定设施确保两次实例化的记录会让最后保存的记录引发 StaleObjectError(如果第一次也被更新了)。

product1 = Product.find(1)
product2 = Product.find(1)

product1.name =  "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
product1.save

product2.name = "Adjustable Workout Foldable Bench Fitness Equipment for Home Gym"
product2.save # Raises an ActiveRecord::StaleObjectError

悲观的锁定

悲观的锁定会锁定一条记录,直到该记录上的所有事务都完成。一旦记录被锁定,其他用户不能修改记录,直到锁被释放。虽然乐观锁在数据库事务不太可能发生时使用,但它在数据库事务冲突更可能发生时使用。它提供了对使用SELECT ... FOR UPDATE和其他锁类型的行级锁定的支持。

要在Rails中实现悲观的锁,请将ActiveRecord::Base#findActiveRecord::QueryMethods#lock

product = Product.lock.find(1) #lock the record

product.name = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"

product.save! #release the lock

另外,你可以使用ActiveRecord::Base#lock! 方法,通过其ID锁定一条记录。

  product = Product.find(1)
  order = Order.find(order_id)
  ActiveRecord::Base.transaction do
    product.lock! 
    product.name  = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
    product.save!
    order.paid!
  end

你也可以使用with_lock ,启动一个事务并同时获得一个锁。

product = Product.find(1)
product.with_lock do #lock the record
product.name = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"

product.save!
end

咨询性锁定

咨询锁是一种机制,用于防止代码的并发执行,而不一定要锁定数据库表或行。在核心Ruby中,这是用mutex实现的。在Rails中,当与MySQL或PostgreSQL一起使用时,Ruby gemwith_advisory_lock可以用来为ActiveRecord添加咨询锁定(mutexes)。

这个宝石会在你所有的ActiveRecord模型中自动包含WithAdvisoryLock 模块。下面是一个例子,说明当Product 是一个ActiveRecord模型,锁名是一个字符串时,如何使用它。

Product.with_advisory_lock("product_lock") do
  product = Product.find(1)

  product.name =  "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
  product.save
end

会发生什么:

  • 线程将无限期地等待,直到锁被获取。
  • 在区块内,你将完全拥有咨询锁。
  • 在你的区块结束后,锁将被释放,即使在区块内有异常发生。

使用唯一索引而不是唯一性验证

Rails的ActiveRecord 验证,比如下面这个,不是数据库级的验证,它是应用级的验证,如果没有竞赛条件,效果很好。

让我们来看看一个典型的现实生活中的例子。在注册过程中,你要求用户提供电话号码和密码。你不希望多个用户用同一个电话号码注册,所以你添加了一个唯一性验证,如果有重复的电话号码,就应该抛出一个错误:

class User < ApplicationRecord
  validates :phone_number, uniqueness: true
end

如果一个用户在几毫秒内错误地连续点击了两次或更多次的注册按钮,这在网络应用中经常发生,你很可能会出现以下情况:

  • 请求1--检查是否存在有该电话号码的用户并继续,因为没有找到用户。
  • 请求2- 检查是否有用户使用该电话号码,然后继续,因为没有找到用户。
  • 请求1- 到达将数据插入数据库的代码。一个新的用户记录被创建。
  • 请求2- 到达向数据库插入数据的代码处。一个新的用户记录被创建。

请求2通过了唯一性验证,因为在检查用户是否存在时,请求1还没有将电话号码保存到数据库中。因此,这两个请求最终都能在数据库中插入新的记录。

防止这种竞赛条件的方法是添加一个唯一索引约束,比如下面这个,它在数据库层面上执行验证:

class AddUniqueIndexToUsers < ActiveRecord::Migration
  def change
    # Have the database raise an exception anytime any process tries to
    # submit a record that has a code duplicated for any particular account
    add_index :users, :phone_number, unique: true
  end
end

Sidekiq唯一作业

如果你使用Sidekiq工作者对你的数据库进行修改,你可以使用SidekiqUniqueJobs来给Sidekiq队列添加唯一约束。唯一性是通过为队列名称、工作者类别和作业参数的哈希值获取锁来实现的。默认情况下,一个给定的哈希值只能获得一个锁。如果试图获取一个新的锁,会引发一个异常SidekiqUniqueJobs::ScriptError

使用Sidekiq很简单;你所要做的就是把它配置为Sidekiq客户端和服务器的中间件。将以下代码添加到你的/config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  config.redis = { url: ENV["REDIS_URL"], driver: :hiredis }

  config.client_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Client
  end

  config.server_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Server
  end

  SidekiqUniqueJobs::Server.configure(config)
end

Sidekiq.configure_client do |config|
  config.redis = { url: ENV["REDIS_URL"], driver: :hiredis }

  config.client_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Client
  end
end

结论

竞争条件,如果不加以预防,会导致数据完整性问题,有时甚至是安全问题。如果你了解什么原因会导致它们,以及如何避免它们,你就可以建立具有一致数据的系统,并避免花时间调试数据完整性问题。