针对竞争条件的PostgreSQL和ActiveRecord子选择

时间:2015-09-03 18:01:06

标签: ruby-on-rails postgresql activerecord

我正在使用PostgreSQL在ActiveRecord中遇到竞争条件,我正在读取一个值,然后递增它并插入一条新记录:

num = Foo.where(bar_id: 42).maximum(:number)
Foo.create!({
  bar_id: 42,
  number: num + 1
}) 

在规模上,多个线程将同时读取,然后写入number的相同值。在事务中包装它不会修复竞争条件,因为SELECT不会锁定表。我无法使用自动增量,因为number不是唯一的,只有给定某个bar_id时它才是唯一的。我看到了3个可能的修复:

  • 明确使用postgres锁(行级锁?)
  • 使用唯一约束并重试失败(哎呀!)
  • 覆盖保存以使用子选择,I.E。

    INSERT INTO foo (bar_id, number) VALUES (42, (SELECT MAX(number) + 1 FROM foo WHERE bar_id = 42));

所有这些解决方案似乎都在重新实现ActiveRecord::Base#save!的大部分内容?有更简单的方法吗?

更新: 我以为我找到了Foo.lock(true).where(bar_id: 42).maximum(:number)的答案,但使用的是SELECT FOR UDPATE,这在汇总查询中是不允许的

更新2: 我刚接到DBA的通知,即使我们可以执行INSERT INTO foo (bar_id, number) VALUES (42, (SELECT MAX(number) + 1 FROM foo WHERE bar_id = 42));也没有修复任何问题,因为SELECT运行的锁与INSERT不同

1 个答案:

答案 0 :(得分:2)

您的选择是:

  • SERIALIZABLE隔离中运行。相互依赖的事务将在提交时中止,因为序列化失败。您将收到大量错误日志垃圾邮件,并且您将进行大量重试,但它将可靠地运行。

  • 如您所述,定义UNIQUE约束并在失败时重试。与上述问题相同。

  • 如果存在父对象,则可以在执行SELECT ... FOR UPDATE查询之前max父对象。在这种情况下,你SELECT 1 FROM bar WHERE bar_id = $1 FOR UPDATE。您使用bar作为锁定foo的所有bar_id。然后,您可以知道继续进行是安全的,只要每个执行计数器递增的查询都能可靠地执行此操作。这可以很好地工作。

    这仍然会对每个调用执行聚合查询,这对于每个调用都是不必要的,但至少它不会像上述选项一样垃圾邮件错误日志。

  • 使用计数器表。这就是我要做的。在barbar_foo_counter这样的边桌中,使用

    获取行ID
    UPDATE bar_foo_counter SET counter = counter + 1
    WHERE bar_id = $1 RETURNING counter
    
    如果你的框架无法处理RETURNING,那么

    或效率较低的选项:

    SELECT counter FROM bar_foo_counter
    WHERE bar_id = $1 FOR UPDATE;
    
    UPDATE bar_foo_counter SET counter = $1;
    

    然后,在同一个事务中,使用为number生成的计数器行。提交时,bar_id的计数器表行将被解锁以供下一个查询使用。如果您回滚,则更改将被丢弃。

我推荐使用计数器方法,使用专用的计数器表来代替向bar添加列。模型更清晰,意味着您在bar中创建的更新膨胀更少,这会降低查询速度bar