advisory lock 的两套键空间和 40P01:bigint 与 (int4,int4) 不互通,死锁要从 pg_locks 反查
PostgreSQL 的 advisory lock 有两套键:单个 bigint(64 位),或两个 int4 组成的 (classid, objid) 对。官方文档明确写了这两个键空间 “do not overlap”。pg_advisory_lock(12345) 和 pg_advisory_lock(0, 12345) 不是同一把锁,互不阻塞。
一、两套键空间不重叠
把两个 int4 的高低位拼成一个 bigint,以为两者等价——这是错的。混用两套形式的结果是两条代码路径各自持锁,谁也没挡住谁。日志里 “锁生效了” 是假绿,冲突照样发生。
pg_locks 里 locktype='advisory',bigint 形式会被拆进 classid / objid 两列:高 32 位进 classid,低 32 位进 objid。反解时必须把两列按无符号重解释:
SELECT (classid::bigint & 4294967295) << 32
| (objid::bigint & 4294967295) AS key_i64
FROM pg_locks WHERE locktype = 'advisory';
直接写 classid::bigint << 32,遇到负的 classid(高位带符号)会算错。对 (int4,int4) 形式,classid 和 objid 就是原来的两个 int4,不用还原。
二、键规划:namespace + resourceid
所有应用共享同一个数据库的键空间,key 冲突是全局的:A 应用的 12345 和 B 应用的 12345 会互相阻塞。常见做法是用两 int4 形式,第一个整数当子系统固定常量(比如 1001 表示 job_sync),第二个是业务 ID(订单号、用户 ID),可读、可解释、无碰撞。
不要把 hashtext('job:sync:42') 当键。hashtext 是未文档化的内部函数,只有 32 位,两个不同名字可能撞同一个 key;它属于内部实现,跨大版本不保证行为。用时间戳或随机数当键同理——那不是锁,是漏。
三、取锁形态
session 级 / 事务级,排他 / 共享,每种再有阻塞版和 try_ 版:
pg_advisory_lock(key):会话级排他,阻塞到拿到为止。pg_try_advisory_lock(key):会话级排他,立即返回 boolean。pg_advisory_lock_shared/pg_try_advisory_lock_shared:共享,多持有者并存,与排他互斥。pg_advisory_xact_lock/pg_try_advisory_xact_lock/pg_advisory_xact_lock_shared:事务级,COMMIT 或 ROLLBACK 自动释放,不能手工解锁——pg_advisory_unlock对 xact 锁无效。
会话级锁不遵守事务语义:事务内取的锁,ROLLBACK 后仍然持有;unlock 即使随后事务失败也已生效。同一会话可以对同一 key 重复取锁,计数叠加,必须 unlock 同样次数才真正释放。如果 helper 函数取了一次、调用方又取一次,就要两次 unlock。官方建议默认用 xact 版本,短临界区更省心。
LIMIT 与函数求值顺序有坑:
-- LIMIT 不保证在锁函数之前生效,可能多锁一批且不释放
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100;
要先用子查询 LIMIT 出 ID,再取锁。另外 advisory lock 与普通锁共用 max_locks_per_transaction × max_connections 的槽位,总数大概几万到几十万,别一次取成千上万个键。
四、40P01 死锁
advisory lock 参与 PG 的死锁检测。会话 A 持 key1 等 key2、会话 B 持 key2 等 key1,deadlock_timeout(默认 1s)到点后检测器发现环,其中一个被终止:
ERROR: deadlock detected
SQLSTATE: 40P01
DETAIL: Process X waits for ExclusiveLock on advisory lock [a,b,c,d]; blocked by process Y.
复现(注意用会话级事务锁):
T1: BEGIN; SELECT pg_advisory_xact_lock(1); ... SELECT pg_advisory_xact_lock(2);
T2: BEGIN; SELECT pg_advisory_xact_lock(2); ... SELECT pg_advisory_xact_lock(1);
取多把锁时必须全体按同一顺序取。DETAIL 方括号里是 locktag 的四个 32 位字段,不是键值,别拿它直接和 pg_locks 的 classid/objid 对照下结论。查真实持有者:
SELECT l.pid, l.mode, l.granted, a.state, a.query, a.backend_start
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
ORDER BY l.granted, l.pid;
granted=false 的行是排队者;同一个键(看 classid/objid/objsubid)下 granted=true 的 pid 就是持有者。清理用 pg_terminate_backend(pid),先确认那条连接在干什么。多资源场景按稳定顺序取锁 + 失败重试,40P01 要重试整个事务。
五、MySQL GET_LOCK 对照
MySQL 没有事务级咨询锁,只有会话级:
SELECT GET_LOCK('billing_daily_usage', 10); -- 1 拿到 / 0 超时 / NULL 出错
SELECT RELEASE_LOCK('billing_daily_usage'); -- 1 成功 / 0 非本线程持有 / NULL 锁不存在
SELECT IS_FREE_LOCK('name'), IS_USED_LOCK('name'); -- IS_USED_LOCK 返回持有者 connection_id
名字是字符串,最多 64 字符,服务端全局命名空间(不分 schema),常用 db_name.str 或 app_name.str 前缀。timeout 单位秒:0 表示只试一次,负数表示无限等待;没有单独的 try 函数,靠 timeout=0 模拟。5.7 起一个会话可同时持有多把命名锁,GET_LOCK 不会释放已有锁;5.6 及更早取第二把会静默释放第一把,老代码的诡异 bug 多源于此。实现基于 MDL(metadata locking)子系统。
COMMIT/ROLLBACK 不释放锁,异常路径必须在 finally 里 RELEASE_LOCK,或用 RELEASE_ALL_LOCKS() 兜底。互相等待时 MySQL 选一个调用者报 ER_USER_LOCK_DEADLOCK(错误码 3058),且不导致事务回滚——这点和 PG 的 40P01 相反,PG 杀掉整个事务,MySQL 只终止这次取锁请求。没有共享模式,想做多读一写得自己造。binlog_format=STATEMENT 下 GET_LOCK 不安全,会打 warning;单 mysqld 生效,NDB Cluster 不适用。
六、主从与容灾
两个引擎都不复制锁状态:MySQL 的命名锁在运行中的 mysqld 内存里,PG 的 advisory lock 在主库共享内存,备库看不到也拿不到。主库切换那一刻,所有靠 advisory lock 防重复执行的任务会同时获得执行权。
锁只保证效率,正确性要靠唯一约束:对 (account_id, usage_date) 建唯一索引,任务重复跑就是一次无害的冲突,而不是重复扣费。跨节点的分布式互斥,单库 advisory lock 不成立。
Go + pgx 示例
const nsJobSync int32 = 1001
func withLock(ctx context.Context, tx pgx.Tx, id int32, fn func() error) error {
var got bool
if err := tx.QueryRow(ctx,
"SELECT pg_try_advisory_xact_lock($1,$2)", nsJobSync, id).Scan(&got); err != nil {
return err
}
if !got {
return ErrLockBusy
}
return fn()
}
锁在 tx 结束时自动释放,不需要 defer unlock。