我有一个分布式任务队列,其任务看起来像这样:
# 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,但我不确定是否可以避免这种竞争条件。表级锁定也意味着我必须为每个附加任务引入一个新表(因为没有它们相互阻塞),这听起来不太吸引人。
答案 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成功并生成四行。第二个生成在第一次更新运行后插入并提交的剩余两个ee
和ff
。第二次更新不会返回gg
和hh
,即使它们在提交后实际执行,因为它已经选择了行并且在插入时等待锁定。 / 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存储在不同的行中。如果没有,也许你可以为每个请求生成一个密钥并用它存储路径。