一、死锁到底是个什么鬼?
咱们先来打个比方。想象一下两个人在狭窄的走廊里迎面相遇,都坚持让对方先走,结果谁都过不去——这就是死锁的生动写照。在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秒)。这个机制主要靠维护一个"等待图"来工作:
- 定期扫描所有活跃事务的锁等待情况
- 构建有向图检查环路
- 发现环路就选择代价最小的事务回滚
我们可以通过日志看到详细过程:
-- 查看死锁日志
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;
四、当死锁不可避免时
有些复杂业务场景确实难以完全避免死锁,这时候我们需要做好"灾后重建":
- 实现自动重试机制:
// Java示例(使用Spring重试注解)
@Retryable(value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100))
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑
}
- 监控与报警配置:
-- 创建死锁监控视图
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;
- 应急处理工具箱:
-- 查询当前锁情况
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;
六、总结与最佳实践
经过这些分析,我们可以得出几个核心建议:
- 事务要尽可能短小精悍
- 多表操作坚持固定顺序
- 选择合适的锁粒度
- 批量操作采用分治策略
- 建立完善的监控和重试机制
记住,死锁就像数据库世界的交通拥堵,完全避免不现实,但通过良好的"城市规划"和"交通管理",我们可以把影响降到最低。PostgreSQL提供的工具链已经很完善,关键是要根据业务特点灵活运用这些策略。
最后分享一个真实案例:某电商平台在大促期间频繁出现死锁,通过将deadlock_timeout从1秒调整为300毫秒,并结合统一更新顺序,使死锁发生率下降了90%。这告诉我们——合适的参数调优+良好的编程规范,才是应对死锁的王道。
评论