本节摘要:PostgreSQL 的表锁从 ACCESS SHARE 到 ACCESS EXCLUSIVE 共八级,强度递增、彼此冲突面递增;行锁由写入命令自带,分更新、共享、键值意图三类。两个事务以相反顺序锁行会形成等待环,死锁检测器默认每秒扫一次等待图,发现环即中止一方。看懂锁等待视图与死锁日志,是并发排障的基本功。
不需要背全部八级,记住三组对应即可覆盖日常:
| 你执行的语句 | 申请的表锁 | 谁会被挡 |
|---|---|---|
| 普通 SELECT | ACCESS SHARE | 仅挡 DDL(改表结构、truncate) |
| INSERT / UPDATE / DELETE | ROW EXCLUSIVE | 挡显式加的共享级以上表锁 |
| ALTER TABLE / DROP / TRUNCATE | ACCESS EXCLUSIVE | 挡一切,包括读 |
推论一:SELECT 会被 ALTER TABLE 挡住。一条跑了十分钟的分析查询期间,对同一张表的 ALTER 只能排队;排队的 ACCESS EXCLUSIVE 又会挡住它后面所有新来的 SELECT——这就是"改个小字段把整表读请求堵死"事故的机制。
推论二:加锁语句按强度申请,低强度不挡低强度。两个 UPDATE 并发跑没有表级障碍,各行其道——真正的相争发生在行锁。
UPDATE、DELETE、SELECT FOR UPDATE 给目标行上行锁;冲突的另一方等待(默认不设超时,除非配置了锁等待超时参数)。行锁信息记在元组上(与 MVCC 同居),不占内存锁表——千万行级并发也不吃紧。
-- 会话 A BEGIN; UPDATE account SET balance = 0 WHERE id = 1; -- 行 1 被锁 -- 会话 B:等待,直至 A 提交或回滚 UPDATE account SET balance = 1 WHERE id = 1;
-- 会话 A BEGIN; UPDATE account SET balance = 0 WHERE id = 1; -- 会话 B BEGIN; UPDATE account SET balance = 0 WHERE id = 2; -- 会话 A 接着 UPDATE account SET balance = 0 WHERE id = 2; -- 等 B -- 会话 B 接着 UPDATE account SET balance = 0 WHERE id = 1; -- 等 A → 成环
一秒内,其中一方会收到 dead lock detected 错误并被回滚,另一方顺利继续。日志里的等待环形如"A 等行 2(持有者 B),B 等行 1(持有者 A)",加上双方的语句文本——根因几乎总是两个事务按相反顺序访问同一批行。
解法按优先级:事务里按统一顺序访问行(比如都按主键升序);把"锁住再看"的大事务拆小;确认没有"先改 A 表再改 B 表"与"先 B 后 A"的代码路径并存。
-- 谁在等谁:锁等待的一行查询 SELECT blocked.pid AS waiting_pid, blocking.pid AS blocking_pid, blocked.query AS waiting_query, blocking.query AS blocking_query FROM pg_stat_activity blocked JOIN pg_stat_activity blocking ON blocking.pid = any(pg_blocking_pids(blocked.pid));
⚠️ 常见坑:应用捕获死锁错误后立刻原样重试,而两个事务又常以相反顺序抢行,于是死锁反复出现。重试之外,必须修正访问顺序,否则只是把错误变成了循环。
八级表锁与行锁都是引擎内置语义,PostgreSQL 还提供咨询锁——不关联任何表、纯应用自定义用途的锁原语。典型场景是"同一任务只允许一个实例跑":
-- 会话尝试领取任务级锁(会话级锁,断开自动释放) SELECT pg_try_advisory_lock(42001);
pg_try_advisory_lock ---------------------- t
返回真即拿到锁,开始干活;另一个会话同时尝试返回假,跳过任务。比起自建任务表加标志位(要处理宕机残留、事务回滚半途状态),咨询锁由引擎管理生命周期:会话断开锁即消失,无需清理任务。编号是应用自定的整数命名空间,大团队应当建一张编号登记表避免撞号。事务级变体 pg_try_advisory_xact_lock 随事务结束释放,适合"事务内防重":同一业务键的并发请求只有一个能进事务。
锁等待有一个比死锁更隐蔽的机制:ACCESS EXCLUSIVE 不是"挡住它冲突的人",而是排在它后面的所有人无论要什么锁都得等——队尾顺序公平但不区分需求。事故的标准剧本:长查询跑着,ALTER TABLE 到达开始排队,随后全部 SELECT 在队列里排在 ALTER 之后集体等待——一个与结构变更毫无关系的读请求也被"堵车"波及。
处置与预防三层。事中:找出排队的 DDL(pg_stat_activity 里 wait_event 为 Lock 且查询是 DDL 的会话),取消它,读流量立即恢复,DDL 改期。参数层:设 lock_timeout(比如两秒)让 DDL 排不到就自动放弃,配合失败重试的低峰脚本。流程层:结构变更一律走低峰加超时的自动化脚本,永远不在业务高峰手工执行 ALTER。三层叠起来的含义是:DDL 的风险管理本质是排队管理,理解队列传染性后,"改个小字段"就再也不是小事。
-- 会话级给 DDL 上保险:等锁最多两秒,拿不到就报错退出而不是堵着 SET lock_timeout = '2s'; ALTER TABLE orders ADD COLUMN memo text; RESET lock_timeout;