pg_advisory_unlock 返回 false:Go 连接池下 session 级咨询锁的失效路径
现象
一个 Go 定时任务,用咨询锁防止多副本并发跑同一份逻辑。拿锁和放锁各是一条独立语句:
if _, err := db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", key); err != nil {
return err
}
defer db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", key)
// ... 业务逻辑
现象有三类:
- 放锁那一步不出错,但扫描出来的 bool 是
false。服务端同时打一条WARNING: you don't own a lock of type ExclusiveLock,客户端这条语句仍然算成功,不看返回值就当没发生。 - 下一轮再拿同一个 key,
pg_advisory_lock直接卡住不返回,直到lock_timeout或调用方超时。某些 worker 从此永远拿不到锁。 - 重启进程后恢复正常,跑一段时间复发。数据库侧
pg_locks里那行 advisory 锁的 pid 属于一条空闲连接,state是idle。
第三种现象是判断依据:锁还在,持有它的 backend 还活着,但应用已经认为锁被放掉了。
根因:两条语句不保证走同一条连接
database/sql 的 *sql.DB 是连接池,不是连接。ExecContext 只保证“借一条空闲连接、执行、归还”,两次调用之间池可以给它不同的 backend。而 session 级咨询锁的所有权记在 backend 的 PGPROC 上:
SELECT pg_advisory_lock(42); -- 落到 backend pid 1001
SELECT pg_advisory_unlock(42); -- 落到 backend pid 1002
第二条在 1002 上执行,1002 没持有 42,于是返回 false 外加那条 warning,1001 上的锁原封不动留着,直到这条连接被池关掉或服务重启。连接池通常会长时间保活,于是“永远拿不到锁”就成了稳定复现。
顺带一个容易搞混的点:pg_locks.pid 是 backend pid,不是应用侧 pid,也不是 goroutine。查锁时不要拿应用的进程号去对。
写法一:用 db.Conn 把语句绑到一条连接上
db.Conn(ctx) 从池里借出一条专用连接,返回 *sql.Conn,上面所有语句都走同一个 backend,Close() 归还池(不是断开连接)。拿锁、干活、解锁都在这个 conn 上做。
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close() // defer 是 LIFO,后注册先执行,所以解锁在归还之前
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", key); err != nil {
return err
}
defer func() {
// 请求 ctx 可能已被取消(客户端断开、上游超时)。
// 直接用它会得到一个不会发出去的解锁语句,锁留在 backend 上。
uctx := context.WithoutCancel(ctx)
var ok bool
err := conn.QueryRowContext(uctx, "SELECT pg_advisory_unlock($1)", key).Scan(&ok)
switch {
case err != nil:
// 连接已断,锁随 backend 退出释放,记录即可
case !ok:
// 这条连接上没有该锁,说明前面的获取或状态管理有问题
}
}()
context.WithoutCancel 是 Go 1.21 起提供的,剥掉 cancel/deadline,保留 value。解锁这种“无论如何都要发出去”的收尾语句适合用它。如果 Go 版本更低,用 context.Background() 加一个独立的短超时,效果接近。
同时注意 conn.Close() 一定在解锁之后执行,否则连接先回池、解锁再借到别的连接,问题原样复现。
写法二:换成事务级锁
pg_advisory_xact_lock 的释放由服务端在事务边界做,应用不需要、也没法手工 unlock:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // 已 Commit 时返回 ErrTxDone,无害
if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock($1)", key); err != nil {
return err
}
// ... 业务写操作也在同一个 tx 里
return tx.Commit()
锁的持有区间与事务完全重合,任何一条路径(正常提交、业务报错回滚、panic 触发 Rollback、连接断开导致服务端回滚)都会释放锁。连接池怎么调度都无所谓。
session 与 xact 的语义差异
| 维度 | session 级 pg_advisory_lock | 事务级 pg_advisory_xact_lock |
|---|---|---|
| 释放时机 | 显式 unlock,或会话结束 | 所在事务 COMMIT / ROLLBACK |
| 事务回滚是否释放 | 否。事务中获取、随后回滚,锁仍然持有 | 是 |
| 能否手工 unlock | 有 pg_advisory_unlock / pg_advisory_unlock_all | 没有对应函数 |
| 同会话重复 acquire | 栈计数,acquire n 次需要 n 次 unlock 才真正释放 | 会话已持有时后续请求直接成功,事务结束整体释放 |
| 连接断开 | backend 退出,锁释放 | 事务中止,锁释放 |
| 连接池下的泄漏风险 | 高:unlock 落到别的连接等于没解 | 低:释放发生在服务端事务边界 |
两套接口对同一个锁标识会正常互相阻塞;同一会话已持有某个 advisory lock 时,它自己的后续请求总是成功,哪怕别的会话正在等——这条规则在两套接口上都成立。
观测:pg_locks 里的 advisory 行长什么样
SELECT l.pid,
l.classid,
l.objid,
l.objsubid,
l.mode,
l.granted,
a.state,
a.query,
pg_blocking_pids(l.pid) AS blockers
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
ORDER BY l.granted, l.pid;
键的编码方式取决于调用的是哪个重载:
- 单参数 bigint 版本:
objsubid = 1,键被拆成classid(高 32 位)和objid(低 32 位)。还原:
SELECT pid, (classid::bigint << 32) | objid::bigint AS key, mode, granted
FROM pg_locks
WHERE locktype = 'advisory' AND objsubid = 1;
- 双参数
(int4, int4)版本:objsubid = 2,classid和objid分别就是两个键。
classid / objid 在系统视图里是 oid(无符号 32 位),用负数 int4 当键时,直接看十进制会和代码里的值对不上,按位还原或 ::bit(32) 看更直观。这一点建议在自己的版本上用一条已知 key 的锁实测确认。
等待行就是 granted = false 的那些:
SELECT w.pid AS waiter, w.classid, w.objid, w.objsubid,
pg_blocking_pids(w.pid) AS blockers
FROM pg_locks w
WHERE w.locktype = 'advisory' AND NOT w.granted;
pg_blocking_pids 给出持锁方,拿它 join pg_stat_activity 看那条连接在干什么、state 是不是 idle。idle 加 advisory 锁,基本就是漏解锁。
几个边界
lock_timeout 对咨询锁生效
等锁会受 lock_timeout 约束,超时报 canceling statement due to lock timeout,SQLSTATE 55P03。advisory lock 也参与死锁检测,环路会被服务端识别并杀掉一方。生产上给拿锁语句单独设超时,比在应用层等 context deadline 更容易区分“锁被占”和“网络慢”:
SET LOCAL lock_timeout = '2s';
SELECT pg_advisory_lock($1);
锁表容量
advisory lock 和普通锁共用同一个共享内存锁表,容量大致是 max_locks_per_transaction × (max_connections + max_prepared_transactions),能授予的 advisory 锁数量通常在几万到几十万量级。耗尽时报:
ERROR: out of shared memory
HINT: You might need to increase max_locks_per_transaction.
泄漏的 session 锁会一直占着槽位,长期跑下来把可用额度吃掉一部分。
LIMIT 的求值顺序
官方文档专门点了这个坑:LIMIT 不保证在加锁函数求值之前生效,可能对超出预期范围的 id 加了锁,而且 session 级锁要到会话结束才放。
-- 危险:可能给不止一行加锁
SELECT pg_advisory_lock(id) FROM jobs WHERE id > 12345 LIMIT 100;
-- 安全:先在子查询里截断
SELECT pg_advisory_lock(q.id)
FROM (SELECT id FROM jobs WHERE id > 12345 LIMIT 100) q;
外层查询自己带过滤条件的那种写法(WHERE id = 12345)没问题。
PgBouncer transaction 模式
连接池在中间做一层时问题更大:PgBouncer 的 transaction 模式下,服务端连接是逐事务复用的,应用侧“同一个连接”不成立。在连接 A 上拿到 session 锁,下一条语句被路由到连接 B,应用就彻底失去了对这把锁的控制,而数据库仍认为 A 持有它。要么改用事务级锁,要么把 PgBouncer 配成 session 模式保持一对一。
与 MySQL GET_LOCK 的差别
GET_LOCK 同样是连接级、非事务的,会话断开自动释放,连接池复用连接时一样会“释放到别的连接上”;它只是把 PostgreSQL 的 session 级接口换了个名字,不会带来额外保障。而事务级语义在 MySQL 侧没有直接对应物。
默认选 xact,session 只在必要时用
绝大多数“防并发重入”的场景,锁的边界本来就和一次事务重合,直接上 pg_advisory_xact_lock:不用管连接、不用管解锁、回滚和崩溃都自动清理,池怎么调度都不影响正确性。
确实需要跨多个事务持锁(比如一次长任务分多段提交),才用 session 版,并且三条同时满足:
- 用
db.Conn借出独占连接,从拿到锁到放锁之间不归还; defer里解锁,用context.WithoutCancel保证语句发得出去,并检查返回的 bool 和 error;- 拿锁语句设
lock_timeout,避免等待无限拉长。
连接池的 MaxConnectionLifetime 之类的回收策略确实能在 backend 被关掉时兜底清锁,但那是分钟级的兜底,不是正确性保证,不要依赖它来解锁。