我怎样才能避免这种竞争条件?

时间:2012-09-17 19:57:45

标签: python sql postgresql synchronization race-condition

我有一个分布式任务队列,其任务看起来像这样:

# 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,但我不确定是否可以避免这种竞争条件。表级锁定也意味着我必须为每个附加任务引入一个新表(因为没有它们相互阻塞),这听起来不太吸引人。

3 个答案:

答案 0 :(得分:5)

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

并发很难

Mihai的回答看起来很好,但在并发操作中有所下降。

两个并发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,即使它们在提交后实际执行,因为它已经选择了行并且在插入时等待锁定。 / p>

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

如果没有显式事务,UPDATE并发运行时会发生同样的事情。如果您使用显式事务,那么在不摆弄时间的情况下演示会更容易。

选择一行怎么样?

如上所述,系统工作正常,但如果你只想获得最老的行怎么办?因为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我认为他们甚至可以返回重复!将entry_id SERIAL PRIMARY KEY列添加到上面的演示paths表后尝试使用它。为了让他们参加比赛,在第3场比赛中只需LOCK TABLE paths;请参阅以下链接中给出的示例。

我写了这些问题in another answer以及我对can multiple threads cause duplicate updates on a constrained set的回答。

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

答案 1 :(得分:1)

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

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

RETURNS子句允许您从刚刚更新(/删除/插入)的行中“选择”值。

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

答案 2 :(得分:0)

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