pg_advisory_lock 的两个悬挂事故:PgBouncer 事务池里的孤儿锁,和 25P02 之后再也解不开的迁移锁
先看现场:所有 pod 卡在 waiting to acquire database lock
authentik 的一个真实 issue(#23876):lifecycle/migrate.py 用 session 级 advisory lock 保护迁移:
ADV_LOCK_UID = 1000
cursor.execute("SELECT pg_advisory_lock(%s)", (ADV_LOCK_UID,))
...
cursor.execute("SELECT pg_advisory_unlock(%s)", (ADV_LOCK_UID,))
部署在 PgBouncer pool_mode = transaction 后面。结果是:新起的 pod 全部卡在 waiting to acquire database lock,Helm chart 的 startup probe(failureThreshold 60 × periodSeconds 10 = 600s)先把它们杀掉,rollout 永远不收敛。单 pod 重启通常能过,因为 PgBouncer 的 server_lifetime 默认 3600s 会把中毒的后端回收掉;一旦重启速度快过这个窗口(滚动发布、节点 drain、版本升级),就变成硬故障。
在 primary 上看到的持有者是这样的:
pid | datname | client_addr | state | in_txn | conn_age
441 | authentik | 10.x.x.x | idle | f | 00:47:00 <- pgbouncer backend; lock granted
持有者 state=idle、in_txn=f,也就是说没有任何事务,也没有任何客户端在跑——锁挂在一个空闲的连接池后端上。
排查 SQL:
SELECT l.pid, l.granted, a.state, a.application_name, a.query
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory' AND l.classid = 0 AND l.objid = 1000;
输出里一行 granted=t 且 state=idle,其余全 granted=f,等待者的 wait_event_type = Lock、wait_event = advisory。看到这个形状基本可以断定:session advisory lock 被池化后端继承了。
机制:锁属于连接,而事务池每个事务换一次连接
PostgreSQL 文档(13.3.5 Advisory Locks):session 级锁一直持有到显式 unlock 或会话结束。会话就是一条连接。事务池模式下,一条 server 连接只在 BEGIN…COMMIT 之间归你,之后归还池子。于是:
SELECT pg_advisory_lock(1000)作为独立语句执行,它自己也构成一个隐式事务。事务结束,PgBouncer 把连接还回池子——锁还在上面。- 迁移在下一个分配到的后端上继续跑。
SELECT pg_advisory_unlock(1000)落到另一条后端,返回 false,而返回值从来没被检查,被静默丢弃。- 锁现在挂在一条无主的池化后端上。PgBouncer 的
server_reset_query(DISCARD ALL,会调 pg_advisory_unlock_all())在 transaction 模式下不执行,除非server_reset_query_always=1,这个开关默认关。
锁只能等 PgBouncer 按 server_lifetime 回收该后端时才释放。注意 server_reset_query_always=1 也不能当解法:DISCARD ALL 会在拿到锁之后立刻把迁移锁放掉,等于允许并发迁移。
修法(任选其一,issue 里列的顺序也是推荐顺序):
- 换成
pg_advisory_xact_lock(ADV_LOCK_UID),把整个迁移放进同一个事务里。事务级锁在 COMMIT/ROLLBACK 释放,与池化兼容。 - 检查
pg_advisory_unlock()的返回值,false 就抛异常。现在失败是静默的,这才是最难查的地方。 - 在一条显式 pin 住的专用连接上取锁,整个迁移期间不归还。
- 或者干脆让迁移绕开 PgBouncer,直连 PostgreSQL。
应急手段:调小 server_lifetime(例如 300),让残留锁在启动预算内自己消失;或者给 server/worker 设 AUTHENTIK_SKIP_MIGRATIONS=true,迁移单独跑一个直连库的 Job。清现场用:
SELECT pg_terminate_backend(pid)
FROM pg_locks
WHERE locktype = 'advisory' AND classid = 0 AND objid = 1000;
第二个事故:事务失败后,unlock 根本执行不到
lakekeeper 的一个 commit(83fe080,issue #1790)记录了另一种悬挂路径,和池化无关。它用 sqlx 的 transaction.lock(),取的是 session 级 advisory lock。迁移脚本里任意一条 SQL 失败,事务进入 aborted 状态,PostgreSQL 会拒绝对该事务的任何后续语句:
ERROR: current transaction is aborted, commands ignored until end of transaction block
SQLSTATE: 25P02
于是配对的 pg_advisory_unlock 查询根本到不了服务器。而 ROLLBACK 不释放 session 级 advisory lock——文档写得很直白:session 级锁不遵守事务语义,事务回滚后锁仍然在。锁活到连接结束,而池里的连接往往活很久,等于永不释放。之前试过 "best-effort unlock",没用,因为失败路径上那条 unlock 不可达。
改成事务级之后就没了:COMMIT 或 ROLLBACK 自动释放,包括事务被 abort 的情况。他们的回归测试是用一个已知会失败的迁移 fixture,断言失败后 pg_locks 里没有残留 advisory lock,且紧接着跑一次 migrate_core_only 不会阻塞。
这个案例值得记的点:session 级锁在“拿锁 → 干活 → 解锁”无法保证全部在同一事务内成功时,等于把正确性押在代码路径不抛异常上。
两个事故的共同前提:锁不是唯一防线
advisory lock 只对“也来取这把锁”的代码生效。绕过协议直接 UPDATE 的写入不会被拦。PgBouncer 文档把 advisory lock 列在 transaction 模式的“会坏”清单里,同样清单里还有 LISTEN/NOTIFY、跨语句的 session SET、跨语句的 temp 表、prepared statement 缓存——都是“需要连接在语句之间保持粘性”的东西。
复制也确实不救场:advisory lock 状态在 primary 的共享内存里,standby 看不到也不能取。主库切换那一刻,只靠 advisory lock 防重复的设计就会重复执行。正确的层次是:唯一约束保证正确性,锁只保证省掉重复劳动。
什么时候 session 级仍然合理
跨多个事务的长任务(迁移 runner、持有数小时的 leader 锁)用 session 级是合理的,前提是那条连接归你独占。用池里的连接做这件事有两个坑,而且不同驱动行为相反:实测(PostgreSQL 17)asyncpg 的池在归还连接时会 reset,session 锁被静默释放;psycopg 的池归还后锁还在,挂在空闲连接上堵住所有人。两种都不是你要的。
一个来自生产事故的反面教训:把 session 锁和连接池一起用,锁就等于占一条连接。有团队三个 cluster 级任务 + 一个节点级任务各自持锁,池子四条连接被锁占死,锁内的代码又要向同一个池申请连接跑查询,读探针 SELECT 1 拿不到连接返回 503,实例被摘出流量。改成租约表(一行一个任务名,ON CONFLICT DO UPDATE ... WHERE expires_at < now(),holder 带 hostname + pid + 64 位随机数,每 30 秒续约)之后,池子回到四条。这里的判断很清楚:锁不该消耗连接,session 锁只在保护范围真的跨事务、且有明确租约/断连语义时用。
操作清单
取锁前的选择:
- 数据库不变量、短事务 →
pg_advisory_xact_lock/pg_try_advisory_xact_lock。 - 已有持有者就跳过(leader 选举、cron) →
pg_try_*变体,看布尔值,别用阻塞版。 - 跨事务的长任务 → session 锁 + 专用连接(直连,或 session 模式的池),finally 里 unlock 并检查返回值。
- 阻塞式取锁一定配超时,
SET LOCAL lock_timeout比客户端取消干净:
BEGIN;
SET LOCAL lock_timeout = '200ms';
SELECT pg_advisory_xact_lock(9001, 42); -- 拿不到报 55P03 LockNotAvailable
-- 受保护的写
COMMIT;
事后检查:
-- 空闲连接上挂着的 advisory lock:泄漏信号
SELECT a.pid, a.state, a.application_name, a.query_start,
now() - a.state_change AS idle_for
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory' AND a.state = 'idle'
ORDER BY idle_for DESC;
这个查询定期跑,把“idle 连接持有 advisory lock 超过 1 分钟”的条数导出成指标。非零基本就是池化后端的 session 锁泄漏,application_name 能指到具体服务。
多把锁时按固定顺序取,否则和行锁一样会死锁(40P01),advisory lock 同样参与死锁检测。键空间也要自己规划:同一集群内所有数据库共享一个 advisory 键空间,两个功能用同一个 bigint 会互相阻塞;用 (classid, objid) 两参数形式划命名空间比 hashtext() 之后祈祷不撞更可靠。
参考: