我正在使用PostgreSQL在ActiveRecord中遇到竞争条件,我正在读取一个值,然后递增它并插入一条新记录:
num = Foo.where(bar_id: 42).maximum(:number)
Foo.create!({
bar_id: 42,
number: num + 1
})
在规模上,多个线程将同时读取,然后写入number
的相同值。在事务中包装它不会修复竞争条件,因为SELECT不会锁定表。我无法使用自动增量,因为number
不是唯一的,只有给定某个bar_id
时它才是唯一的。我看到了3个可能的修复:
覆盖保存以使用子选择,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不同
答案 0 :(得分:2)
您的选择是:
在SERIALIZABLE
隔离中运行。相互依赖的事务将在提交时中止,因为序列化失败。您将收到大量错误日志垃圾邮件,并且您将进行大量重试,但它将可靠地运行。
如您所述,定义UNIQUE
约束并在失败时重试。与上述问题相同。
如果存在父对象,则可以在执行SELECT ... FOR UPDATE
查询之前max
父对象。在这种情况下,你SELECT 1 FROM bar WHERE bar_id = $1 FOR UPDATE
。您使用bar
作为锁定foo
的所有bar_id
。然后,您可以知道继续进行是安全的,只要每个执行计数器递增的查询都能可靠地执行此操作。这可以很好地工作。
这仍然会对每个调用执行聚合查询,这对于每个调用都是不必要的,但至少它不会像上述选项一样垃圾邮件错误日志。
使用计数器表。这就是我要做的。在bar
或bar_foo_counter
这样的边桌中,使用
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
。