ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostgreSQL SELECT FOR UPDATE SKIP LOCKED 源码解析与演进

PostgreSQL SELECT FOR UPDATE SKIP LOCKED 源码解析与演进 做后端的人应该都遇到过这种场景多个 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 UPDATESELECT 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 LOCKEDPostgreSQL 很早就有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 SHAREFOR UPDATE、FOR NO KEY UPDATE、DELETEFOR SHAREFOR UPDATE、FOR NO KEY UPDATE、DELETEFOR NO KEY UPDATEFOR UPDATE、FOR SHARE、FOR NO KEY UPDATEFOR 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_tuple3.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 LOCKEDanalyze 阶段还会做下推处理把锁语义传递到实际的基础表上。从这里开始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执行器不能直接忽略这个新版本而是要触发 EvalPlanQualEPQ机制回到索引重新抓取这个元组的最新版本判断它对当前快照是否可见。如果可见就基于新版本继续加锁。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 12Table 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_tuple9.6并行查询引入FOR UPDATE 类语句无法并行化LockRows 由 leader 执行10声明式分区分区表上的行锁下推行为逐步完善11存储过程、分区索引增强SKIP LOCKED 语法和语义无变化12Table AM 抽象HTSU_Result 改为 TM_Resultheap_lock_tuple 改为 table_lock_tuple13行锁相关维护性改进MultiXact 与 VACUUM 交互持续调整14wait event 体系增强行锁等待可观测性提升15MERGE 引入行锁接口增加列集跟踪能力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 加锁成功把行交给 LimitLimit 满足一行就终止上层不会再继续扫描。所以虽然用了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可以精确定位物理行少一次索引回表看起来更高效。但如果表上有并发 UPDATEctid在 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今天还能跑而且跑得更好。
返回列表