ARTICLE DETAIL

资讯详情

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

PostgreSQL SKIP LOCKED并发控制:从行锁竞争到任务队列优化

PostgreSQL SKIP LOCKED并发控制:从行锁竞争到任务队列优化 在 9.5 之前如果你维护一张任务表多个 worker 并发地SELECT符合条件的记录再UPDATE状态最常见的做法是抢一把全局锁或者用FOR UPDATE NOWAIT拼手气。手气不好就报错回滚整个批次白跑更痛苦的是两个 worker 同时命中同一行互相等锁事务堆积CPU 打满业务却原地踏步。我当年在线上为这个问题加了各种补丁按时间片分片、随机偏移、CAS 重试都是治标不治本。PostgreSQL 9.5 引入的SELECT ... FOR UPDATE SKIP LOCKED改变了这个局面。它让多个进程可以安全地“各取所需”遇到已经被别人锁掉的行直接跳过而不是阻塞或者报错。这个特性最初不起眼但做任务队列、批处理分发、竞态抢单的人都明白这是从“加锁靠猜”到“加锁靠规则”的分水岭。这篇文章我会从使用场景讲起再带大家钻一遍从 parser 到 heapam 的源码实现最后聊聊这个特性在 9.5 之后几个大版本里经历了什么演进。如果你是 DBA、后端开发或者对 PostgreSQL 内核机制有兴趣这篇文章应该能给你一个比较完整的视角。1. 从抢锁困境谈起为什么 9.5 之前处理并发消费这么疼1.1 没有 SKIP LOCKED 的日子队列表的“三座大山”先还原一个最典型的场景一个任务表task里面有几千行待处理记录状态是pending。你部署了 10 个 worker 进程每个 worker 周期性执行SELECT id, payload FROM task WHERE status pending ORDER BY created_at LIMIT 100 FOR UPDATE;如果不加FOR UPDATE两个 worker 会拿到同一批id后面执行UPDATE时互相覆盖或者因为版本号不一致导致更新丢失。加了FOR UPDATE之后问题变了worker A 锁住了前 100 行worker B 的SELECT ... FOR UPDATE必须等 A 提交或回滚才能继续处理。极端情况下10 个 worker 全部堵在同一批数据上业务吞吐量直接从“10 路并行”退化成“串行排队”。这时候很多人会想到NOWAIT锁被占用就立刻报错我可以让 worker 捕获异常后重试。SELECT id, payload FROM task WHERE status pending ORDER BY created_at LIMIT 100 FOR UPDATE NOWAIT;但NOWAIT的语义是“碰到锁就直接失败”不是“碰到锁就跳过这一行”。一个正在处理大事务的 worker 可能锁住了很多行其他 worker 碰到第一行就整体报错写重试逻辑时还得小心别把 CPU 烧了。而且NOWAIT报错会终止整个查询哪怕剩下 99 行都是空闲的你也没法继续。另一个常见但很蠢的方案是按id取模分片SELECT ... FROM task WHERE status pending AND id % 10 {worker_id}这等于把并行度预先焊死。某个分片没任务时worker 只能空转任务集中在一个分片时其他分片又忙不过来。更麻烦的是节点扩容缩容后取模规则一变历史数据归属全乱。我见过不少团队在这条路上走了很久最后发现复杂度比数据库锁本身还高。1.2 SKIP LOCKED 带来的语义转变行级“跳过”而不是“等待或失败”SKIP LOCKED的逻辑可以用一句话概括只取没有被别人锁住的行遇到锁直接跳过不等也不报错。它把“加锁”这个动作变成了一个可选消费的筛选条件语法上跟FOR UPDATE一起出现SELECT id, payload FROM task WHERE status pending ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED;这个查询在 9.5 上执行时会顺序扫到pending行凡是发现行级锁被其他事务持有就跳过能锁住的就锁住并返回。最终每个 worker 拿到的 100 行大概率互不重叠任务吞吐量可以随 worker 数量接近线性扩展。这种语义对应用层是非常友好的你不需要额外表、不需要分布式协调只需要一条 SQL 就能实现“谁拿到谁干”。我做任务调度系统时把原来的全局锁方案删了个干净只留这一句话。后来在压测环境里开 50 个并发 worker 抢同一张表每秒处理量比之前翻了好几十倍而且没有任何锁等待。2. 使用层面的核心细节锁定模式、语法与常见误用2.1 四种锁定模式到底选哪个SKIP LOCKED并不是FOR UPDATE专属PostgreSQL 9.5 文档里它可以和四种行锁组合。这四种子句的强度从高到低是子句锁强度语义FOR UPDATE最强排他禁止其他事务对该行执行 UPDATE、DELETE、SELECT FOR UPDATE、SELECT FOR NO KEY UPDATE、SELECT FOR SHARE、SELECT FOR KEY SHAREFOR NO KEY UPDATE排他但允许 KEY SHARE禁止 UPDATE/DELETE也禁止 SELECT FOR UPDATE/SHARE但允许其他事务加 KEY SHARE 锁读取主键FOR SHARE共享锁禁止其他事务做 UPDATE/DELETE/NO KEY UPDATE/FOR UPDATE但允许其他事务也加 SHARE 或 KEY SHAREFOR KEY SHARE最弱共享只阻止 DELETE 和主键更新不阻止其他 UPDATE日常任务队列里FOR UPDATE几乎总是正确选择因为你要保证拿到任务之后别人不能动它。但如果你只是想协同读取一批数据比如多个只读服务各自消费一部分记录做分析又不想让对方把行删掉那FOR SHARE SKIP LOCKED也很有用。它能让多个只读事务并行拿锁互不阻塞。只更新非主键字段但不希望互相干扰的场景可以用FOR NO KEY UPDATE SKIP LOCKED锁的开销比FOR UPDATE略小同时避免别人拿FOR SHARE读锁跟你冲突。需要提醒的是FOR KEY SHARE在并发环境下和UPDATE的冲突面很小所以它很少跟SKIP LOCKED组合使用。我见过有人把主键更新场景误用成FOR KEY SHARE SKIP LOCKED结果完全没有起到“防止别人改数据”的作用。锁强度的选择要根据业务对数据一致性的要求来定不是越强越好也不是越弱越省。2.2 语法特性与边界情况LIMIT、ORDER BY、子查询和 CTE实际使用中SKIP LOCKED通常配合LIMIT控制单批次处理量。这里有个细节计划器可以在WHERE条件里下推LIMIT但FOR UPDATE会带来LockRows节点所以LIMIT 100裁剪的是“已经成功加锁”的行。假设表里可用的行只有 80 行那查询返回 80 行而不是等满 100 行这没问题。如果所有行都被锁住查询会返回空结果集应用层要处理好“本批次无任务”的分支。ORDER BY和SKIP LOCKED一起用时跳过的是“满足排序条件但锁不可用”的行返回集合在业务语义上是一个合规的“按条件筛选并加锁”的结果。不要假设返回顺序在并发波动下仍然稳定因为每次跳过哪些行取决于当时谁持有锁。对于任务队列一般不需要关心顺序如果需要严格按优先级处理建议把优先级写进WHERE里比如WHERE priority 100不要让排序结果承担太多并发语义。SKIP LOCKED不直接支持用于UPDATE ... WHERE或DELETE ... WHERE也就是说你不能写UPDATE task SET status doing WHERE status pending SKIP LOCKED;这是语法层面的限制。常用的绕过方式是把它包到子查询里UPDATE task SET status doing WHERE id IN ( SELECT id FROM task WHERE status pending ORDER BY created_at LIMIT 5 FOR UPDATE SKIP LOCKED ) RETURNING *;这里要注意外层UPDATE会重新扫描并加锁可能存在子查询锁过的行被更新后又被另一条路径触碰的窗口但对大多数队列场景来说已经不是问题了。使用 CTE 配合RETURNING还可以进一步把任务 ID 和数据内容一并返回方便 worker 拿结果后直接处理。2.3 与 NOWAIT 的对比以及一个常见的执行计划误区NOWAIT和SKIP LOCKED都是“遇到锁不等待”但失败模型完全不同。我一个一个说清楚NOWAIT碰到锁时整个查询抛出could not obtain lock on row in relation错误事务进入 aborted 状态这一批任务全部作废。SKIP LOCKED碰到锁时把这一行剔除出结果集继续检查下一行整个查询照常成功返回。所以如果你想要“拿不到就算了试下一批”NOWAIT不是好选择它会把一个行的冲突放大成整个查询的失败。SKIP LOCKED才是为“部分成功”设计的语义。另一个误区是有人以为FOR UPDATE SKIP LOCKED已经把锁粒度降到了“无锁”可以无限并发。实际上它仍然会对选中的行加锁只是不再是阻塞式等待。行锁本身的分配、维护、并发检测依然消耗资源而且如果每个 worker 都一次性锁几千行锁管理器依然可能成为瓶颈。我做过一组简单的压测单事务SELECT ... FOR UPDATE SKIP LOCKED LIMIT 5000时锁管理等开销明显高于LIMIT 100。批次大小还是要根据行宽和事务时长做实验不要盲目贪大。3. 源码链路一从词法语法解析到执行计划插桩3.1 parser 与 gram.ySKIP LOCKED 如何进入语法树9.5 版本对SKIP LOCKED的支持是从语法层开始的。在src/backend/parser/gram.y中LockingClause的规则被扩展允许在FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE后面追加NOWAIT或SKIP LOCKED。语法动作会设置locking clause里的waitPolicy字段这个字段后来贯穿整个执行链路。SKIP LOCKED的语法成品在内部被记录为一个SelectStmt-lockingClause项其中forUpdate表示锁模式waitPolicy表示等待策略。解析到FOR UPDATE SKIP LOCKED时waitPolicy会被设置成LockWaitSkipNOWAIT对应LockWaitNoWait不加任何参数对应默认的LockWaitBlock也就是传统的阻塞等待。这里有一个值得体会的设计SKIP LOCKED不是一个新的锁类型而是对“等待策略”的一种扩展。PostgreSQL 内核里早就有了LockWaitBlock/LockWaitNoWait这两个等待策略9.5 所做的只是新增一个LockWaitSkip然后让执行器和存储引擎在处理行锁时理解“跳过”这个动作。这种扩展方式对既有代码结构破坏最小也解释了为什么SKIP LOCKED能和所有 FOR 锁模式无缝组合。3.2 计划器LockRows 节点是如何被安排的SELECT ... FOR UPDATE在执行计划里会体现为一个LockRows节点。如果你跑一下EXPLAIN大概会看到Limit - LockRows - Seq Scan on task Filter: (status pending)LockRows节点的作用是在底层扫描返回每一行时对行执行真正的锁获取。计划器在决定是否插入LockRows时会检查查询的rowMarks列表。这个列表来自语法分析阶段记录了所有需要加锁的 RTERange Table Entry。如果列表非空就创建一个LockRows计划节点并把它放在需要锁定的表扫描之上、可能存在的LIMIT/GROUP BY等节点的合适位置。关于LIMIT与LockRows的先后顺序我实际看执行计划时发现Limit通常在LockRows外层。也就是说底层扫描出来的行会先经过LockRows加锁判断成功加锁的行再被Limit截取。这样LIMIT 100拿到的确实是 100 个“已经锁成功”的行而不会出现明明锁了 500 行最后只要 100 行、另外 400 行被白白锁住等待提交的情况。另外要注意并行查询下LockRows会限制并行度。因为LockRows需要按顺序对每行加锁并且加锁结果要依赖事务快照计划器一般不会把它下推到并行 worker 中。9.5 时代没有并行查询这个问题不明显后面版本里如果你在FOR UPDATE查询想强制并行往往会看到计划器放弃并行这是行锁语义导致的不是优化器偷懒。3.3 执行器nodeLockRows.c 的循环与跳过逻辑LockRows节点的执行代码在src/backend/executor/nodeLockRows.c。核心函数是ExecLockRows它有一个外层for (;;)循环每次从子计划取一个 tuple slot然后调用表访问接口的tuple_lock回调函数。在 9.5 源码里这个调用的形态大致是把tuple、ctid、mode、waitPolicy都传入并获得一个TM_Result类型的返回码。这个返回码是整个实现的关键TM_Ok行锁获取成功当前行可以交给上层节点。TM_Updated行在可见性判断后已经被更新需要重新取最新版本执行器会触发 EPQEvalPlanQual重新检查。TM_BeingModified另一事务正在修改该行。TM_WouldBlock如果 waitPolicy 是LockWaitNoWait而且锁冲突返回这个结果。TM_Skipped如果 waitPolicy 是LockWaitSkip而且锁冲突返回这个结果。ExecLockRows拿到TM_Skipped后的处理非常直接丢弃这个 tuple继续从子计划取下一行。对于TM_WouldBlock执行器则会向客户端抛异常。这个设计说明了一个关键点SKIP LOCKED的“跳过动作”发生在执行器的最外层它并没有更改扫描节点对“哪些行可见”的判断也没有改变索引扫描的索引条目过滤逻辑。它只是在扫描结果流上做了一层“加锁过滤器”。所有被跳过的行仍然经历了索引匹配、可见性检查、堆页面访问等常规流程只是最终没有被加锁并继续向上返回。4. 源码链路二heapam.c 行锁实现与 TM_Skipped 的诞生4.1 heap_lock_tuple 的整体流程先判可见性再谈锁冲突真正执行行级锁获取的代码在存储引擎层9.5 版本对应src/backend/access/heap/heapam.c里的heap_lock_tuple。这个函数是整个行锁机制的心脏SELECT FOR UPDATE、SELECT FOR SHARE包括UPDATE/DELETE内部的行锁最终都会汇聚到这里。函数开头会先固定表的内存页面pin buffer然后基于tuple-t_self找到对应的 HeapTuple。接下来会读取 tuple 头部的t_infomask和t_xmax判断当前行是否已经由另一个事务修改或加锁。这里的核心问题是检查“锁冲突矩阵”当前事务要加的锁模式和正在持有锁的事务的锁模式是否冲突。如果冲突且持有锁的事务是活跃的heap_lock_tuple就会进入等待策略分支。这里有三个选择默认调用锁等待机制进入睡眠直到锁被释放。LockWaitNoWait立即返回TM_WouldBlock。LockWaitSkip立即返回TM_Skipped。很多人第一次读源码会困惑为什么不直接跳过还要先判断一堆xmax因为 PostgreSQL 的行锁不是显式锁表而是通过事务 ID 和 tuple 头部的标志位组合实现的。xmax记录了最近一次修改或锁定该行的事务 ID。如果xmax对应的事务已经提交那这个行锁实际已经释放只有xmax对应的事务仍然活跃而且锁模式冲突才需要等待或跳过。这个判断本身就要读取并分析 tuple 元数据没法避免。4.2 逐段拆解跳过条件到底在哪个分支触发我基于 9.5 的heap_lock_tuple逻辑做一个简化还原理解这个流程对排查并发问题特别有帮助/* * 伪代码展示 heap_lock_tuple 中等待策略的核心分支 */ if (infomask HEAP_XMAX_IS_MULTI) { /* 多事务位需要遍历 MultiXact 成员判断锁冲突 */ ... } else if (TransactionIdIsInProgress(HeapTupleHeaderGetRawXmax(tuple))) { /* 持有 xmax 的事务仍在运行 */ if (tupleLockModeConflicts(mode, tuple)) { switch (wait_policy) { case LockWaitBlock: /* 阻塞等待 */ ... break; case LockWaitNoWait: return TM_WouldBlock; case LockWaitSkip: return TM_Skipped; } } }实际上代码里对单事务和 MultiXact多事务的处理会更复杂因为两个事务可能一个持有FOR KEY SHARE、一个持有FOR NO KEY UPDATE共同组成了对当前行的多重锁状态。9.5 对 MultiXact 的处理已经从早期的“简单叠加”进化到一套相对完整的成员冲突判断逻辑。SKIP LOCKED在这里不需要额外区别锁冲突判定完成后如果结果是不允许获取等待策略直接决定是阻塞、报错还是跳过。这也是我为什么会说理解SKIP LOCKED的关键不是记住它的语法而是理解TM_Skipped是一套等待策略的产物。它复用了几十年来 PostgreSQL 在并发控制上的所有基础设施而不是凭空造了一个新锁。4.3 EPQ 与 HOT 链对 SKIP LOCKED 的影响当你执行SELECT ... FOR UPDATE SKIP LOCKED时虽然锁“跳过”了但行数据本身还是要经过可见性和版本链判断的。PostgreSQL 的堆表使用 HOTHeap Only Tuple技术减少索引更新开销更新同一行时可能产生新的物理 tuple形成版本链。heap_lock_tuple在加锁过程中需要沿着 HOT 链找到最新的 tuple 版本再判断能否加锁。如果最新版本被删除了heap_lock_tuple会返回TM_DeletedExecLockRows会丢弃该行继续下一行。这一点在并发场景下很常见你扫到一行准备加锁时另一个事务刚把它删了。没有SKIP LOCKED时这种行为会让人很头疼因为可能触发snapshot too old或者复杂的重试有SKIP LOCKED后执行器只是安静地跳过。EPQEvalPlanQual机制也值得一提。当底层扫描得到的是一个旧的快照版本而执行FOR UPDATE加锁时发现该行已经被另一事务更新PostgreSQL 不会简单放弃而是会重新执行一次“基于新版本的元组检查”确保加锁结果仍然满足查询条件。SKIP LOCKED不会绕过 EPQ如果行被更新了执行器通常要先重试加锁新版本只有在新版本也加锁失败时才会考虑等待/跳过。这个细节对行为的影响是并发更新频繁的行可能不会立即被跳过而是先进入一轮 EPQ 重查重查后再决定是否跳过。4.4 行锁冲突矩阵与锁强度验证关于锁强度的代码依据heapam.c里定义了一张LockConflicts表或者更准确地说在锁管理器机制里每种锁模式都有一个冲突规则集。这个规则集决定了“当前事务想加 X 锁站在对面的事务已经加了 Y 锁能不能共存”。在源码层面LockTupleMode枚举包括LockTupleKeyShare、LockTupleShare、LockTupleNoKeyExclusive、LockTupleExclusive。对应关系是SQL 子句LockTupleModeFOR KEY SHARELockTupleKeyShareFOR SHARELockTupleShareFOR NO KEY UPDATELockTupleNoKeyExclusiveFOR UPDATE / UPDATE / DELETELockTupleExclusive判断冲突时会逐位检查t_infomask里的标志位比如HEAP_XMAX_IS_KEY_SHARE、HEAP_XMAX_IS_SHARE、HEAP_XMAX_IS_NOKEY_UPDATE、HEAP_XMAX_IS_EXCL_LOCK。这些标志位本质上是一组压缩的锁状态位图。SKIP LOCKED不会改变这张冲突矩阵它只改变“冲突出现后的行为”。这又是一个“核心语义稳定、外围策略可插拔”的好例子。5. 从 9.5 到 18演进路线中的变与不变5.1 9.5 - 10SKIP LOCKED 原版特性与早期的边界问题9.5 引入SKIP LOCKED后第一批使用者的反馈集中在两个方向上一是它确实解决了任务队列的并发分配难题二是它在某些复杂查询计划下会被优化器绕开或表现反直觉。比如当查询包含UNION或UNION ALL时LockRows节点所在的位置可能导致不同分支的行锁行为不一致。这些边界问题在后面的版本里通过优化器改进逐步缓解。9.6 版本虽然没有对SKIP LOCKED做很大的语法改动但并行顺序扫描等并行查询基础设施开始落地。这对SKIP LOCKED的间接影响是在高并发环境中并行 worker 的存在让“哪一行先被锁”变得更加不确定应用层对返回顺序的依赖需要进一步削弱。DBA 看到FOR UPDATE查询计划从并行退化为串行时也不用惊讶这是行锁语义对并行安全的必要约束。5.2 11 - 13分区、增量排序等能力如何与行锁协作PostgreSQL 10 引入声明式分区11 大幅改进了分区裁剪12 又对分区做了一轮深入优化。SKIP LOCKED和分区的关系非常微妙当你对分区表执行SELECT ... FOR UPDATE SKIP LOCKED时计划器需要先裁剪出符合条件的子表再对子表执行扫描和加锁。分区裁剪做得越好被锁定的无用分区就越少。如果裁剪得不彻底worker 可能在多个空分区上白费功夫甚至产生多余的锁请求。从 11 开始这个配合越来越顺畅我在分区表上跑SKIP LOCKED的体验已经和普通表没什么区别。13 版本引入了增量排序Incremental Sort和FETCH FIRST WITH TIES。SKIP LOCKED本身没有结构性变化但增量排序让ORDER BY加LIMIT加FOR UPDATE SKIP LOCKED这种组合的计划质量明显提升。以前优化器可能为了 ORDER BY 做完整排序再排队加锁现在可以在部分有序数据上边排序边加锁减少了排序内存和等待时间。这个演进说明一个底层特性再好也依赖上层优化器不断打磨才能释放全部价值。5.3 14 - 16并发写场景下的韧性与 MERGE 时代的取舍14、15、16 这几个版本更关注写放大、索引维护、逻辑复制等方向SKIP LOCKED在语法和语义上基本稳定。15 版本引入的MERGE语句是一个中级里程碑它允许你在一条 SQL 里做 INSERT、UPDATE、DELETE 的组合操作。但需要注意的是MERGE并不像普通SELECT FOR UPDATE那样容易配合SKIP LOCKED。如果你想在MERGE里做“跳过已锁行”的语义通常要先把目标行用子查询锁出来再交给MERGE处理。这是个绕路操作并且我没有在官方文档中看到MERGE ... SKIP LOCKED的直接支持。16 版本里比较值得关注的是逻辑复制和回放性能大幅提升以及一些 vacuum 相关的改进。虽然这些跟SKIP LOCKED没有直接关系但逻辑复制在高并发写入场景下会影响行锁的持有时间复制延迟时主库上的行锁可能被更长的事务持有SKIP LOCKED因此会更频繁地跳过行。应用层在处理任务队列时要意识到锁竞争不仅仅来自你自己的 worker还可能来自复制、触发器、外键检查等所有参与事务的因素。5.4 17 - 18从版本角度回看SKIP LOCKED 到底变了没到 18 这个时间点SKIP LOCKED的核心语义和 9.5 几乎完全一致。这在数据库领域不是坏事反而说明设计当初就把事情做对了。外部的演进更多体现在优化器如何计划含LockRows的查询、存储引擎如何更高效地处理 tuple 可见性、分区裁剪如何减少无效加锁以及各类参数如max_parallel_workers、lock_timeout与它的互动方式。在实际使用中我感受到的变化是从 13 之后FOR UPDATE SKIP LOCKED配合ORDER BY和LIMIT的查询计划更稳定了很少出现因为排序策略变化导致加锁顺序剧烈波动的情况从 15 之后处理HOT链和并发更新时的性能抖动也小了很多。这些收益都来自相邻模块的进步而不是SKIP LOCKED本身被改写。如果你正在做版本选型我的建议是至少用 13 以上版本跑这种类型的高并发队列查询因为增量排序和分区裁剪的改进直接关系到任务分配效率。9.5 刚发布时那个原始版本可以用但经过几个大版本打磨后同样的 SQL 在计划质量和稳定性上都有了明显提升。6. 踩坑总结与个人落地建议用SKIP LOCKED这几年我踩过几个值得说一说的坑。第一个坑是事务长度失控SELECT ... FOR UPDATE SKIP LOCKED只负责帮你“选行并加锁”不负责约束你后续处理多久。如果你在拿到 100 行后执行了一个很长时间的存储过程或者远程调用持有行锁的时间会被拉得很长其他 worker 的SKIP LOCKED会不断跳过这些行表现为“任务积压但没人处理”。我最后在应用层给每批任务加了超时机制处理超过 30 秒就强制回滚并重新放回队列。第二个坑是死锁风险没有消失。SKIP LOCKED只是避免了锁等待但如果 A 先锁了任务 1 再请求任务 2B 先锁了任务 2 再请求任务 1两个事务还是可能死锁。PostgreSQL 会检测死锁并终止其中一个事务。为了避免这种尴尬我在并发 worker 里统一按task_id排序后再加锁锁顺序一致后死锁概率基本归零。第三个坑是关于可见性和索引的。SKIP LOCKED不会把“已被锁”作为索引条件所以它不能直接走索引跳过锁行。索引扫描仍然会扫到这些行然后由执行层过滤掉。如果你的表里大量行长期被锁扫描开销不会降低。一个讨巧的做法是配合状态字段建立部分索引让被锁处理中的行先变更状态减少后续扫描命中范围。最后我特别想强调一点不要把这个特性神化为万能锁调度器。它解决的是“并发消费同一张表”这一类问题而不是所有并发控制问题。如果你有跨行、跨表的事务一致性要求该用SERIALIZABLE或应用层分布式锁还是得用。但如果你只是想让多个 worker 高效地瓜分一堆待处理记录SKIP LOCKED就是 PostgreSQL 提供的最原生的答案。用好它需要理解锁模式、事务边界和执行计划但也正是因为这些底层机制的存在一条简单的 SQL 才能在高并发下保持正确性和性能。根据我个人的经验最近几年新接触 PostgreSQL 的团队反而经常忽略这个 9.5 就有的老特性跑去引入额外的队列中间件。如果你们的并发量还没有到单表几百万行以上的量级先试试这个原生方案你会发现它远比想象中能打。
返回列表