做后端的人应该都遇到过这种场景:多个 worker 同时从一张任务表里取数据,大家都执行SELECT ... LIMIT 1准备认领任务,结果两个进程取到同一行,后面一个UPDATE要么长时间阻塞,要么干脆死锁报错。PostgreSQL 9.5 引入的SELECT ... FOR UPDATE SKIP LOCKED就是专门解决这个问题的。它的本意非常朴素:拿不到行锁就跳过这行,换下一行试试,而不是在原地死等。
这篇文章我会把这条特性的源码链路完整拆一遍,从语法解析、查询规划、执行器到存储引擎的行锁获取,同时梳理 9.5 到 18 这些版本里跟 SKIP LOCKED 有关的底层演进。适合三类人看:被并发任务队列折磨的后端开发、想深入理解 PostgreSQL 锁机制的内核爱好者、以及做数据库排障的 DBA。
1. 任务队列里的锁竞争:SKIP LOCKED 到底解决了什么
1.1 没有 SKIP LOCKED 的时代是怎么抢任务的
假设有一张任务表,结构大概是这样的:
CREATE TABLE task_queue ( id bigint PRIMARY KEY, status text NOT NULL DEFAULT 'pending', payload text );多个 worker 进程的领任务逻辑通常长这样:
SELECT id, payload FROM task_queue WHERE status = 'pending' ORDER BY id LIMIT 1;拿到结果后执行UPDATE task_queue SET status = 'running' WHERE id = ...。问题在于,两个 worker 几乎同时执行上面的 SELECT,都会看到同一条 pending 任务,然后一个 UPDATE 成功,另一个被阻塞,或者靠唯一约束去兜底,又或者直接抛死锁。
更聪明的写法是直接加FOR UPDATE:
SELECT id, payload FROM task_queue WHERE status = 'pending' ORDER BY id LIMIT 1 FOR UPDATE;这样第一个 worker 锁住了这行,第二个 worker 的 SELECT 会在行锁上等待。等第一个 worker 提交事务后,第二个 worker 才能拿到锁,然后看到status = 'running',再配合WHERE status = 'pending'判断就能避开已处理的任务。这基本是 9.5 之前的标准做法。
但问题是:如果第一个 worker 处理得很慢,第二个 worker 就在锁上干等。如果同时有十个 worker 挤在同一行上,就会排成一条锁等待队列。任务队列讲究的是"谁有空谁处理",而不是"大家排队抢同一个任务"。
1.2 为什么 NOWAIT 替代不了 SKIP LOCKED
PostgreSQL 很早就有FOR UPDATE NOWAIT,语义是"拿不到锁就直接报错"。放在任务队列里,第二个 worker 会立刻收到类似could not obtain lock on row in relation "task_queue"的错误。它不会阻塞了,但任务也没领到,而且整条 SQL 直接失败,上层还得做重试或异常处理。如果一批任务里有一半被锁,每个 worker 的 SQL 都会频繁失败。
SKIP LOCKED 的语义比 NOWAIT 更贴合业务:拿不到锁的行直接当成"不存在",跳过它继续往下找。对于LIMIT 1的任务领取来说,就是"第一个能锁到的行归我"。不需要重试,不需要异常分支,批量取多条任务也只需要一条 SQL。
两者的对比非常直观:
| 等待策略 | 拿不到行锁时的行为 | 适用场景 |
|---|---|---|
| 默认阻塞 | 事务挂起,等待持锁者提交或回滚 | 业务上严格要求读取最新状态,且并发冲突极少 |
| NOWAIT | 立即抛出锁不可用错误 | 宁可失败也不等待,由上层重试 |
| SKIP LOCKED | 跳过这一行,继续扫描后续行 | 任务队列、批量分发、抢占式处理 |
任务队列有一个特点:任务本身是无状态的,谁处理都一样,丢了这行换下一行即可。这种"行不可用就绕过"的语义,只有在 SKIP LOCKED 出现之后才被原生支持。
1.3 适用场景和不适合的场景
SKIP LOCKED 最常见的用法集中在三类场景:
- 任务队列:worker 从待处理任务里领取一条或多条,处理完更新状态。
- 消息批处理:同一张表积压了一批待发送的消息,多个发送进程并发消费。
- 定时扫描抢占:比如定时任务注册表,多个实例抢某个时间段的任务。
但也有明显不适合的场景。如果业务要求"必须处理到某一行,缺了它整个批次都不完整",那 SKIP LOCKED 会导致行被漏掉,这种场景应该用普通FOR UPDATE加上重试。如果两个事务之间必须严格按主键顺序处理一批行,SKIP LOCKED 会打乱顺序,也不合适。
理解它解决了什么,才能接下来理解内核为这个语义做了哪些设计。
2. 语法与锁模式:FOR UPDATE/SHARE 家族和 SKIP/NOWAIT 的语义边界
2.1 四种行锁模式的冲突矩阵
SKIP LOCKED 并不是 FOR UPDATE 独有的,FOR SHARE、FOR NO KEY UPDATE、FOR KEY SHARE后面都可以跟。这四种行锁模式对应 PostgreSQL 行级锁的不同强度:
- FOR KEY SHARE:最弱,只禁止删除行或修改键值,不禁止其他更新。
- FOR SHARE:禁止 UPDATE 和 DELETE,但不禁止键值更新之外的并发共享读取。
- FOR NO KEY UPDATE:禁止其他事务对同一行加 UPDATE 类排他锁,但允许 KEY SHARE。
- FOR UPDATE:最强,禁止其他事务以任何方式再锁这行。
冲突关系大致可以这样记:
| 锁模式 | 与哪些模式冲突 |
|---|---|
| FOR KEY SHARE | FOR UPDATE、FOR NO KEY UPDATE、DELETE |
| FOR SHARE | FOR UPDATE、FOR NO KEY UPDATE、DELETE |
| FOR NO KEY UPDATE | FOR UPDATE、FOR SHARE、FOR NO KEY UPDATE |
| FOR UPDATE | 所有其他行锁模式 |
实际上,UPDATE 语句默认会对目标行加 FOR NO KEY UPDATE 级别的锁,DELETE 加 FOR UPDATE 级别的锁。理解这个矩阵的意义在于:任务队列里如果两个 worker 都加 FOR UPDATE,那么它们之间肯定互斥;如果一个 worker 加 FOR KEY SHARE 另一个加 FOR SHARE,两者反而不冲突。SKIP LOCKED 只会在"加锁真的会冲突"时跳过,不会因为行上有任意锁就跳。
2.2 SKIP LOCKED 的语法位置和互斥关系
语法形式是这样的:
SELECT ... FROM ... WHERE ... FOR UPDATE SKIP LOCKED; SELECT ... FROM ... WHERE ... FOR SHARE OF table_name SKIP LOCKED;有几个语法边界值得注意。
第一,SKIP LOCKED必须跟在FOR UPDATE / FOR SHARE / FOR NO KEY UPDATE / FOR KEY SHARE之后,没有独立的SKIP LOCKED子句。
第二,SKIP LOCKED和NOWAIT是同一个语法槽位的互斥选项,写FOR UPDATE NOWAIT SKIP LOCKED会直接语法错误。
第三,FOR UPDATE OF a SKIP LOCKED这种形式只对指定表加锁,未指定的表只做普通快照读取。这在多表 JOIN 时很有用:只跳过目标表上的锁冲突,不干扰关联表。
2.3 隔离级别与快照:锁定是在执行阶段发生的
这是很多人踩坑的地方。SELECT FOR UPDATE不是纯粹的快照读,它对返回的每一行都会在"查询执行阶段"尝试加锁。SKIP LOCKED 的跳过动作发生在执行阶段,而不是快照建立阶段。
在默认的 READ COMMITTED 隔离级别下,每条 SQL 都会拿一个新的快照,所以即使某个任务在执行到一半时被别的事务更新了,参与跳过的判断依然基于最新可见性。在 REPEATABLE READ 或 SERIALIZABLE 下,快照是整个事务固定的,被其他事务更新并提交的行对当前事务不可见,此时 SKIP LOCKED 的跳过行为会更复杂,甚至可能出现"明明这行后来已经被提交,但当前事务因为快照看不到新版本而放弃处理"的情况。做任务队列时,我会优先选 READ COMMITTED。
快照与锁分开处理是理解后续源码的基础:扫描器返回的是"快照里可见的元组版本",行锁加在这个元组对应的物理行上。SKIP LOCKED 跳过的是"加不上锁的行",但已经返回给执行器的元组缓存并不会因此立即失效,这中间就涉及到执行器如何与存储引擎配合。
3. 9.5 源码级执行链路:从 gram.y 到 heap_lock_tuple
3.1 语法解析:LockWaitPolicy 枚举与 opt_nowait_or_skip
在 9.5 的src/backend/parser/gram.y里,fitting_clause相关规则中有一个专门处理等待策略的非终结符,逻辑等同于:
opt_nowait_or_skip: /* EMPTY */ { $$ = LockWaitBlock; } | NOWAIT { $$ = LockWaitError; } | SKIP LOCKED { $$ = LockWaitSkip; } ;这个非终结符的返回值被塞进LockingClause节点,并最终转换成parsenodes.h里定义的LockWaitPolicy枚举:
typedef enum LockWaitPolicy { LockWaitBlock, /* 等锁 */ LockWaitSkip, /* 跳过 */ LockWaitError /* 报错 */ } LockWaitPolicy;analyze.c中的transformLockingClause会把LockingClause里的锁模式和等待策略写入每个关系对应的RowMarkClause。如果你对视图或子查询执行FOR UPDATE SKIP LOCKED,analyze 阶段还会做下推处理,把锁语义传递到实际的基础表上。从这里开始,SKIP LOCKED 就不再是语法层面的概念,而是规划器和执行器都能识别的属性。
3.2 规划阶段:RowMarkData 与 LockRows 节点
查询规划阶段,planner.c里的preprocess_rowmarks会把RowMarkClause转换成执行计划中使用的PlanRowMark,并保留waitPolicy。优化器会在需要行锁的查询上生成一个LockRows节点,通常位于扫描节点和 Limit 节点之间。
一个典型的执行计划形状如下:
Limit -> LockRows -> Index Scan using task_queue_pkey on task_queue这里 LockRows 的位置非常关键。它处在扫描之上、Limit 之下,意味着执行器会从下层扫描拿到满足条件且对当前快照可见的所有行,再逐行加锁。LIMIT的存在让 LockRows 可以在取到足够数量后提前终止,所以任务队列的LIMIT 1通常只会真正锁住一行。
如果查询本身有排序,比如ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED,计划器会先按索引顺序取出排好序的行,再交给 LockRows 做行锁过滤。这保证了你拿到的确实是当前可锁行里 id 最小的那一行。
3.3 执行器主战场:ExecLockRows 的循环与分支
真正的加锁动作发生在src/backend/executor/nodeLockRows.c的ExecLockRows函数里。这个函数的核心结构是一个循环,对从下层节点拿到的每个元组执行加锁逻辑。9.5 的实现逻辑可以大致简化为如下流程:
for (;;) { slot = ExecProcNode(outerPlan); if (TupIsNull(slot)) return NULL; /* 下层扫描完 */ foreach (l, node->rowmarks) { PlanRowMark *rc = lfirst(l); Relation relation = ...; HeapTuple tuple = ExecFetchSlotTuple(slot); /* * 调用存储引擎层函数对元组加行锁。 * 这里传入的 LockWaitPolicy 决定了等锁、报错还是跳过。 */ HTSU_Result result = heap_lock_tuple(relation, tuple, estate->es_output_cid, rc->markType, rc->waitPolicy, &update_xmax); switch (result) { case HeapTupleSelfUpdated: /* 被当前事务自己更新,继续处理 */ break; case HeapTupleUpdated: /* 元组被并发事务更新,READ COMMITTED 下需要 EPQ 再检查 */ goto lnext; case HeapTupleBeingUpdated: /* * 元组正被其他事务锁定。 * 如果等待策略是 SKIP LOCKED,则跳过这一行, * 否则由 heap_lock_tuple 内部完成阻塞等待或报错。 */ if (rc->waitPolicy == LockWaitSkip) { /* 跳过当前元组,回外层循环取下一条 */ continue; } break; default: /* 加锁成功 */ break; } } return slot; }这段代码把执行器对"锁冲突"的处理分成了两层:heap_lock_tuple负责在存储引擎层尝试加锁,执行器拿到结果后决定是保留、等待、报错还是跳过。SKIP LOCKED 的本质就是在HeapTupleBeingUpdated这个分支里,根据waitPolicy选择了continue。
有个细节容易被忽略:continue跳过的是"当前元组版本",但扫描游标并不会自动前进到下一行,而是回到外层循环继续从下层节点取数。所以如果一行被锁,执行器会不停从下层拉取下一行,直到找到能加锁的行或扫描结束。
3.4 行锁获取:heap_lock_tuple 如何处理三种等待策略
heap_lock_tuple是 9.5 里存储引擎层最核心的行锁入口,位于src/backend/access/heap/heapam.c。它的签名在 9.5 里已经带了LockWaitPolicy参数,这本身就是为 SKIP LOCKED 引入的改动。
函数处理流程大致是:根据元组的t_infomask判断当前锁状态,尝试把当前事务的 xid 写入元组头并置位相应的HEAP_XMAX_*标志。如果发现元组正被其他事务锁住,会根据等待策略走不同分支。用一个简化的伪代码表达核心逻辑:
if (result == HeapTupleBeingUpdated) { switch (wait_policy) { case LockWaitBlock: /* 阻塞等待持锁事务结束,然后重新检查 */ XactLockTableWait(xmax, ...); result = HeapTupleUpdated; break; case LockWaitError: /* NOWAIT:直接抛出锁不可用错误 */ ereport(ERROR, (errcode(ERRCODE_LOCK_NOT_AVAILABLE), errmsg("could not obtain lock on row in relation \"%s\"", RelationGetRelationName(relation)))); break; case LockWaitSkip: /* SKIP LOCKED:不等待,直接返回正在被更新的状态 */ return HeapTupleBeingUpdated; } }重点在于LockWaitSkip并不是"获取锁失败"的错误路径,而是"明确告知执行器这行现在不可用"。执行器拿到HeapTupleBeingUpdated后,配合rc->waitPolicy == LockWaitSkip就能安全地跳过这一行。
从设计角度看,把等待策略传入存储引擎层,是为了让最底层的锁判断能在一个原子流程内完成。如果先由执行器检查元组头、再决定是否调用存储引擎,中间可能会有并发事务提交或回滚造成的信息空隙。直接让heap_lock_tuple看到完整元组状态、返回统一结果,执行器就不用关心xmax和infomask的具体位操作了。
4. 跳过一行没那么简单:xmax、MultiXact 与并发事务判定
4.1 infomask、xmax 与行锁的存储表示
PostgreSQL 的堆表元组头里有两个关键字段:t_xmin记录插入事务,t_xmax记录删除或锁定该元组的事务。行锁并不是存在独立锁表里的,而是直接写在元组头的xmax和相关位标志上。
当某个事务对一行加 FOR UPDATE 锁时,它会把自己的事务 ID 写入t_xmax,同时置位HEAP_XMAX_EXCL_LOCK。FOR SHARE 则置位HEAP_XMAX_SHARED_LOCK。其他事务扫描到这行时,通过TransactionIdIsInProgress(xmax)判断持锁事务是否还活着。如果持锁事务已经提交或回滚,这个 xmax 标记就相当于失效了,可以安全忽略。
这也是为什么锁判断必须放在存储引擎层:仅仅看xmax非空是不够的,还要结合事务状态、infomask 的锁类型、当前是否处于快速路径锁等条件综合判断。SKIP LOCKED 的"跳过"判断同样依赖这套元组头机制。
4.2 MultiXact:多个事务共享锁时的复杂情况
当一个元组被多个事务同时加共享锁时,PostgreSQL 不会让多个事务 ID 直接竞争写入元组头,而是把它们合并成一个 MultiXactId 存在xmax里,并置位HEAP_XMAX_IS_MULTI。MultiXact 在 9.3 重写后已经很成熟,9.5 引入 SKIP LOCKED 时,也需要正确处理这种结构。
下面是一个实际场景:三个会话同时对同一行执行SELECT ... FOR SHARE,前两个成功锁住,第三个执行SELECT ... FOR UPDATE SKIP LOCKED。此时这行的 xmax 已经是一个 MultiXactId,包含前两个事务的 ID。第三个会话要判断自己能否加 FOR UPDATE 锁,必须检查 MultiXact 里所有成员事务的状态和锁类型。
这会带来一个额外的性能开销:遍历 MultiXact 成员比检查单一 xid 更贵。任务队列里如果大量并发会话对同一行加锁,MultiXact 会持续膨胀。所以工程上任务表最好只用 FOR UPDATE 这种强排他锁,尽量避免多个 worker 对同一行加 FOR SHARE 的场景。
4.3 9.5 的"安全跳过"判断逻辑
这里有个很微妙的内核实现细节。heap_lock_tuple在LockWaitSkip模式下不能无脑返回HeapTupleBeingUpdated,它还要确认一个问题:xmax 指向的事务是"真正持有锁"还是"只是在锁队列里等待"。
举例说明,事务 A 持有一行的 FOR UPDATE 锁,事务 B 试图对这行加锁但还没成功,正在等待事务 A。此时事务 C 也来加锁,扫描到这行时看到 xmax 可能是 A,也可能是 A 的事务还处于运行中。对于 C 而言,真正需要判断的是 A 的状态。但如果是更复杂的锁等待链,比如 A 正在等待 D、D 正在等待 E,那么 A 短期内无法提交,C 可以安全跳过这行,不用担心 A 马上提交导致漏掉可见新版本。
9.5 的heapam.c里为此设计了专门的判断逻辑,通过检查持锁事务是否处于"已中止"或"正在等待其他锁"的状态来决定跳过是否安全。这段逻辑在后续版本里有过重构,但从 9.5 到今天,语义一直保留:SKIP LOCKED 不是简单的"看到锁就跳",而是"确认这行确实不可用才跳"。
对于普通使用者的启示是:不要以为SKIP LOCKED会无条件跳过所有被锁行。在极端锁竞争场景下,它仍然需要做事务状态检查,扫描性能会受到锁状态复杂度的拖累。
4.4 EPQ 与 SKIP LOCKED 的交互
最后补充一个 READ COMMITTED 下容易遇到的边界情况。
READ COMMITTED 的每条语句都会建立新快照,如果扫描时发现一个元组正在被并发事务 UPDATE,执行器不能直接忽略这个新版本,而是要触发 EvalPlanQual(EPQ)机制:回到索引重新抓取这个元组的最新版本,判断它对当前快照是否可见。如果可见,就基于新版本继续加锁。
SKIP LOCKED 与 EPQ 交互时,会发生"旧版本被跳过,但新版本需要再判断一次"的情况。比如 A 事务正在更新某行但未提交,B 事务执行SELECT ... FOR UPDATE SKIP LOCKED扫到旧版本,选择跳过。此时不算结束,因为 A 紧接着提交了,B 的扫描器通过 EPQ 机制抓到新版本,发现新版本也符合条件,就会对新版本重新做加锁判断。如果新版本恰好又被别的会话锁住,SKIP LOCKED 会再次跳过。
这就是为什么在并发频繁更新任务状态的表上,SELECT FOR UPDATE SKIP LOCKED的结果集可能会有细微的"不稳定性":它保证返回的行此刻一定能锁定,但不保证一定是最新的物理版本。对任务队列场景来说,这完全够用。
5. 9.5 到 18:语法没变,内核变了——周边演进盘点
5.1 9.6:并行查询带来的新约束
9.6 引入了并行查询,但FOR UPDATE/FOR SHARE这类行锁语句并不能直接并行化。原因很简单:并行 worker 进程不能各自持有行锁,锁需要统一由 leader 进程获取并管理,否则事务结束时的锁释放会变得不可控。
所以 9.6 之后,SELECT ... FOR UPDATE SKIP LOCKED即使底层扫描可以并行,执行计划里通常也会把 LockRows 放在 Gather 节点之上或附近,由 leader 统一处理。这意味着在非常大的任务表上,并行扫描带来的收益会被行锁串行获取部分抵消。任务队列场景一般单表不大,这个影响不明显,但如果用 SKIP LOCKED 扫描上千万行的冷数据表,就要注意执行计划里 LockRows 的位置。
5.2 12:Table AM 抽象与 TM_Result 重构
PostgreSQL 12 是一个里程碑。它把表存储抽象成了 Table Access Method 接口,原本堆表专属的heap_lock_tuple变成了table_lock_tuple,返回结果也从HTSU_Result重构为TM_Result。
这个变化对 SKIP LOCKED 的影响是架构性的。在 9.5 里,行锁逻辑深度绑定heap存储结构,元组头的 xmax、infomask 这些概念都是堆表专属的。12 之后,行锁协议变成了表 AM 的标准接口。理论上,一个自定义表 AM 只要实现了table_tuple_lock,就能使用SELECT FOR UPDATE SKIP LOCKED语法。虽然实际应用中大家基本还是用堆表,但内核的分层从此清晰了:执行器只依赖table_tuple_lock接口,存储引擎负责解释元组头和锁状态。
对普通使用者来说,12 的演进意味着升级后不需要修改任何 SQL,但如果你做内核开发或写扩展,就会感受到函数签名和错误码体系的整体变化。
5.3 15 之后:MERGE 与锁协议的新压力
15 版本引入的MERGE语句对行锁协议提出了新要求,因为它在一个语句里混合了 INSERT、UPDATE、DELETE 多种操作,同一行的锁需求可能在执行过程中动态变化。这促使内核在行锁接口里增加了更精细的列集跟踪,比如只更新某些列时如何选择锁强度。
SKIP LOCKED 本身没有因为 MERGE 而增加语法支持,MERGE语句后面不能跟SKIP LOCKED。但锁协议在 15 之后的调整,让行锁判断更依赖元组头的列级信息,这也间接影响到了所有行锁获取路径的性能特征。
从 16 到 18,公开的 release notes 里没有对 SKIP LOCKED 语法做任何改动,官方文档对它的描述和 9.5 几乎逐字一致。这说明这个特性从诞生开始,行为定义就足够稳定,后续版本的工作重心都放在了底层实现重构上。
5.4 一张表看 9.5 到 18 的相关变化
下面这张表只列与行锁、锁等待、SKIP LOCKED 直接相关的变化,不包含与本文无关的特性:
| 大版本 | 相关变化 |
|---|---|
| 9.5 | 引入 SKIP LOCKED;等待策略作为 LockWaitPolicy 传入 heap_lock_tuple |
| 9.6 | 并行查询引入;FOR UPDATE 类语句无法并行化,LockRows 由 leader 执行 |
| 10 | 声明式分区;分区表上的行锁下推行为逐步完善 |
| 11 | 存储过程、分区索引增强;SKIP LOCKED 语法和语义无变化 |
| 12 | Table AM 抽象;HTSU_Result 改为 TM_Result,heap_lock_tuple 改为 table_lock_tuple |
| 13 | 行锁相关维护性改进,MultiXact 与 VACUUM 交互持续调整 |
| 14 | wait event 体系增强,行锁等待可观测性提升 |
| 15 | MERGE 引入,行锁接口增加列集跟踪能力 |
| 16-18 | 内部重构与性能优化,官方文档对 SKIP LOCKED 行为描述未变 |
这张表提醒我们一件事:判断一个 PostgreSQL 特性是否值得依赖,不能只看当时的小版本,还要看它在后续架构演进中是否被持续支持。SKIP LOCKED 从 9.5 到 18 活着,而且活得很好,是因为它的语义足够简单稳定。
6. 实战模式:任务队列、消息批处理和容易踩的坑
6.1 经典任务队列 SQL 与执行计划观察
我目前在多个生产环境用的任务领取 SQL 基本是同一套:
WITH next_task AS ( SELECT id FROM task_queue WHERE status = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE task_queue SET status = 'processing', worker_id = pg_backend_pid(), started_at = now() WHERE id = (SELECT id FROM next_task) RETURNING id, payload;这条 SQL 的执行计划大致长这样:
Update on task_queue CTE next_task -> Limit -> LockRows -> Index Scan using task_queue_pkey on task_queue Index Cond: (status = 'pending') -> Sort -> CTE Scan on next_task重点看 LockRows 和 Limit 的顺序。LockRows 在 Limit 之下,意味着执行器扫描到第一行可锁定行后,LockRows 加锁成功,把行交给 Limit,Limit 满足一行就终止,上层不会再继续扫描。所以虽然用了FOR UPDATE,最终加锁的行数量基本就等于LIMIT的数量。
如果你观察到计划里 LockRows 在 Limit 之上,比如因为排序导致必须全量锁完才能知道哪一行最小,那就需要小心了。这种情况下执行器可能锁很多行才返回一小部分结果,任务队列的并发度会急剧恶化。
6.2 容易被忽略的坑:ctid、二次 UPDATE 和死锁
第一个坑是ctid的使用。有些优化建议会让任务队列写成:
UPDATE task_queue SET status = 'processing' WHERE ctid = ( SELECT ctid FROM task_queue WHERE status = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING *;ctid可以精确定位物理行,少一次索引回表,看起来更高效。但如果表上有并发 UPDATE,ctid在 UPDATE 前后会变化,并且查询期间可能发生 HOT 更新,导致ctid指向的物理位置已经不是当初 SELECT 到的逻辑行。更关键的是,外层 UPDATE 再次获取行锁时,锁判断的对象是当前的物理元组,而不是 SELECT 时的元组版本。这个写法在生产环境踩过坑之后,我基本不再推荐。宁可多花一次主键索引回表的成本,也要保证更新的是同一逻辑行。
第二个坑是死锁。SKIP LOCKED 大幅减少了行锁等待,但它不能消除死锁。如果一个 worker 领了两个任务,并且总是按 id 升序处理,另一个 worker 也领了两个任务但按 id 降序处理,两者在更新全局状态表时仍可能互相等待。任务队列的死锁排查,不能因为用了 SKIP LOCKED 就放松警惕。
第三个坑是隔离级别。如果在 REPEATABLE READ 下使用任务队列,两个 worker 可能因为快照固定而同时看不到对方提交的新版本,导致任务被重复处理或漏处理。我建议任务队列统一使用 READ COMMITTED,这是与行锁语义最匹配的隔离级别。
6.3 监控与排障:从 pg_locks 看 SKIP LOCKED 行为
生产环境里,如果怀疑 SKIP LOCKED 没有生效,可以看pg_locks和pg_stat_activity。当一个 worker 正在扫描并跳过被锁行时,它通常不会在pg_locks里留下对被锁行的等待记录,因为LockWaitSkip不会真正进入阻塞等待。你看到的可能是大量TupleLock类型的轻量锁短暂出现又消失。
还有一个技巧:观察pg_stat_activity中wait_event字段。如果大量 worker 卡在transactionid等待事件,说明有 worker 在等事务结束,而不是被 SKIP LOCKED 跳过的行卡住。这时候要查的不是行锁,而是那个迟迟不提交的事务。
如果发现 SKIP LOCKED 的查询一直在扫很多行才能找到一个可锁行,说明任务表的可用行分布有问题。例如 pending 任务大量集中在索引前部,且都被锁住,新 worker 每次都要跳过几百行。这时候要么增加 worker 错峰,要么调整领取策略,比如随机 offset 或按 id 分段领取。
6.4 一点关于内核演进的心得
跟踪一个特性从 9.5 到 18 的演进,我觉得最有价值的不是记住哪一年加了什么,而是理解"为什么一个语法可以十年不变"。
SKIP LOCKED 站在了正确的位置上:它把"等待策略"作为一个独立的语义维度,从语法层一路传到存储引擎层。上层的执行器不需要知道 MultiXact 怎么存储,下层的存储引擎不需要关心 SQL 是来自 SELECT 还是 CTE。锁冲突的判断在存储引擎里完成,跳过的决定在执行器里完成,职责清晰。
这种分层设计让它在 Table AM 抽象、并行查询、MERGE 引入等一系列大重构中都能存活下来。如果你也在做需要长期维护的系统,可以借鉴这个思路:把稳定的业务语义定义在接口层,把复杂的实现细节藏在存储层,而不是让语法和物理存储绑死在一起。
我实际用过 9.5 的 SKIP LOCKED 做任务分发,也看过 17 上完全相同的 SQL 跑在几十线程的并发下。语法没变,执行计划形状也类似,但底层的锁检查路径已经换了不止一代。这大概就是开源数据库演进最让人舒服的地方:你十年前写的 SQL,今天还能跑,而且跑得更好。