我正在使用 Postgres 数据库为大量计算机/进程实施作业调度。简而言之,每个作业都有它的 ID,所有调度都是通过三个选项卡实现的:所有作业、当前正在运行的作业和已经完成的作业。
调度的关键功能是 (1) 请求作业和 (2) 通知数据库有关已完成的作业。请求作业从作业列表中获取任何 id,它不在 running 表中,也不在 completed 表中:
insert into piper.jobs_running
select x.fid from (
SELECT fid FROM piper.jobs
except
select fid from piper.jobs_running
except
select fid from piper.jobs_completed
) as x limit 1
returning(fid)
完成作业会将其从运行列表中删除并将其插入已完成列表。因为它不是特定于并发的,所以我忽略了 SQL 命令(完成一项工作需要几十分钟到几个小时。)
这对我来说是一个令人讨厌的惊喜,两个进程运行上面完全相同的查询(几乎同时请求作业)可能获得相同的作业 ID (fid)。我提出的唯一可能的解释是 Postgres 不依赖于 ACID 规范。注释?
附加信息:我将事务设置为可序列化(在postgresql.conf set default_transaction_isolation = 'serializable'
中)。现在,DBMS 会在隔离未完成的情况下使事务失败。是否可以强制 Postgres 自动重启它们?
查询的具体问题
PostgreSQL 默认为
READ COMMITTED
隔离,看起来你没有使用任何不同的东西。在READ COMMITTED
每个语句中获取自己的快照。它看不到来自其他事务的未提交的更改;就好像它们根本就没有发生过一样。现在,假设您在三个会话中同时运行此程序,设置中包含三个条目 in
jobs
、 zero injobs_running
和 zero injobs_completed
:每次运行都会选择
jobs
. 因为他们的快照都是在他们中的任何一个提交更改之前拍摄的,甚至是在创建未提交的行之前拍摄的,*他们都会在jobs_running
和中找到零行jobs_completed
。所以他们都要求一份工作。可能是同一份工作,因为即使没有
ORDER BY
,扫描顺序也是一样的。锁定
行锁定跨越跨国边界,让您可以在事务之间进行通信以强制执行排序。所以你可能认为这会解决你的问题,但它不会。
如果您
FOR UPDATE
在row
条目上锁定,则该行被独占锁定,锁定将一直保持到事务提交或回滚。所以你会认为下一个事务会得到不同的行,或者如果它试图得到相同的行,它会等待锁释放,看到 中现在有一个条目jobs_running
,然后跳过该行。你错了。将会发生的是您将锁定所有行。一个事务将成功获取所有行的锁。将执行相同索引或顺序扫描的其他事务通常会尝试以相同顺序锁定行,在第一次锁定尝试时卡住,并等待第一个事务回滚或提交。如果你不走运,它们可能会开始锁定不同的行集并相互死锁,从而导致死锁中止,但通常你只是没有得到有用的并发。
更糟糕的是,第一个事务选择它锁定的行之一,插入一行
jobs_running
并提交,释放锁。然后另一个事务能够继续,并锁定所有行....但它没有获得数据库状态的新快照(快照在语句开始时拍摄),所以*它看不到你插入一排成jobs_running
。因此,它获取相同的作业,将该作业的一行插入到jobs_running
中,然后提交。条件重新检查
PostgreSQL 有一个大多数数据库都没有的古怪功能,如果一个事务阻塞在锁上,它会在第一个事务提交后获得锁时重新检查所选行是否仍然匹配锁条件。
这就是https://stackoverflow.com/questions/11532550/atomic-update-select-in-postgres中的示例起作用的原因 - 它依赖于回收锁后的限定符重新检查
WHERE
子句。锁定的使用强制所有事情串行运行,所以在实践中你还不如让一个连接来完成工作。
隔离、ACID 和现实
PostgreSQL 中的事务隔离并不是事务并发运行的完美理想,但它们的效果与串行执行时的效果相同。
唯一能够提供完美隔离的真实世界数据库是在写入事务首次访问每个表时专门锁定每个表的数据库,因此在实践中,如果事务针对数据库的不同部分,则只能是并发访问。没有人愿意在需要或有用并发的情况下使用这样的数据库。
所有现实世界的实施都是妥协。
READ COMMITTED
默认PostgreSQL 的默认设置是
READ COMMITTED
隔离,这是一个定义明确的隔离级别,允许不可重复和幻读,如PostgreSQL 事务隔离手册中所述。SERIALIZABLE
隔离您可以在每个事务的基础上或作为每个用户、每个数据库或(不推荐)全局默认值请求更严格的
SERIALIZABLE
隔离。这提供了更强大的保证,但仍然不完美,如果它们以串行运行时不会发生的方式交互,则以强行回滚事务为代价。因为您的并发查询将始终尝试获取第一份工作,所以无论有多少工作,除了一个以外的所有工作都会因序列化失败而中止。因此在实践中,您不会获得任何有用的并发性,还不如使用单个连接将作业分发给工作人员。
(请注意,在 PostgreSQL 9.1 之前
SERIALIZABLE
提供的保证要弱得多,并且不会检测到很多事务相互依赖的情况。)自动重新运行
SERIALIZABLE
PostgreSQL 不会自动重新运行
SERIALIZABLE
因序列化失败而中止的事务。在某些情况下这会非常有用,但在其他情况下这样做将是完全错误和危险的——尤其是在涉及通过应用程序进行读/修改/写周期的情况下。目前不支持在序列化失败时自动重新运行事务。应用程序应重试。不要自己动手写排队系统
看起来你正在尝试做的是编写一个排队系统。考虑不这样做。编写一个健壮、可靠和正确的排队系统是很困难的,并且有一些已经可用的好系统可供您采用。您必须处理诸如碰撞安全性、当有人接受任务但未能完成任务时会发生什么、完成时的竞争条件就像您放弃完成它的任务处理程序等。有很多微妙的并发问题。不要尝试自己动手做。
9.5 和
SKIP LOCKED
仍在开发中的 PostgreSQL 9.5 添加了一项功能,使排队变得更加容易。
它让你说如果当你
SELECT ... FOR UPDATE
,如果一行被锁定,你应该忽略它并继续寻找下一个未锁定的行。这在与 a 结合时非常有用,LIMIT
因为它可以说“找到其他人尚未尝试声明的第一行”。因此,编写安全并发地抓取作业的队列变得非常简单。在该功能可用之前,我强烈建议您坚持使用单连接队列管理,或者使用经过良好测试的任务队列系统。
craig-ringer 的回答完全回答了这个问题。为了完整起见,我添加了以下简单代码来解决使用锁的问题: