PostgreSQL死锁问题:使用SELECT FOR UPDATE产生奇怪的死锁

6
我将基于PostgreSQL构建一个锁定系统,我有两个方法,acquirerelease
对于acquire方法,它的工作原理如下:
BEGIN
while True:
    SELECT id FROM my_locks WHERE locked = false AND id = '<NAME>' FOR UPDATE
    if no rows return:
        continue
    UPDATE my_locks SET locked = true WHERE id = '<NAME>'
    COMMIT
    break

对于release操作

BEGIN
UPDATE my_locks SET locked = false WHERE id = '<NAME>'
COMMIT

这看起来很简单,但它并没有奏效。奇怪的是,我认为
SELECT id FROM my_locks WHERE locked = false AND id = '<NAME>' FOR UPDATE

只有当目标行的 lockedfalse 时,才应该仅获取目标行上的锁。但实际情况并非如此。不知何故,即使不存在 locked = false 的行,它也会获取锁。结果,我遇到了死锁问题。情况看起来像这样:

Select for update dead lock issue

在等待 SELECT FOR UPDATE 时,释放正在做无限循环,而同时它因无缘无故持有锁。
为了再现这个问题,我写了一个简单的测试:

https://gist.github.com/victorlin/d9119dd9dfdd5ac3836b

你可以使用 psycopg2pytest 运行它,记得更改数据库设置并运行。
pip install pytest psycopg2
py.test -sv test_lock.py

你的隔离级别是什么?特别是,你是否使用“可重复读”或“串行化”? - Kevin
@Kevin 我没有改变隔离级别配置,因此我认为那是默认值,我认为应该是REPEATABLE READ,稍后会检查一下。 - Fang-Pen Lin
默认值为 READ COMMITTED - Kevin
@Kevin 是的,它是“读取提交”。 - Fang-Pen Lin
3个回答

9
测试用例如下:
  • 线程1运行SELECT并获取记录锁。
  • 线程2运行SELECT并进入锁的等待队列。
  • 线程1运行UPDATE / COMMIT并释放锁。
  • 线程2获取锁。检测到自其进行SELECT以来记录已更改,它会根据其WHERE条件重新检查数据。检查失败,该行将从结果集中过滤出来,但仍保持锁定状态。
这种行为在FOR UPDATE文档中提到:

...符合查询快照条件的行将被锁定,尽管如果它们在快照之后更新并且不再满足查询条件,则不会返回这些行。

这可能会导致一些不愉快的后果,因此从长远考虑,一个多余的锁也不是那么糟糕。

可能最简单的解决方法是通过在每次acquire迭代之后提交来限制锁定持续时间。还有其他各种方法可以防止它保持此锁定(例如,在REPEATABLE READSERIALIZABLE隔离级别中运行的SELECT ... NOWAIT,在Postgres 9.5中使用SELECT ... SKIP LOCKED)。

我认为使用这种重试循环方法的最干净实现是完全跳过SELECT,直接运行UPDATE ... WHERE locked = false,每次提交。您可以通过调用cur.execute()后检查cur.rowcount来判断是否获取了锁定。如果您需要从锁记录中提取其他信息,则可以使用UPDATE ... RETURNING语句。
但我同意@Kevin的观点,并说您可能最好利用Postgres内置的锁支持,而不是试图重新发明它。它将为您解决许多问题,例如:
  • 死锁会自动检测
  • 等待进程被放入睡眠状态,而不必轮询服务器
  • 锁请求排队,防止饥饿
  • 锁通常不会超过失败的进程
最简单的方法可能是将acquire实现为SELECT FROM my_locks FOR UPDATE,将release简单实现为COMMIT,并让进程争夺行锁。如果您需要更多的灵活性(例如阻塞/非阻塞调用、事务/会话/自定义范围),advisory locks应该很有用。

实际上,没有必要忙等待,因为Postgres可以简单地使线程等待。locked是不必要的,我们只需使用与id行关联的UPDATE锁即可。这直接导致了您最后一段的acquire/release - philipxy

2
PostgreSQL通常会中止死锁的事务:
使用显式锁定可以增加死锁的可能性,即两个(或更多)事务都持有彼此想要的锁。例如,如果事务1在表A上获取了独占锁,然后尝试在表B上获取独占锁,而事务2已经在表B上获取了独占锁并现在想要在表A上获取独占锁,则两者都无法继续。 PostgreSQL自动检测死锁情况并通过中止其中一个涉及的事务来解决它们,从而允许其他事务完成。(很难预测将中止哪个事务,不应依赖于此。)
查看您的Python代码和您展示的屏幕截图,我认为:
  • 线程3持有locked=true锁,并且正在等待获取行锁(waiting to acquire a row lock)
  • 线程1也在等待行锁,同时也在等待locked=true锁。
  • 唯一的逻辑结论是线程2以某种方式持有了行锁,并正在等待locked=true锁(请注意查询时间很短;它正在循环,而不是阻塞)。

由于Postgres不知道locked=true锁,因此无法中止事务以防止死锁。

对我来说还不清楚T2如何获得行锁,因为我查看的所有信息都说它不能这样做

“FOR UPDATE”会使得SELECT语句检索到的行像被更新一样被锁定,这可以防止其他事务在当前事务结束之前对其进行锁定、修改或删除。也就是说,其他尝试对这些行执行UPDATE、DELETE、SELECT FOR UPDATE、SELECT FOR NO KEY UPDATE、SELECT FOR SHARE或SELECT FOR KEY SHARE命令的事务将被阻塞,直到当前事务结束;反之,SELECT FOR UPDATE将等待并锁定并返回已更新的行(如果该行被删除,则不返回任何行),直到同时运行这些命令的并发事务结束。然而,在REPEATABLE READ或SERIALIZABLE事务中,如果要锁定的行自事务开始以来已更改,则会抛出错误。我没有找到任何关于PostgreSQL“神奇地”将行锁升级为表锁或类似操作的证据。
但你所做的事情显然也不安全。你先获取锁A(行锁),然后获取锁B(明确的locked=true锁),然后释放并重新获取A,最后按照那个顺序释放B和A。这样做没有正确地观察锁层次结构,因为我们尝试在持有B时获取A,反之亦然。但是,另一方面,在持有A的情况下获取B不应该失败(我认为),所以我仍然不确定这是否完全错误。
坦白说,我认为你最好只在一个空表上使用 LOCK TABLE 语句。Postgres 会意识到这些锁并为您检测死锁。这也省去了您使用 SELECT FOR UPDATE 的麻烦。

1
此外,您应该在发布代码中添加locked = true
BEGIN
UPDATE my_locks SET locked = false WHERE id = '<NAME>' AND locked = true
COMMIT

如果不这样做,你将会更新记录,无论它的锁定状态是什么(在你的情况下,即使锁定为false),并增加死锁的可能性。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接