【问题标题】:Atomic UPDATE .. SELECT in PostgresPostgres 中的原子更新 .. SELECT
【发布时间】:2012-07-16 23:13:33
【问题描述】:

我正在构建某种排队机制。有需要处理的数据行和一个状态标志。我正在使用update .. returning 子句来管理它:

UPDATE stuff
SET computed = 'working'
WHERE id = (SELECT id from STUFF WHERE computed IS NULL LIMIT 1)
RETURNING * 

嵌套选择部分是否与更新相同,或者我这里有竞争条件?如果是这样,内部选择是否需要是select for update

【问题讨论】:

  • 如果您尝试在 sql 中构建消息队列,并且这是任何合理的大量任务,我假设您最终会想要删除已完成作业的行。这些将消耗您的索引,因此请务必在清理已完成的任务时进行真空分析,否则您的性能将下降。您可能只想考虑使用实际的消息队列(rabbitmq、zeromq、activemq 等)。

标签: multithreading postgresql concurrency race-condition transaction-isolation


【解决方案1】:

虽然 Erwin 的建议可能是获得正确行为的最简单方法(只要您在遇到 SQLSTATE of 40001 的异常时重试事务),排队应用程序的性质往往与 SERIALIZABLE 事务的 PostgreSQL 实现相比,请求阻塞以有机会轮到队列更好地工作,后者允许更高的并发性并且对冲突的可能性更加“乐观”。

问题中的示例查询,就目前而言,在默认的READ COMMITTED 事务隔离级别将允许两个(或更多)并发连接从队列中“声明”同一行。会发生什么:

  • T1 启动并在UPDATE 阶段锁定行。
  • T2 在执行时间上与 T1 重叠,并尝试更新该行。它阻止待处理的 T1 的 COMMITROLLBACK
  • T1 提交,已成功“认领”该行。
  • T2 尝试更新该行,发现 T1 已经有,查找该行的新版本,发现它仍然满足选择条件(即 id 匹配),并且还“声称”行。

可以修改它以使其正常工作(如果您使用的 PostgreSQL 版本允许在子查询中使用 FOR UPDATE 子句)。只需将 FOR UPDATE 添加到选择 id 的子查询的末尾,就会发生这种情况:

  • T1 启动,现在锁定 选择 id 之前的行。
  • T2 在执行时间上与 T1 重叠,并在尝试选择 id 时阻塞,等待 T1 的 COMMITROLLBACK
  • T1 提交,已成功“认领”该行。
  • 当 T2 能够读取行以查看 id 时,它发现它已被认领,因此它会找到下一个可用的 id。

REPEATABLE READSERIALIZABLE 事务隔离级别,写入冲突会抛出错误,您可以根据SQLSTATE 捕获并确定是序列化失败,然后重试。

如果您通常需要 SERIALIZABLE 事务,但又想避免在排队区域重试,则可以使用 advisory lock 来实现。

【讨论】:

  • 将多余的 WHERE 子句 AND computed IS NULL 添加到外部 UPDATE 会使这个特定查询正常运行吗?还是在 T1 在操作中途“使”所选行“无效”之后,T2 会空出来吗?
  • READ COMMITTED 中,它将防止双重分配,但在重叠事务的情况下将返回一个空结果集,即使有其他行可用。在更严格的隔离级别下,它不会产生任何真正的区别,但无论如何可能是好的形式。
  • 再次感谢。我在此基础上设计了一个替代解决方案。
  • 请注意,使用此策略的应用程序必须有一种方法来发现某人何时声明了一行,然后崩溃或以其他方式无法完成工作。
  • @kgrittn 该语句是否需要包含在 BEGIN...COMMIT 中以持有 FOR UPDATE 锁?有一些报告说它不起作用:github.com/collectiveidea/delayed_job_active_record/pull/79
【解决方案2】:

如果您是唯一用户,则查询应该没问题。特别是,查询本身(外部查询和子查询之间)没有竞争条件或死锁。我引用手册here

但是,事务永远不会与自身发生冲突。

对于并发使用,事情可能会更复杂。使用SERIALIZABLE transaction mode,您会更安全:

BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
RETURNING * 
COMMIT;

您需要为序列化失败做好准备,并在这种情况下重试查询。

但我不完全确定这是否矫枉过正。我会请@kgrittn 停下来......他是并发和可序列化事务的专家......

And he did. :)


两全其美

以默认事务模式READ COMMITTED运行查询。

对于 Postgres 9.5 或更高版本,请使用 FOR UPDATE SKIP LOCKED。见:

对于旧版本,重新检查外部UPDATE 中的条件computed IS NULL

UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
AND   computed IS NULL;

正如@kgrittn 在对他的回答的评论中所建议的那样,在(不太可能)它与并发事务交织在一起的情况下,此查询可能会出现空,而无需执行任何操作。

因此,它的工作方式与事务模式SERIALIZABLE 中的第一个变体非常相似,您必须重试 - 只是没有性能损失。

唯一的问题:虽然由于机会之窗很小,所以冲突不太可能发生,但它可能在重负载下发生。您无法确定是否最终没有更多行了。

如果这无关紧要(就像您的情况一样),您就完成了。
如果确实如此,绝对确定,在得到一个空结果后使用explicit locking 再开始一个查询。如果这是空的,你就完成了。如果没有,请继续。
plpgsql 中可能如下所示:

LOOP
   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE SKIP LOCKED);  -- pg 9.5+
   -- WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
   -- AND    computed IS NULL; -- pg 9.4-

   CONTINUE WHEN FOUND;  -- continue outside loop, may be a nested loop

   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE);

   EXIT WHEN NOT FOUND;  -- exit function (end)
END LOOP;

这应该给你两全其美:性能可靠性。

【讨论】:

  • SERIALIZABLE 事务确实会导致 正确 行为,但在 PostgreSQL 9.1(及更高版本)中对其实现的新实现的反馈表明,排队应用程序是“最坏的情况” “ 设想。虽然该技术(称为 Serializable Snapshot Isolation 或 SSI)比对大多数工作负载使用阻塞锁要快得多,但我有一份报告称,对于一个特定的队列应用程序,性能下降了 20%,而报告者(PostgreSQL 的主要贡献者)能够故意设计更糟。因此,您可以尝试一下,但您可能会发现显式锁定效果更好。
  • 在没有实际进行基准测试的情况下,我不愿就性能发表任何声明,但我希望仅从UPDATE 在第二个区块中。对重新锁定已经锁定的行的连接进行了优化,我认为它不会阻塞,除非您处于无论如何都需要重试的情况。
  • 在我的具体情况下,这不是必需的。当进程到达队列的末尾时(就它而言),它会休眠 10 分钟并再次尝试。如果记录落入下一个窗口,这不是问题。感谢您的编译。
  • @kolosy:在外部查询中重复AND computed IS NULL的简单版本应该是这种情况的最佳解决方案。
猜你喜欢
  • 1970-01-01
  • 2016-10-26
  • 1970-01-01
  • 2017-03-13
  • 2016-03-31
  • 1970-01-01
  • 2016-12-22
  • 1970-01-01
  • 2022-06-29
相关资源
最近更新 更多