"以一致的顺序获取锁"是预防死锁最核心、最有效的手段。要彻底理解它,我们可以从死锁产生的底层原理出发,再结合具体的代码实践来看。
1. 底层原理:破坏"循环等待"条件
从操作系统和数据库的并发控制原理来看,死锁的产生必须同时满足四个必要条件:互斥、占有和等待、不剥夺、循环等待。
"以一致的顺序获取锁"的本质,就是从物理上破坏"循环等待"条件。
假设有两个并发事务需要同时操作资源 A 和 资源 B:
- 不一致的顺序(产生死锁) :
- 事务 1:先锁 A,再锁 B
- 事务 2:先锁 B,再锁 A
- 结果:事务 1 持有 A 等待 B,事务 2 持有 B 等待 A,形成闭环,互相卡死。
- 一致的顺序(避免死锁) :
- 事务 1:先锁 A,再锁 B
- 事务 2:也先锁 A,再锁 B
- 结果:如果事务 1 先抢到了 A,事务 2 会在尝试锁 A 时直接被阻塞排队。等事务 1 执行完释放了 A 和 B,事务 2 才能继续。交叉加锁的路径被消除,死锁自然无法发生。
2. 在 PostgreSQL 中的具体实践
在实际的数据库表设计和业务代码中,"一致的顺序"通常体现在以下两个维度:
维度一:跨表操作时,固定表的访问顺序
如果你的业务逻辑需要同时更新多张表(例如订单表和库存表),必须在全局约定一个固定的访问路径(例如:永远先锁 orders 表,再锁 products 表)。
维度二:批量操作同表多行时,强制按主键排序
这是后端开发中最容易踩坑的场景。比如批量更新一批商品库存,如果直接按前端传来的 ID 顺序去加锁,极易引发死锁。
在 Python 后端中,正确的做法是在应用层先对 ID 列表进行排序,或者在 SQL 中强制指定顺序:
python
# 假设 items 是从前端或上游拿到的待更新商品列表
# 错误做法:直接按原顺序加锁
# for item in items:
# update_stock(item.id)
# 正确做法:在 Python 应用层按 ID 升序排序,确保所有并发请求拿锁顺序一致
items.sort(key=lambda x: x['id'])
for item in items:
# 在事务中执行更新
cursor.execute("UPDATE products SET stock = stock - 1 WHERE id = %s", (item['id'],))
或者直接在 SQL 层面利用 SELECT ... FOR UPDATE 配合 ORDER BY 来预锁定资源:
sql
BEGIN;
-- 核心:通过 ORDER BY id 强制按主键升序获取行级锁
SELECT * FROM products WHERE id IN (1, 5, 3) ORDER BY id ASC FOR UPDATE;
-- 此时 ID 为 1, 3, 5 的行已按顺序被锁定,后续执行更新绝对安全
UPDATE products SET stock = stock - 1 WHERE id IN (1, 5, 3);
COMMIT;
3. 进阶注意事项
- 排序字段必须命中索引 :如果
ORDER BY的字段没有索引,数据库可能会触发全表扫描或 filesort,导致锁范围扩大甚至锁升级,反而增加死锁风险。 - 警惕隐式加锁 :除了显式的
UPDATE或SELECT FOR UPDATE,外键约束、UPSERT(INSERT ... ON CONFLICT)等操作在底层也是逐行加锁的。如果并发事务处理的数据集有交集且传入顺序不同,同样会死锁。因此,在 Python 中写入数据前,养成先对数据集按唯一约束字段排序的习惯是非常必要的。 - 应用层兜底 :即使设计了完美的加锁顺序,极端并发下仍可能有意外。建议在 Python 代码中捕获 PostgreSQL 的死锁错误码(
40P01),并加入指数退避的重试机制。