SELECT FOR UPDATE 的奇怪死锁 PostgreSQL 死锁问题
Strange deadlock PostgreSQL deadlock issue with SELECT FOR UPDATE
我正在构建一个基于PostgreSQL的锁定系统,我有两种方法,acquire
和release
。
对于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
仅当目标行的 locked
为 false
时才应获取目标行上的锁。但实际上,并非如此。不知何故,即使不存在 locked = false
行,它仍然会获取锁。结果,我遇到了死锁问题。看起来像这样
释放正在等待 SELECT FOR UPDATE
,并且 SELECT FOR UPDATE
在无故持有锁的同时进行无限循环。
为了重现这个问题,我在这里写了一个简单的测试
https://gist.github.com/victorlin/d9119dd9dfdd5ac3836b
你可以运行用psycopg2
和pytest
,记得更改数据库设置,运行
pip install pytest psycopg2
py.test -sv test_lock.py
PostgreSQL normally 中止发生死锁的事务:
The use of explicit locking can increase the likelihood of deadlocks, wherein two (or more) transactions each hold locks that the other wants. For example, if transaction 1 acquires an exclusive lock on table A and then tries to acquire an exclusive lock on table B, while transaction 2 has already exclusive-locked table B and now wants an exclusive lock on table A, then neither one can proceed. PostgreSQL automatically detects deadlock situations and resolves them by aborting one of the transactions involved, allowing the other(s) to complete. (Exactly which transaction will be aborted is difficult to predict and should not be relied upon.)
查看您的 Python 代码和您显示的屏幕截图,在我看来:
- 线程 3 持有
locked=true
锁,并且是 waiting to acquire a row lock。
- 线程 1 也在等待行锁,还有
locked=true
锁。
- 唯一合乎逻辑的结论是线程 2 以某种方式持有行锁,并等待
locked=true
锁(注意该查询的时间很短;它是循环的,而不是阻塞的)。
由于 Postgres 不知道 locked=true
锁,因此在这种情况下无法中止事务以防止死锁。
我不是很清楚 T2 是如何获得行锁的,因为我看过的所有信息都说 it can't do that:
FOR UPDATE causes the rows retrieved by the SELECT statement to be locked as though for update. This prevents them from being locked, modified or deleted by other transactions until the current transaction ends. That is, other transactions that attempt UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE, SELECT FOR SHARE or SELECT FOR KEY SHARE of these rows will be blocked until the current transaction ends; conversely, SELECT FOR UPDATE will wait for a concurrent transaction that has run any of those commands on the same row, and will then lock and return the updated row (or no row, if the row was deleted). Within a REPEATABLE READ or SERIALIZABLE transaction, however, an error will be thrown if a row to be locked has changed since the transaction started. For further discussion see Section 13.4.
我找不到任何 PostgreSQL "magically" 将行锁升级到 table 锁或类似东西的证据。
但是你所做的显然也不安全。您正在获取锁 A(行锁),然后获取锁 B(显式 locked=true
锁),然后释放并重新获取 A,最后按顺序释放 B 和 A。这并没有正确地遵守锁层次结构,因为我们尝试在持有 B 的同时获取 A,并且 反之亦然 。但是 OTOH,在持有 A 的同时收购 B 应该不会失败(我认为),所以我仍然不确定这是完全错误的。
坦率地说,我认为您最好只在空 table 上使用 LOCK TABLE
语句。 Postgres 知道这些锁并将为您检测死锁。它还可以为您省去 SELECT FOR UPDATE
欺诈的麻烦。
测试用例是这样的:
- Thread-1 运行s
SELECT
并获取记录锁。
- Thread-2 运行s
SELECT
并进入锁的等待队列。
- Thread-1 运行s
UPDATE
/ COMMIT
并释放锁。
- Thread-2 获取锁。检测到记录自其
SELECT
以来发生了更改,它根据其 WHERE
条件重新检查数据。检查失败,该行被从结果集中过滤掉,但锁仍然持有。
此行为在 FOR UPDATE
documentation 中提到:
...rows that satisfied the query conditions as of the query snapshot will be locked, although they will not be returned if they were updated after the snapshot and no longer satisfy the query conditions.
这可以有一些 unpleasant consequences,所以考虑到所有因素,多余的锁 并不坏。
可能最简单的解决方法是通过在 acquire
的每次迭代后提交来限制锁定持续时间。还有其他各种方法可以防止它持有此锁(例如 SELECT ... NOWAIT
、运行ning 在 REPEATABLE READ
或 SERIALIZABLE
隔离级别,SELECT ... SKIP LOCKED
在 Postgres 9.5 ).
我认为使用这种重试循环方法的最干净的实现是完全跳过 SELECT
,而只是 运行 和 UPDATE ... WHERE locked = false
,每次都提交。您可以通过在调用 cur.execute()
之后检查 cur.rowcount
来判断您是否获得了锁。如果需要从锁定记录中提取其他信息,可以使用 UPDATE ... RETURNING
语句。
但我不得不同意 ,并说您最好利用 Postgres 的内置锁定支持,而不是尝试重新发明它。它会为你解决很多问题,例如:
- 自动检测死锁
- 等待进程进入睡眠状态,而不必轮询服务器
- 锁定请求排队,防止饥饿
- 锁(通常)不会超过失败的进程
最简单的方法可能是将 acquire
实现为 SELECT FROM my_locks FOR UPDATE
,将 release
简单地实现为 COMMIT
,并让进程竞争行锁。如果您需要更大的灵活性(例如 blocking/non-blocking 调用,transaction/session/custom 范围),advisory locks 应该很有用。
此外,您应该在发布代码中添加locked = true
:
BEGIN
UPDATE my_locks SET locked = false WHERE id = '<NAME>' AND locked = true
COMMIT
如果不是,则无论记录处于何种锁定状态(在您的情况下,即使 locked = false),您都在更新记录,并增加导致死锁的可能性。
我正在构建一个基于PostgreSQL的锁定系统,我有两种方法,acquire
和release
。
对于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
仅当目标行的 locked
为 false
时才应获取目标行上的锁。但实际上,并非如此。不知何故,即使不存在 locked = false
行,它仍然会获取锁。结果,我遇到了死锁问题。看起来像这样
释放正在等待 SELECT FOR UPDATE
,并且 SELECT FOR UPDATE
在无故持有锁的同时进行无限循环。
为了重现这个问题,我在这里写了一个简单的测试
https://gist.github.com/victorlin/d9119dd9dfdd5ac3836b
你可以运行用psycopg2
和pytest
,记得更改数据库设置,运行
pip install pytest psycopg2
py.test -sv test_lock.py
PostgreSQL normally 中止发生死锁的事务:
The use of explicit locking can increase the likelihood of deadlocks, wherein two (or more) transactions each hold locks that the other wants. For example, if transaction 1 acquires an exclusive lock on table A and then tries to acquire an exclusive lock on table B, while transaction 2 has already exclusive-locked table B and now wants an exclusive lock on table A, then neither one can proceed. PostgreSQL automatically detects deadlock situations and resolves them by aborting one of the transactions involved, allowing the other(s) to complete. (Exactly which transaction will be aborted is difficult to predict and should not be relied upon.)
查看您的 Python 代码和您显示的屏幕截图,在我看来:
- 线程 3 持有
locked=true
锁,并且是 waiting to acquire a row lock。 - 线程 1 也在等待行锁,还有
locked=true
锁。 - 唯一合乎逻辑的结论是线程 2 以某种方式持有行锁,并等待
locked=true
锁(注意该查询的时间很短;它是循环的,而不是阻塞的)。
由于 Postgres 不知道 locked=true
锁,因此在这种情况下无法中止事务以防止死锁。
我不是很清楚 T2 是如何获得行锁的,因为我看过的所有信息都说 it can't do that:
FOR UPDATE causes the rows retrieved by the SELECT statement to be locked as though for update. This prevents them from being locked, modified or deleted by other transactions until the current transaction ends. That is, other transactions that attempt UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE, SELECT FOR SHARE or SELECT FOR KEY SHARE of these rows will be blocked until the current transaction ends; conversely, SELECT FOR UPDATE will wait for a concurrent transaction that has run any of those commands on the same row, and will then lock and return the updated row (or no row, if the row was deleted). Within a REPEATABLE READ or SERIALIZABLE transaction, however, an error will be thrown if a row to be locked has changed since the transaction started. For further discussion see Section 13.4.
我找不到任何 PostgreSQL "magically" 将行锁升级到 table 锁或类似东西的证据。
但是你所做的显然也不安全。您正在获取锁 A(行锁),然后获取锁 B(显式 locked=true
锁),然后释放并重新获取 A,最后按顺序释放 B 和 A。这并没有正确地遵守锁层次结构,因为我们尝试在持有 B 的同时获取 A,并且 反之亦然 。但是 OTOH,在持有 A 的同时收购 B 应该不会失败(我认为),所以我仍然不确定这是完全错误的。
坦率地说,我认为您最好只在空 table 上使用 LOCK TABLE
语句。 Postgres 知道这些锁并将为您检测死锁。它还可以为您省去 SELECT FOR UPDATE
欺诈的麻烦。
测试用例是这样的:
- Thread-1 运行s
SELECT
并获取记录锁。 - Thread-2 运行s
SELECT
并进入锁的等待队列。 - Thread-1 运行s
UPDATE
/COMMIT
并释放锁。 - Thread-2 获取锁。检测到记录自其
SELECT
以来发生了更改,它根据其WHERE
条件重新检查数据。检查失败,该行被从结果集中过滤掉,但锁仍然持有。
此行为在 FOR UPDATE
documentation 中提到:
...rows that satisfied the query conditions as of the query snapshot will be locked, although they will not be returned if they were updated after the snapshot and no longer satisfy the query conditions.
这可以有一些 unpleasant consequences,所以考虑到所有因素,多余的锁 并不坏。
可能最简单的解决方法是通过在 acquire
的每次迭代后提交来限制锁定持续时间。还有其他各种方法可以防止它持有此锁(例如 SELECT ... NOWAIT
、运行ning 在 REPEATABLE READ
或 SERIALIZABLE
隔离级别,SELECT ... SKIP LOCKED
在 Postgres 9.5 ).
我认为使用这种重试循环方法的最干净的实现是完全跳过 SELECT
,而只是 运行 和 UPDATE ... WHERE locked = false
,每次都提交。您可以通过在调用 cur.execute()
之后检查 cur.rowcount
来判断您是否获得了锁。如果需要从锁定记录中提取其他信息,可以使用 UPDATE ... RETURNING
语句。
但我不得不同意
- 自动检测死锁
- 等待进程进入睡眠状态,而不必轮询服务器
- 锁定请求排队,防止饥饿
- 锁(通常)不会超过失败的进程
最简单的方法可能是将 acquire
实现为 SELECT FROM my_locks FOR UPDATE
,将 release
简单地实现为 COMMIT
,并让进程竞争行锁。如果您需要更大的灵活性(例如 blocking/non-blocking 调用,transaction/session/custom 范围),advisory locks 应该很有用。
此外,您应该在发布代码中添加locked = true
:
BEGIN
UPDATE my_locks SET locked = false WHERE id = '<NAME>' AND locked = true
COMMIT
如果不是,则无论记录处于何种锁定状态(在您的情况下,即使 locked = false),您都在更新记录,并增加导致死锁的可能性。