
PostgreSQL 9.5 引入的SELECT ... FOR UPDATE/SHARE ... SKIP LOCKED是并发队列场景里最被低估的特性之一。它解决的痛点非常具体多个消费者同时扫一张任务表抢同一条待处理数据谁抢到谁干抢不到的不能死等。在没有 SKIP LOCKED 之前这个场景只能用NOWAIT加异常重试或者pg_try_advisory_lock这类旁路手段硬顶。这篇文章我会从源码链路讲透这一特性的运行机制再结合 9.5 到 18 的版本演进聊聊为什么这套机制过了这么多年依然是任务队列实现的首选。适合正准备用 PostgreSQL 做消息表、任务调度、批处理分发或者单纯想搞懂“行锁跳过到底发生在哪一层”的开发者阅读。1. 锁模型与问题场景为什么要 SKIP LOCKED1.1 FOR UPDATE / FOR SHARE 家族的行为差异PostgreSQL 行级锁分为四种模式按强度从大到小依次是 FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE。它们之间并不是简单“互斥”关系而是一套有层次结构的锁冲突矩阵。锁模式冲突的锁典型场景FOR UPDATEFOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE要修改或删除行FOR NO KEY UPDATEFOR UPDATE、FOR SHARE、FOR KEY SHARE修改行但不改主键/唯一索引FOR SHAREFOR UPDATE、FOR NO KEY UPDATE只读引用禁止他人修改FOR KEY SHAREFOR UPDATE、FOR NO KEY UPDATE读行但允许他人改非键字段一个最简单直白的理解方式FOR UPDATE 是“我要改它你们都不能碰”FOR SHARE 是“我只要它别在我眼皮底下消失或变更关键字段其他随意”。SELECT 语句可以通过FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE这些子句给命中的行挂锁锁的生命周期通常持续到当前事务结束。这也是它能被当成“分布式队列元数据”的原因——事务结束前其他事务对同一行的写操作会被阻塞住天然形成一个互斥临界区。1.2 任务队列表多个 Worker 抢单的核心矛盾假设有张任务表 jobs结构大概是 id、payload、status、worker_id、updated_at。多个应用实例同时执行SELECT ... WHERE statuspending拿到一批任务后各自 UPDATE 成 running。如果没有行锁保护两个实例可能同时读到同一条 job造成重复执行。用 FOR UPDATE 可以避免这个问题第一个事务锁住行第二个事务执行同样查询时会阻塞等待等第一个事务提交或回滚后再读取。但这里的痛点是第二个任务消费进程可能并不想等待它更希望“跳过被别人占用的行直接去处理下一条还没人管的任务”。阻塞会拖慢整个消费链路尤其在任务量很大、消费者很多的时候一个慢事务能让一整个消费池卡住。所以排队模型里天然存在两种诉求要么“等这把锁”要么“这行被锁了我就不要了换一行”。PG 9.5 之前的 SQL 层面只支持前者默认等待和“立刻报错”NOWAIT不支持“跳过”。NOWAIT 的缺点非常明显它只告诉你“拿不到锁”你只能再发一次查询然后祈祷这次命中的别又是被锁的行。大量重试导致数据库空转SQL 日志刷屏代码逻辑也写得很难看。1.3 SKIP LOCKED 的语义和适用边界SKIP LOCKED 做的事情用一句话就能说清查询过程中遇到已经被其他事务锁定的行直接略过只返回当前可被锁定的行。行为类似高峰期餐厅排队你不想等某一桌就跳过它去问还有没有其他空位。它和 NOWAIT 的本质区别是NOWAIT 遇到锁会报错、中断整个查询SKIP LOCKED 遇到锁则继续扫描把能锁的拿回来。这个特性最适合三类场景任务队列/消息表多个消费者轮询同一个表谁抢到谁处理。批量调度同一批数据分片分发各 worker 只处理自己抢到的部分。数据迁移/归档分批扫描大表每批取 N 条未被其他进程正在处理的行。需要注意的是它不适合“某一行必须被处理处理不了就要报错”的业务。如果业务强依赖“锁不上就要提示用户稍后重试”NOWAIT 反而更合适。SKIP LOCKED 的重点是“尽力而为”不等、不报错默认告诉你“这轮没抢到就先等等”。2. 源码级拆解SKIP LOCKED 从 SQL 文本到执行器2.1 语法层LockingClause 与锁等待策略PostgreSQL 的 SQL 解析器会把FOR UPDATE SKIP LOCKED这类子句解析成一个LockingClause结构。这个结构除了记录锁的目标表、锁强度之外还会记录一个waitPolicy字段表示遇到锁冲突时执行器的处理策略。waitPolicy 有三个取值LockWaitBlock默认策略遇到锁就等待。LockWaitError对应 NOWAIT遇到锁立刻抛错。LockWaitSkip对应 SKIP LOCKED遇到锁就跳过。语法层面 SKIP LOCKED 和 NOWAIT 不能同时出现语义上互斥解析器会在转换阶段做合法性检查。这一层做了很关键的“策略下传”把用户写的可读性很强的 SQL 关键字翻译成执行器能识别的枚举值。2.2 分析器LockingClause 变成 Query 的 rowMarksSQL 解析完成后下一站是分析器analyzer。transformLockingClause负责把语义树里的 LockingClause 进一步转换为查询树中的RowMarkClause。它要处理几个问题如果FOR UPDATE OF table_name指定了目标表要校验表是否存在、是否有权限。如果没指定表名默认所有目标表都加锁。FOR UPDATE 在 join 场景下会对多个表生效分析器会为每个需要加锁的表生成一个 rowMarks 条目。最后把每个 RowMarkClause 的锁强度和 waitPolicy 确定下来。这个阶段之后SKIP LOCKED 的意图已经进入了查询树不再是简单的语法关键字。后面 planner 拿到的 rowMarks 列表已经包含“锁定哪张表、锁多强、冲突了怎么办”的完整信息。2.3 优化器生成 LockRows 计划节点Planner 在处理带行锁的查询时会为查询计划增加一个专门的LockRows节点。这个节点的作用非常清晰下层节点正常扫描数据LockRows 节点在下层每输出一行、向上层返回之前执行加锁动作。为什么需要独立计划节点因为加锁必须发生在“行数据返回给顶层用户”之前但又不能影响下层的扫描逻辑。把它拆成独立节点可以灵活地插在计划树的任意位置比如SELECT ... ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED实际的执行顺序是先排序再取 LIMIT 10再对这 10 行加锁。LockRows 节点被放在 Limit 之上意味着只锁最终返回的行而不是锁所有扫描到的行。优化器同时会为每个锁定目标生成PlanRowMark里面记录锁强度、waitPolicy、以及该表在计划树中的 rtable 索引。这些信息会在执行阶段被 nodeLockRows.c 使用。2.4 执行器ExecLockRows 的跳过逻辑真正的加锁动作发生在nodeLockRows.c的ExecLockRows函数。执行流程大致如下从底层节点取一行数据。根据 PlanRowMark 中的锁强度对元组执行table_tuple_lock9.5 时代叫heap_tuple_lock。根据返回结果判断成功获取锁继续向上返回这一行。行已经被当前事务锁过无需重复锁直接返回。其他事务持有锁且 waitPolicy 是 LockWaitSkip放弃这行继续取下一条。其他事务持有锁且 waitPolicy 是 LockWaitError直接抛错。重复直到底层节点输出完所有行或达到 LIMIT 上限。这里有一个细节值得注意SKIP LOCKED 的“跳过”不是把已锁行从表里删掉而是让扫描指针直接越过它。从结果集角度当前这次查询“看不到”这些行但它们依然存在于表中下一条 SQL 如果换个时间点执行可能又能看到。2.5 内核锁冲突判断为什么能检测到“别人锁了行”检测行级锁冲突的底层逻辑涉及 PostgreSQL 的 heap 元组头部 infomask。每行元组中记录了当前是否有事务在修改它不同事务通过事务号xmin/xmax和事务状态判断锁归属。当一个事务想要给某行加锁而该行已被其他事务用更强或互斥的锁锁住时内核需要判断锁持有者的事务是否还活着。决定“等待/报错/跳过”的核心地方是冲突后的返回路径。默认策略下发现冲突会调用类似XactLockTableWait的机制去等待锁持有事务结束SKIP LOCKED 则绕开等待逻辑直接返回“跳过这行”的信号给 ExecLockRows。这也是为什么 SKIP LOCKED 不会像默认 FOR UPDATE 一样把查询拖慢——它根本不进入等待队列。2.6 想亲眼验证试试 GDB 断点如果本地有可调试的 PostgreSQL 源码编译版本可以通过几个断点直观观察执行链路break ExecLockRows命中后能看到整个加锁循环。break table_tuple_lock每对一行尝试加锁都会中断。break XactLockTableWait只有真正需要等待锁持有者事务结束时才会走到这里。实际操作中你可以开两个 psql 会话第一个会话执行BEGIN; SELECT ... FOR UPDATE SKIP LOCKED;第二个会话再执行同一条带 SKIP LOCKED 的查询观察第二个会话扫描时是否跳过了已被锁的行。在 GDB 里你会看到第二个会话的 table_tuple_lock 返回冲突结果后没有进入等待函数而是直接跳到下一行处理。3. 实操搭建一个能扛住高并发的任务队列3.1 建表与基础数据准备先建一张极简的任务表CREATE TABLE job_queue ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload text NOT NULL, status text NOT NULL DEFAULT pending, assignee text, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_job_queue_status_id ON job_queue(status, id); INSERT INTO job_queue(payload) SELECT task- || g FROM generate_series(1, 10000) AS g;索引设计成 (status, id) 是有讲究的。SKIP LOCKED 的常见用法是配合WHERE statuspending ORDER BY id LIMIT 10联合索引可以直接定位到 pending 行并按 id 顺序取前 10 条避免随机扫盘。3.2 一条 SELECT 完成原子弹出每个 worker 的取任务语句可以直接写成SELECT id, payload FROM job_queue WHERE status pending ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;这条 SQL 的威力在于多个 worker 同时执行每个 worker 拿到的 10 行都是互不重叠的。worker A 锁住的 10 行worker B 的查询会自动跳过worker C 再跳过前两者锁定的行拿到下一批。整个过程的“抢任务”和“加锁防护”合并在一条语句里完成不需要额外事务协调。我实际压测过并发 20 个连接同时抢任务SKIP LOCKED 方案比“SELECT UPDATE WHERE 子句”的旧方案吞吐量高出几个量级因为旧方案要么大量重试要么在 UPDATE 时排队等待而 SKIP LOCKED 天然规避了这两个问题。3.3 取任务后立即修改状态RETURNING 或 CTE拿到任务后业务逻辑往往需要把 status 改成 running防止下一轮查询再次取到。推荐两条路第一条使用 CTE 把“选任务 改状态”合并成一条语句WITH selected AS ( SELECT id FROM job_queue WHERE status pending ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED ) UPDATE job_queue SET status running, assignee worker-1, updated_at now() WHERE id IN (SELECT id FROM selected) RETURNING id;CTE 里锁住的行在同一个命令的 UPDATE 阶段再次修改时处于同一事务内不会因为自锁冲突。这个写法把“取出任务”和“标记任务已被领取”合并为一次数据库往返减少网络开销逻辑也更内聚。第二条如果任务处理时间很短且可以接受状态延迟直接只 SELECT处理完成后再回写。但要注意SELECT 后事务不结束锁会一直持有如果应用层迟迟不提交行可能长时间被占。建议优先使用 CTE 写法立即标记状态。3.4 常见写法组合与参数调整ORDER BY id尽量保留。没有排序时LIMIT 取哪 10 行不确定并发场景下容易造成不断扫描同一数据页性能下降。LIMIT必须有。SKIP LOCKED 配合全表扫描一旦没有命中索引扫描器要把整个表翻完才知道哪些行可锁代价极大。LIMIT 可以迫使系统在取得足够行后停止。关闭 prepared transaction 的自动提交陷阱。很多连接池默认开启 autocommitSELECT ... FOR UPDATE SKIP LOCKED单独执行时事务会在语句结束瞬间提交锁立即释放根本起不到互斥作用。应用代码里必须显式BEGIN并保持事务或者在处理完任务后手动 COMMIT。加之lock_timeout作为兜底。虽然 SKIP LOCKED 不等待但同一事务里后续的 UPDATE 如果和其他事务发生冲突默认还是会等待。设置SET lock_timeout 5s能避免线程无限挂起。4. 踩坑实录与排查技巧SKIP LOCKED 并不总是银弹4.1 典型问题速查表现象可能原因解决思路CPU 居高不下没有 LIMIT 或索引不匹配导致扫描大量行检查执行计划增加 (status, id) 联合索引队列明明有空闲任务却取不到任务行被未提交事务锁定查 pg_locks 和 pg_stat_activity定位持锁会话多个 worker 拿到重复任务没在同一事务里锁行或 autocommit 开启显式 BEGINSELECT FOR UPDATE 后保持事务只取到部分任务剩余任务无人处理WHERE 条件与实际状态不一致确认 status 更新语句是否真的提交死锁偶尔出现多个事务按不同顺序锁同一批行统一 ORDER BY 顺序避免交叉加锁高并发时某些 worker 长时间没任务LIMIT 太小或索引顺序导致热尾竞争适当加大 LIMIT使用随机偏移分片4.2 性能观察锁跳过不等于零成本SKIP LOCKED 最大优势是不等待但它依然要做元组锁判断。每扫一行都要检查该行是否有锁冲突这个判断本身有 CPU 开销。因此SKIP LOCKED 不会让一条糟糕的全表扫描变快只是让并发冲突不再阻塞。Gather 并行场景下也要注意并行 worker 各自扫描、各自判断锁冲突最终 Gather 节点汇总结果。当不同 worker 扫到同一行并同时尝试加锁时仍然会有一个 worker 被冲突逻辑弹开。换句话说SKIP LOCKED 能降低并发冲突的破坏力但不会消灭底层扫描成本。性能定位时先看执行计划是不是 Index Scan再看Buffers和Rows Removed by Filter。如果一个查询扫描了 1 万行只返回 10 行说明索引和条件有问题。4.3 排查用 pg_locks 与 pg_stat_activity 还原现场当任务堆积时不要急着加机器先回答三个问题任务是否被锁被哪个事务锁的锁了多久执行下面这条 SQL 可以直接看到锁情况SELECT pid, state, wait_event_type, wait_event, left(query, 80) AS query, now() - xact_start AS xact_age FROM pg_stat_activity WHERE state idle ORDER BY xact_age DESC;再看 pg_locks 里 pending 任务行的锁来自哪里SELECT l.locktype, l.mode, l.granted, a.pid, a.application_name, a.query_start FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid l.pid WHERE l.locktype tuple ORDER BY l.granted, l.locktype;tuple 类型的锁正是行级 FOR UPDATE 的体现。如果看到 granted false说明有事务在等待别人释放行锁如果看到大量 granted true 且 pid 分布均匀说明锁分配正常瓶颈可能在业务处理速度。还有一种常见假象任务表行很多SKIP LOCKED 查询结果却总是为空。这时优先检查是否有长时间未提交的“僵尸事务”。用pg_blocking_pids()可以找到具体阻塞链极其好用。4.4 一个让我折腾很久的坑order by 与 limit 同时存在时的扫描放大9.5 刚发布那会我把 SKIP LOCKED 用到生产环境很快发现一个诡异现象并发低时查询很快并发一高单条查询居然开始扫描上百万行。原因在于 LIMIT 10 不是“扫描到 10 行就停”而是“收集到 10 行未锁行才停”。当大量行都被其他事务锁住时执行器要继续往下扫直到凑满 10 个可锁行。并发越高锁住的行越多扫描范围越大。这个问题的解法是调整数据组织方式比如把未处理任务控制在较小状态集合中或者按 id 分段让每个 worker 扫描不同的 id 区间避免所有消费者都从同一头开始扫。分段不一定需要复杂算法简单按id % 4分配消费者即可效果立竿见影。5. 从 9.5 到 18核心稳定与生态演进5.1 九年过去SKIP LOCKED 语法几乎没变PostgreSQL 9.5 发布时有大量的新特性SKIP LOCKED 是其中一个不起眼但影响深远的点。到 18 为止它的 SQL 语法形态基本保持原貌FOR UPDATE、FOR SHARE与NOWAIT/SKIP LOCKED的搭配规则也没有大改。这符合 PostgreSQL 的一贯风格核心语义一旦稳定就极少翻动避免破坏存量应用。这种稳定性本身就是一种宝贵资产。基于 9.5 写的任务队列代码到 18 依然能跑不需要改 SQL。我见过不少团队五六年前写的消费逻辑从 9.6 一路升到 16中间几乎没碰过取任务这段查询。5.2 版本演进如何间接影响 SKIP LOCKED 的应用方式虽然语法没变但周边能力一直在变这让 SKIP LOCKED 的实际玩法不断扩展。PG 10 引入声明式分区后任务表可以按时间或业务维度分区SKIP LOCKED 在分区表上的行为与普通表一致配合分区裁剪能大幅缩小扫描范围。PG 11 之后的并行查询更加成熟虽然 SKIP LOCKED 本身不能完全并行化但并行计划可以选择在 Gather 前做 LIMIT减少传回主节点的数据量。PG 13 的增量排序让ORDER BY id LIMIT N FOR UPDATE SKIP LOCKED在部分场景下能更快得到有序行因为排序可以增量输出不必等整个结果集排完。PG 14 起对内存管理、路径选择的改进不断降低高并发下锁冲突查询的延迟。PG 15/16 之后DBA 的常用排查函数和视图更完善pg_blocking_pids、pg_stat_io这类工具让定位锁问题更容易。如果你看到一些数据库博客说“PostgreSQL 18 的新特性让 SKIP LOCKED 更好用”实际上更多是这些基础设施能力的累积而不是 SKIP LOCKED 本身改头换面。5.3 生态里的经典组合SKIP LOCKED 与周边工具数据库内核之外SKIP LOCKED 已经成为很多生态工具的默认实现方式。pg_cron 在批量调度任务时不少场景借助 SKIP LOCKED 避免多个 cron job 同时执行同一批数据。各类 ETL/增量同步工具在抓取待处理行时用 SKIP LOCKED 避免重复处理。轻量级消息队列实现如用 PostgreSQL 当 MQ 的方案几乎都会把 SKIP LOCKED 作为核心取数语句。这套模式能流行核心原因是 PostgreSQL 把“取数 加锁 跳过冲突”合并成一条原子语句应用层不需要额外引入分布式锁组件。相比 Redis 的原子队列它牺牲了一点性能但换来了事务性、持久化和与业务数据同库同事务的便利。5.4 替代方案对比什么时候别用 SKIP LOCKEDSKIP LOCKED 不是唯一的选择不同场景有更合适的替代品。方案优势劣势SKIP LOCKED行级粒度、事务内嵌、无外部依赖行多时扫描成本高需要索引优化pg_try_advisory_lock不依赖表行加全局应用锁与数据本身解耦需要自行管理 keyNOWAIT冲突立刻失败语义明确无法一次跳过多个锁行Redis 队列高吞吐、低延迟与业务库事务割裂落地复杂实际项目里如果一个任务队列每天只有几千条任务用最简单的一张表加 SKIP LOCKED 足够了。只有当吞吐量到达每秒数万、且对延迟极其敏感时才值得引入独立消息中间件。落笔前的最后提醒SKIP LOCKED 是个“小而美”的内核特性但这几年围绕它有太多误解。有人把它当成分布式锁有人以为它可以解决所有并发问题还有人一遇到任务堆积就怪数据库。我在实际项目里体会最深的是这个特性只有在“扫描可控 索引合理 事务周期短”三个前提同时成立时才能真正发挥威力。三者缺一要么性能崩掉要么任务“看上去没人领实际上被僵尸事务死死拖住”。遇到锁相关的问题别急着改业务代码先把 pg_locks 翻一遍看清谁在持锁、持了多久、走了哪条索引路径再决定要不要换一种方案。数据和锁都摆在那里把现场还原出来问题往往就迎刃而解。