我搜索了竞争条件的良好定义,这是我找到的最好的定义:
竞争条件是由于多个进程以与预期不同的顺序与共享资源交互而导致的意外行为。
这实在是太拗口了,而且还不是很清楚Rails中的竞争条件是如何显示的。
使用Rails,我们总是处理多个进程——每个请求或后台作业都是一个单独的进程,可以基本上独立于其他进程运行。
我们也始终使用共享资源。应用程序是否使用关系数据库?那是共享资源。应用程序是否使用某种缓存服务器?是的,这是共享资源。你使用某种外部API吗?您猜对了——这是共享资源。
我想讨论两种竞争条件的示例类别,然后讨论如何解决它们。
读取-修改-写入类别是一种竞争条件,其中一个进程将从共享资源读取值,修改内存中的值,然后尝试将其写回共享资源。当我们从单一流程的角度来看时,这似乎非常简单。但是当出现第二个进程时,可能会导致一些意外的行为。
考虑如下代码:
class IdeasController < ActionController::Base def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! end end
这里我们正在读取( Idea.find(params[:id])
),修改( @idea.votes += 1
),然后写入( @idea.save!
)。
我们可以看到,这将使某个想法的投票数增加一票。如果有一个想法得到零票,那么最终将以一票结束。然而,如果第二个请求进来并从数据库中读取这个想法,而它仍然有零票,并在内存中增加该值,我们可能会出现两票同时进入的情况 - 但最终结果是票数数据库中只有一个。
这也称为丢失更新竞争条件。
check-then-act 类别是一种竞争条件,其中数据从共享资源加载,并且根据存在的值,我们确定是否需要执行操作。
Rails 中的validates_uniqueness_of
验证是这种情况的典型示例之一,如下所示:
class User < ActiveRecord::Base validates_uniqueness_of :email end
考虑如下代码:
User.create(email: "[email protected]")
验证到位后,Rails 将检查是否有任何现有用户拥有该电子邮件地址。如果没有其他的,它将通过将用户保存到数据库中来执行操作。但是,如果第二个请求同时执行相同的代码,会发生什么情况?我们最终可能会遇到这样的情况:两个请求都会检查以确定是否存在重复数据(实际上没有),然后它们都会通过保存数据来执行操作,从而导致数据库中出现重复的用户。
解决竞争条件没有灵丹妙药,但有一些策略可以用于解决任何特定问题。消除竞争条件主要分为三个类别:
虽然这可能被视为删除有问题的代码,但有时您可以重构代码,使其不易受到竞争条件的影响。其他时候,您可以研究原子操作。
原子操作是一种没有其他进程可以中断操作的操作,因此您知道它将始终作为单个单元执行。
对于读取-修改-写入示例,可以在数据库中增加想法投票,而不是在内存中增加想法投票:
@ideas.increment!(:votes)
这将执行如下所示的 sql:
UPDATE "ideas" SET "votes" = COALESCE("votes", 0) + 1 WHERE "ideas"."id" = 123
利用它不会受到相同的竞争条件的影响。
对于 check-then-act 示例,我们可以使用 upsert 将记录直接插入数据库,而不是让 Rails 验证模型:
User.where(email: "[email protected]").upsert({}, unique_by: :email)
这会将记录插入数据库中。如果电子邮件存在冲突(这需要电子邮件上的唯一索引),它将简单地忽略插入。
有时您无法删除关键部分。可能存在原子操作,但它并不完全按照代码要求的方式工作。在这些情况下,您可以尝试检测和恢复方法。通过这种方法,可以设置保护措施,在发生竞争情况时通知您。您可以正常中止或重试该操作。
对于读-修改-写示例,这可以通过乐观锁定来完成。 Rails 中内置了乐观锁定,可以检测多个进程同时对同一条记录进行操作的情况。要启用乐观锁定,您只需在表中添加一个lock_version
列,Rails 就会自动启用它。
change_table :ideas do |t| t.integer :lock_version, default: 0 end
然后,当您尝试更新记录时,只有当lock_version
与内存中的版本相同时,Rails 才会更新它。如果不是,它将引发ActiveRecord::StaleObjectError
异常,可以通过救援来处理它。处理它可能是retry
,也可能只是向用户报告的错误消息。
def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! rescue ActiveRecord::StaleObjectError retry end
对于先检查后执行的示例,可以通过列上的唯一索引来完成此操作,然后在保留数据时挽救异常。
add_index :users, [:email], unique: true
有了唯一索引,如果数据库中已存在该email
数据,Rails 将引发ActiveRecord::RecordNotUnique
错误,并且可以适当地挽救和处理该错误。
begin user = User.create(email: "[email protected]") rescue ActiveRecord::RecordNotUnique user = User.find_by(email: "[email protected]") end
为了重试操作,整个操作必须是幂等的,这一点很重要。这意味着如果多次执行某个操作,则结果与仅应用一次相同。
例如,想象一下,如果一项工作发送了一封电子邮件,并且每当某个想法的投票发生变化时就会执行该电子邮件。如果每次重试都发送一封电子邮件,那就太糟糕了。为了使操作幂等,您可以推迟发送电子邮件,直到整个投票操作完成。或者,您可以更新发送电子邮件的流程的实现,以便仅在投票自上次发送时发生变化时才发送电子邮件。如果发生竞争条件并且您需要重试,则第一次尝试发送电子邮件可能会导致无操作,并且可以安全地再次触发它。
许多操作可能不是幂等的,例如对后台作业进行排队、发送电子邮件或调用第三方API 。
如果无法检测和恢复,可以尝试保护代码。这里的目标是创建一个契约,其中一次只有一个进程可以访问共享资源。实际上,您正在消除并发性 - 由于只有一个进程可以访问共享资源,因此我们可以避免大多数竞争条件。但代价是删除的并发性越多,应用程序的速度就越慢,因为其他进程将等待它们被允许访问。
这可以使用 Rails 内置的悲观锁定来处理。要使用悲观锁定,您可以向正在构建的查询添加lock
,Rails 将告诉数据库在这些记录上持有行锁定。然后,数据库将阻止任何其他进程获取锁,直到完成为止。请务必将代码包装在transaction
中,以便数据库知道何时释放锁。
Idea.transaction do @idea = Idea.lock.find(params[:id]) @idea.votes += 1 @idea.save! end
如果无法实现行级锁定,则可以使用其他工具,例如 Redlock 或 with_advisory_lock。这些将允许锁定任意代码块。使用它可以像这样简单:
email = "[email protected]" User.with_advisory_lock("user_uniqueness_#{email}"} do User.find_or_create_by(email: email) end
这些策略将导致进程等待,直到获得锁。因此,他们还希望有某种形式的超时来防止进程永远等待,以及在超时时进行一些处理。
虽然没有解决竞争条件的灵丹妙药,但许多竞争条件可以通过这些策略来解决。但是,每个问题都略有不同,因此解决方案的细节可能会有所不同。您可以看看我在 RailsConf 2023 上的演讲,其中更详细地介绍了竞争条件。
凯尔·奥利维拉
凯尔热衷于将抽象的想法转化为可工作的软件。他是Aha!的首席软件工程师。 — 世界排名第一的产品开发软件。当不开发时,凯尔喜欢在加拿大温哥华的家附近享用美味的食品和精酿啤酒厂。
也发布 在这里。