【问题标题】:How can I avoid this race condition?我怎样才能避免这种竞争条件?
【发布时间】:2012-09-18 11:19:54
【问题描述】:

我有一个分布式任务队列,其中的任务如下所示:

# creates a uniquely-named file
new_path = do_work()

old_path = database.query('select old path')
unlink(old_path)

database.query('insert new path')

这里有一个竞争条件:如果任务队列软件同时触发了其中两个任务,它们将从数据库中得到相同的old_path,并且竞争失败者的 unlink 调用失败(孤立失败者未来解除链接的新路径)。

有没有一种方法可以让我绕过这场比赛?如果需要,我可以从当前的设计中丢弃任何东西。具体来说,我使用的是 PostgreSQL、Python 和 Celery。我知道我可能可以使用表范围的锁定/将 psycopg2 的事务级别更改为 SERIALIZABLE,但我不确定是否可以避免这种竞争条件。表级锁定还意味着我必须为每个额外的任务引入一个新表(以免它们相互阻塞),这听起来不太吸引人。

【问题讨论】:

  • 据我所知,old_path / new_path 是您需要同步访问的全局变量。我认为没有比序列化访问更好的方法了,尽管可能有比通过数据库更好的方法来实现这一点。
  • 它们不是全局变量——这里的所有代码都在一个函数范围内(任务)。
  • 我的答案的极短版本:使用 PGQ。排队很麻烦。

标签: python sql postgresql synchronization race-condition


【解决方案1】:

我强烈建议您研究已经解决此问题的工具,例如 PGQ。排队比你想象的要难得多。这不是你想重新发明的轮子。

并发很难

米海的回答表面上看还不错,但在并发操作中却有些落伍了。

两个并发的 UPDATE 可以选择同一行(在他的示例中为 used_flag = FALSE)。其中一个将获得锁并继续。另一个将等到第一次运行并提交。当提交发生时,第二次更新将获得锁,重新检查它的条件,找到不再匹配的行,并且什么也不做。因此,除了一组并发更新中的一个之外,所有并发更新都可能返回一个空集。

READ COMMITTED 模式下,您仍然可以获得不错的结果,大致相当于单个会话在UPDATE 上连续循环。在SERIALIZABLE 模式下,它将无可救药地失败。试试看;这是设置:

CREATE TABLE paths (
    used_flag boolean not null default 'f',
    when_entered timestamptz not null default current_timestamp,
    data text not null
);

INSERT INTO paths (data) VALUES
('aa'),('bb'),('cc'),('dd');

这是演示。尝试三个并发会话,逐步进行。在 READ COMMITTED 中执行一次,然后在所有会话 SERIALIZABLE 中使用 BEGIN ISOLATION LEVEL SERIALIZABLE 而不是普通的 BEGIN。比较结果。

SESSION 1             SESSION2         SESSION 3

BEGIN;
                                       BEGIN;

UPDATE      paths
    SET     used_flag = TRUE
    WHERE   used_flag = FALSE
    RETURNING data;

                      BEGIN;

                      INSERT INTO
                      paths(data)
                      VALUES
                      ('ee'),('ff');      

                      COMMIT;               
                                       UPDATE      paths
                                           SET     used_flag = TRUE
                                           WHERE   used_flag = FALSE
                                           RETURNING data;


                      BEGIN;

                      INSERT INTO
                      paths(data)
                      VALUES
                      ('gg'),('hh');      

                      COMMIT;        

COMMIT;

READ COMMITTED 中,第一个 UPDATE 成功并产生四行。第二个生成剩余的两个eeff,它们在第一次更新运行后插入并提交。 gghh 不会被第二次更新返回,即使它在它们提交后实际执行,因为它已经选择了它的行并且在它们被插入时正在等待锁定。

SERIALIZABLE 隔离中,第一次更新成功并产生四行。第二个以ERROR: could not serialize access due to concurrent update 失败。在这种情况下,SERIALIZABLE 隔离不会帮助您,它只会改变故障的性质。

如果没有显式事务,当UPDATEs 并发运行时也会发生同样的事情。如果您使用显式事务,那么演示会更容易,而无需摆弄时间。

只选择一行怎么样?

如上所述,系统工作正常,但如果您只想获取最旧的行怎么办?因为UPDATE 在阻塞锁之前选择了它要操作的行,所以您会发现在任何给定的事务集中只有一个UPDATE 会返回结果。

