编程 pg_advisory_unlock 返回 false:Go 连接池下 session 级咨询锁的失效路径

2026-11-08 00:04:05

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 版,并且三条同时满足:

  1. 用 db.Conn 借出独占连接,从拿到锁到放锁之间不归还;
  2. defer 里解锁,用 context.WithoutCancel 保证语句发得出去,并检查返回的 bool 和 error;
  3. 拿锁语句设 lock_timeout,避免等待无限拉长。

连接池的 MaxConnectionLifetime 之类的回收策略确实能在 backend 被关掉时兜底清锁,但那是分钟级的兜底,不是正确性保证,不要依赖它来解锁。

复制全文 生成海报 咨询锁 PostgreSQL Go 连接池 锁排查

推荐文章

程序员茄子在线接单