一、死锁到底是个什么鬼?

咱们先来打个比方。想象一下两个人在狭窄的走廊里迎面相遇,都坚持让对方先走,结果谁都过不去——这就是死锁的生动写照。在PostgreSQL里,当两个或多个事务互相等待对方释放锁资源时,系统就会陷入这种"僵局"。

举个典型的银行转账场景(以下示例均基于PostgreSQL 14):

-- 事务1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁定账户1
-- 这里故意不提交,模拟业务处理时间

-- 事务2(同时在另一个会话中执行)
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 锁定账户2
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 等待事务1释放锁

这时候如果事务1反过来想操作账户2:

-- 回到事务1继续执行
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 等待事务2释放锁

boom!死锁产生了。PostgreSQL的检测机制会立即发现这个循环等待,然后自动终止其中一个事务,让另一个可以继续执行。

二、PostgreSQL的侦探技巧

PostgreSQL内置的死锁检测器就像个尽职的交警,默认每秒钟巡逻一次(由deadlock_timeout参数控制,默认1秒)。这个机制主要靠维护一个"等待图"来工作:

  1. 定期扫描所有活跃事务的锁等待情况
  2. 构建有向图检查环路
  3. 发现环路就选择代价最小的事务回滚

我们可以通过日志看到详细过程:

-- 查看死锁日志
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
SELECT pg_reload_conf();

-- 模拟死锁后查看日志
SELECT pg_read_file('log/postgresql-'||to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD')||'.log');

日志会清晰记录哪些命令导致了死锁,以及最终牺牲了哪个事务。这个侦探虽然能干,但频繁死锁检测也会消耗资源,所以要根据业务特点调整检测间隔:

-- 对高并发OLTP系统可以调小检测间隔
ALTER SYSTEM SET deadlock_timeout = '500ms';

-- 对分析型业务可以适当调大
ALTER SYSTEM SET deadlock_timeout = '2s';

三、防患于未然的优化策略

与其等死锁发生再处理,不如从设计上规避。以下是几个实战验证过的"黄金法则":

1. 统一操作顺序

就像收拾行李先装大件后装小件,数据库操作也要有固定顺序。比如在多表更新时,约定总是按表名字母序操作:

-- 好的做法:按固定顺序更新
BEGIN;
UPDATE account SET ... WHERE id = 1;  -- A开头的表
UPDATE billing SET ... WHERE id = 1;  -- B开头的表
COMMIT;

-- 反面教材:随机顺序更新
BEGIN;
UPDATE billing SET ... WHERE id = 1;  -- 可能与其他事务冲突
UPDATE account SET ... WHERE id = 1;
COMMIT;

2. 使用短事务

长时间运行的事务就像占着厕所玩手机的人,特别容易引发排队。解决方案:

-- 错误示范:事务中包含业务逻辑处理
BEGIN;
SELECT * FROM orders WHERE ... FOR UPDATE;  -- 获取锁
-- 这里执行耗时20秒的Java业务逻辑
UPDATE orders SET ...;
COMMIT;

-- 正确做法:先处理业务再快速提交
-- Java代码处理业务逻辑...
BEGIN;
UPDATE orders SET ... WHERE ...;  -- 快速完成
COMMIT;

3. 合理使用锁级别

不是所有操作都需要最高级别的锁。PostgreSQL提供多种锁模式:

-- 使用FOR UPDATE SKIP LOCKED跳过已锁定行
BEGIN;
SELECT * FROM job_queue 
WHERE status = 'pending' 
FOR UPDATE SKIP LOCKED 
LIMIT 1;
-- 处理获取到的任务
UPDATE job_queue SET status = 'processing' WHERE id = ?;
COMMIT;

-- 使用FOR NO KEY UPDATE降低锁粒度
BEGIN;
UPDATE accounts SET balance = balance - 100 
WHERE id = 1 
FOR NO KEY UPDATE;  -- 比FOR UPDATE限制更少
COMMIT;

四、当死锁不可避免时

有些复杂业务场景确实难以完全避免死锁,这时候我们需要做好"灾后重建":

  1. 实现自动重试机制:
// Java示例(使用Spring重试注解)
@Retryable(value = {DeadlockLoserDataAccessException.class}, 
           maxAttempts = 3, 
           backoff = @Backoff(delay = 100))
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
    // 业务逻辑
}
  1. 监控与报警配置:
-- 创建死锁监控视图
CREATE VIEW deadlock_stats AS
SELECT 
    COUNT(*) FILTER (WHERE query LIKE '%deadlock%') AS deadlock_count,
    MAX(deadlock_timeout) AS current_timeout
FROM pg_stat_activity;
  1. 应急处理工具箱:
-- 查询当前锁情况
SELECT blocked_locks.pid AS blocked_pid,
       blocking_locks.pid AS blocking_pid,
       blocked_activity.query AS blocked_query,
       blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks 
    ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
    AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
    AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
    AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
    AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
    AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
    AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
    AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
    AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
    AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.GRANTED;

五、特殊场景的特别处理

有些死锁问题需要特殊技巧:

批量处理优化

-- 危险操作:批量更新相同模式
UPDATE products SET stock = stock - 1 WHERE category = 'electronics';

-- 安全方案:分批处理
DO $$
DECLARE
    batch_size INT := 100;
    max_id INT := (SELECT MAX(id) FROM products);
    min_id INT := (SELECT MIN(id) FROM products);
BEGIN
    FOR i IN 0..((max_id - min_id)/batch_size) LOOP
        BEGIN
            UPDATE products SET stock = stock - 1 
            WHERE id BETWEEN min_id + (i*batch_size) 
                         AND min_id + ((i+1)*batch_size - 1)
            AND category = 'electronics';
            COMMIT;
        EXCEPTION WHEN OTHERS THEN
            ROLLBACK;
            -- 记录错误日志
        END;
    END LOOP;
END $$;

外键约束处理

外键特别容易引发隐蔽的死锁:

-- 危险操作:并发插入关联数据
-- 事务1
BEGIN;
INSERT INTO parent(id) VALUES(1);
INSERT INTO child(parent_id, name) VALUES(1, 'test');  -- 获取父表锁
COMMIT;

-- 事务2(并发执行)
BEGIN;
INSERT INTO parent(id) VALUES(2);
INSERT INTO child(parent_id, name) VALUES(2, 'test');  -- 可能与事务1冲突
COMMIT;

-- 解决方案:先统一插入父表
BEGIN;
-- 第一阶段:集中插入所有父记录
INSERT INTO parent(id) VALUES(1), (2), (3);
-- 第二阶段:插入子记录
INSERT INTO child(parent_id, name) VALUES
(1, 'test1'), (2, 'test2'), (3, 'test3');
COMMIT;

六、总结与最佳实践

经过这些分析,我们可以得出几个核心建议:

  1. 事务要尽可能短小精悍
  2. 多表操作坚持固定顺序
  3. 选择合适的锁粒度
  4. 批量操作采用分治策略
  5. 建立完善的监控和重试机制

记住,死锁就像数据库世界的交通拥堵,完全避免不现实,但通过良好的"城市规划"和"交通管理",我们可以把影响降到最低。PostgreSQL提供的工具链已经很完善,关键是要根据业务特点灵活运用这些策略。

最后分享一个真实案例:某电商平台在大促期间频繁出现死锁,通过将deadlock_timeout从1秒调整为300毫秒,并结合统一更新顺序,使死锁发生率下降了90%。这告诉我们——合适的参数调优+良好的编程规范,才是应对死锁的王道。