你会想到以下技巧:

UPDATE      paths
    SET     used_flag = TRUE
    WHERE entry_id = (
        SELECT entry_id
        FROM paths 
        WHERE used_flag = FALSE
        ORDER BY when_entered
        LIMIT 1
    )
    AND used_flag = FALSE
    RETURNING data;

UPDATE      paths
    SET     used_flag = TRUE
    WHERE entry_id = (
        SELECT min(entry_id)
        FROM paths 
        WHERE used_flag = FALSE
    )
    AND used_flag = FALSE
    RETURNING data;

但这些不会按您的预期工作;当同时运行时,两者都会选择相同的目标行。一个会继续,一个会阻塞锁直到第一次提交,然后继续并返回一个空结果。如果没有第二个AND used_flag = FALSE,我认为他们甚至可以返回重复项!在上面的演示paths 表中添加entry_id SERIAL PRIMARY KEY 列后尝试。为了让他们参加比赛,只需在第三节 LOCK TABLE paths 即可;请参阅我在以下链接中提供的示例。

我在in another answer 和我对can multiple threads cause duplicate updates on a constrained set 的回复中写过这些问题。

说真的,去看看 PGQ。它已经为你解决了这个问题。

【讨论】:

  • 1.感谢 PGQ 链接 - 这可能会解决我的许多问题! 2.你认为原创海报可以在“独占访问”模式下做一个锁表吗?
  • @JonathanVanasco 你的意思是ACCESS EXCLUSIVE,默认LOCK TABLE模式?当然可以,但他们不想强制事务按顺序执行,那样做就可以了。
  • 是的,我误解了这个问题。我没有意识到所有的任务/数据都在那个表中。我使用ACCESS EXCLUSIVE 在不同的设置中阻止并行事务。
【解决方案2】:

不要选择旧路径,而是执行以下操作:

old_path = database.query('
    UPDATE      paths
        SET     used_flag = TRUE
        WHERE   used_flag = FALSE
        RETURNS data');

RETURNS 子句允许您从刚刚更新的行中“选择”值(/deleted/inserted)。

used_flag 指定该行是否已被另一个 Python 实例使用。使用WHERE used_flag = FALSE 位将确保您没有选择已经使用过的东西。

【讨论】:

  • 我的意图是实现“锁定和释放”。这样,脚本就会被通知它没有可以处理的值。由于问题没有明确说明他是如何选择“旧路径”的,如果它是一个队列或者它是由 PK 选择的,我不想过多地假设这个应用程序。
  • 我还举例说明了UPDATE,因为它是原子独立的,因此例如在递增/递减计数器(例如:预留停车位)时,您将锁定同一行以进行更新并希望它如果没有更多可用的停车位,则失败。
  • UPDATE 不是原子的。它在不同的阶段进行。执行过滤器以选择UPDATE 将影响的行,然后锁定这些行,最后更新它们。 UPDATE 语句之间可能存在竞争。
  • MySQL UPDATE 中的 AFAIK 在以下方面保证是原子的:它保证它的整个有效负载的提交/失败(不会发生部分更新);它保证如果您对行数据集(例如SET x = x+1, y=x/2, z=x+y)进行操作,您最终不会得到任何垃圾数据,在重新计算和提交之前,没有其他部分提交可以污染 x 和 y 的值。确实,就您所指的有关它选择的内容以及最终更新的内容而言,它可能会失败,但失败是原子性保证的一部分。
  • 当然,您可以保证事务之间提交的明显原子性,以及防止脏读的隔离。这些是一般事务属性,而不是 UPDATE 的属性。是的,如果您明确指定主键,那么并发更新将按预期运行。对于更复杂的WHERE 子句,由于行选择、锁定、更改和事务提交的顺序,可能会出现意外的并发行为。见stackoverflow.com/questions/11914804/…
【解决方案3】:

如果任务队列软件能够为请求提供唯一标识符,也许您可​​以将每个请求的 old_path 存储在不同的行中。如果没有,也许您可​​以为每个请求生成一个密钥并用它存储路径。

【讨论】:

    猜你喜欢
    • 2011-03-17
    • 2013-01-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-01-30
    相关资源
    最近更新 更多