Activerecord 中的悲观锁定 - with_lock 未按预期工作

Pessimistic locking in Activerecord - with_lock is not working as expected

我正在尝试为 User 模型实施 create_or_update 方法,这将创建新记录或更新现有记录。

def create_or_update_user(external_user_id, data)
  user = User.find_or_initialize_by(external_user_id: external_user_id)
  user.title = data[:title].downcase
  # and so on - here we assign other attributes from data
  user.save! if user.changed?
end

这里的陷阱是这个 table 正在由不同的进程同时更新,当他们试图用相同的 external_user_id 修改 user 时,竞争条件发生并且 ActiveRecord::RecordNotUnique 被提出。我尝试使用数据库锁来解决这个问题,但它没有按预期工作 - 有时仍然会引发异常。

table 结构如下所示:

create_table :users do |t|
    t.integer :external_user_id, index: { unique: true }
    # ...
end

更新方法 - 我在这里做错了什么?:

def create_or_update_user(external_user_id, data)
  user = User.find_or_initialize_by(external_user_id: external_user_id)
  user.with_lock do
    user.title = data[:title].downcase
    # and so on - here we assign other attributes from data
    user.save! if user.changed?
  end
end

我不能使用 upsert,因为我需要模型回调。

可能可以通过添加

来解决
rescue ActiveRecord::RecordNotUnique
  retry
end

但我想使用锁以获得最佳性能,因为在这部分代码中竞争条件并不罕见。

UPD:添加了重现此竞争条件的要点 https://gist.github.com/krelly/520a2397f64269f96489c643a7346d0f

如果我的理解正确,在两个线程试图为不存在的外部 ID 创建新用户的情况下,该错误可能仍然会发生。

在这种情况下,线程 #1 将尝试在不存在的行上获取行锁 - 实际上不会发生锁定。

线程 #2 可以在线程 #1 仍在构建记录时进行相同的 find_or_initialize 查询,因此线程 #2 一旦提交就会遇到唯一性冲突。

正如您已经猜到的那样,解决此问题的唯一简单方法是救援 + 重试。线程 #2 会再试一次,它会更新线程 #1 创建的记录。

另一种解决方案是 advisory lock - 具有 application-specific 行为的锁。在这种情况下,您会说一次只有一个线程可以 运行 create_or_update_user 具有特定的外部 ID。

Rails 本身不支持咨询锁,但链接的文章包含一个示例,说明如何使用 Postgres 进行此操作。还有 gem with_advisory_locks