
先说一个真实处理过的场景。某周五下午我正要下班项目群的监控就弹了告警定时报表数据异常。连上去看日志是一条联表查询订单表关联物流表统计“发货后 7 天内有无物流轨迹”。日志里明确写着有几百条订单明明已经发了货物流记录怎么都查不出来。业务方第一反应是数据同步丢了让我赶紧补数据。我让人先别动单独跑了同样的 SQL结果一条不少。问题就出在“那个时刻”并发事务和锁的相互影响上。这就是 PostgreSQL 联表加锁里藏的连环坑今天把这套东西彻底盘一遍。1. 一次“藏”在并发下的查询异常1.1 事故现场还原那套定时任务其实很简单每隔 10 分钟从订单主表orders拉一批待统计订单再去关联物流表shipments查最新的物流节点时间最后把结果写进报表中间表。深夜又有个批处理会批量更新订单的状态字段同时物流侧也在回写轨迹。我查了当时的会话日志发现那条报表 SQL 的运行时间刚好落在另一个事务批量更新订单状态、并且尚未提交的时间窗口里。一个 UPDATE 批量跑了几十万行把订单表相关行锁住了。报表查询本身走的是普通 SELECT按理说 MVCC 下不会被阻塞但它是先扫订单表、再联物流表。问题出在它读到的那批订单行有相当一部分在事务快照建立之后被另一个事务更新、提交了而物流表的读取又发生在这批更新提交之后。两边数据来自不同的时间点JOIN 结果自然就“对不上”了。换句话说单条 SQL 没问题凌晨那一刻并发一叠加结果就变得让人摸不着头脑。这不是业务代码乱写是 PostgreSQL 并发模型与隔离级别之间的经典摩擦。1.2 排查时最容易踩的三个误区我处理类似问题多了发现大家一上来就喜欢干三件事而且全是错的。第一个误区先去翻业务代码而不是先看锁。代码里一句LEFT JOIN写得再对也架不住并发事务在背后改数据。正确顺序应该是先看pg_stat_activity、pg_locks确认有没有长事务、有没有锁等待再回来审 SQL。第二个误区拿默认隔离级别当唯一标准。PostgreSQL 默认是READ COMMITTED在这个级别下每一条语句各自拿一个快照。也就是说同一条复合 SQL 里的多个表扫描可能读到“不同时间点”的数据。这在联表场景里就是噩梦。第三个误区临时把事务隔离级别调高就完事。调到REPEATABLE READ确实让整个事务共享同一个快照但也引入了新的问题快照越旧VACUUM越没法清理旧版本表膨胀查询越来越慢最后把整个库拖住。2. 先把底层机制盘明白否则全是瞎猜2.1 MVCC 的“读不阻塞写”只是表面现象PostgreSQL 用 MVCC多版本并发控制实现读写隔离核心思想很朴素每一行可能同时存在多个版本不同事务看到不同版本。读操作不会去阻塞写操作写操作也不会阻塞读操作这个设计比加读锁的系统高到不知道哪里去。但它有一个前提容易被忽略读操作看到的数据版本取决于快照的建立时机。在READ COMMITTED下每条 SQL 开头都会建立一个新的快照。联表查询这种需要扫多张表的复杂 SQL第一条语句执行期间会连续读取多张表如果其他事务在这中间提交了数据后面的表可能就会读到新版本而前面的表还停留在旧快照。结果就是你拿“昨天”的订单去联“今天”的物流记录自然可能匹配不上。打个比方。你进超市拍了一张货架照片然后又去另一个货架拍了第二张照片中间店员补了新货、移走了老货。你把两张照片拼在一起看商品与价格牌对不齐——这就是联表查询在READ COMMITTED下可能发生的事。2.2 表级锁与行级锁MVCC 虽然解决了普通 SELECT 的读写互斥但 PostgreSQL 仍然需要锁来保护结构性操作和写操作之间的互斥。表级锁从弱到强主要有 8 种锁模式典型操作ACCESS SHARE普通 SELECTROW SHARESELECT FOR SHARE / FOR UPDATEROW EXCLUSIVEINSERT、UPDATE、DELETESHARE UPDATE EXCLUSIVEVACUUM、CREATE INDEX CONCURRENTLYSHARECREATE INDEX非并发SHARE ROW EXCLUSIVE部分 ALTER TABLEEXCLUSIVEREFRESH MATERIALIZED VIEW 等ACCESS EXCLUSIVEDROP、TRUNCATE、LOCK TABLE 默认模式行级锁则分为四类FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE。普通 UPDATE 会拿FOR NO KEY UPDATEDELETE 会拿FOR UPDATE外键检查时父表被引用的行会被子表锁拿FOR KEY SHARE。注意一点ROW EXCLUSIVE虽然名字里有 EXCLUSIVE但它和另一个事务的ROW EXCLUSIVE并不冲突。也就是说两个事务完全可以同时在不同行上做 UPDATE这才是并发写入的基础。容易搞混我见过不少同学以为表上有个写锁数据库就锁死了其实不是。2.3 锁冲突不是“你死我活”而是“分情况”理解锁冲突矩阵能帮你快速判断两条 SQL 卡住是正常还是异常。我整理一个简化版只看常用到的当前持有锁哪些线程来会被卡住ACCESS SHAREACCESS EXCLUSIVEROW SHAREEXCLUSIVE、ACCESS EXCLUSIVEROW EXCLUSIVESHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVESHAREROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVEACCESS EXCLUSIVE所有这张表不用死记。核心结论普通的增删改只会互相挤兑“结构变更”类的锁而结构变更DDL会像一堵墙一样堵住所有读写。一旦你发现某条联表查询卡了很久优先怀疑是不是有ALTER TABLE、TRUNCATE之类的操作在背后搞事其次是长事务持有的行锁。3. 联表加锁里真正的四个隐藏陷阱3.1 陷阱一外键约束让你“查一表、锁两表”业务上最常见的联表场景都是做主表和子表之间的关联。如果子表上建了外键PostgreSQL 在写主表时会偷偷去检查子表这个过程就可能动手加锁。我处理过一个典型卡顿有个主数据表customers客户资料存在里面订单表orders通过customer_id外键引用它。某天报表联查orders与customers突然一堆会话聚集在customers表上等待。业务方说只是有人更新了一个客户的手机号怎么把报表查询全堵住了。问题出在这条 SQLUPDATE customers SET phone 1XX WHERE id 123456;这条语句要更新主键列吗不需要。是不是 DELETE也不是。为什么卡住查询因为 PostgreSQL 的即时外键约束检查需要确保没有子表的未提交事务正在插入或更新引用这一行的数据。它会在子表上加锁这个锁不是整张表的ACCESS EXCLUSIVE而是针对相关子表行的行锁以及子表上的并发检查机制。本质上一条看起来只动单表的 UPDATE可能把子表也一起锁进一场混战。反过来说子表里的INSERT在插入一条新订单时会对父表customers中对应的那一行加上FOR KEY SHARE锁。这个锁与UPDATE主键、DELETE是冲突的但和普通 SELECT 不冲突所以平时感觉不到可一旦主表在做主键更新的长事务所有子表的插入都会被按在等待队列里。实操建议很简单高并发写入子表前先检查外键列有没有索引。没有索引外键检查就得走全表扫描锁的粒度和持有时间都会指数级上升。这也是很多 PG 性能事故的隐形根源。3.2 陷阱二SELECT FOR UPDATE 联表时锁的行比你想象的多做任务调度或者订单处理时大家喜欢这样写SELECT o.id, s.id FROM orders o JOIN shipments s ON s.order_id o.id WHERE o.status pending FOR UPDATE;如果你不加OF子句PostgreSQL 会默认把查询涉及到的所有表的行都锁上。意思是订单行锁了物流行也锁了。很多开发者只防住了主表的并发没想到子表也被拖下水然后两个不同的调度任务互相卡死。看这个场景。任务 A 执行上面这条 SQL拿了一批订单和它们的物流单任务 B 也在跑相似的逻辑但它是从物流表反查订单表。两边都要锁同一批订单行和物流行顺序还不一致死锁就那么来了。日志里就会出现经典的ERROR: deadlock detected如果你只锁主表就不该让子表被牵扯进来。写SELECT ... FOR UPDATE OF o只锁orders表别让关联的shipments一起卷进锁竞争。另一个细节FOR UPDATE的加锁顺序受执行计划影响。如果查询先走shipments的索引再回订单表那它可能是先锁了物流行、再锁订单行而另一个事务可能正好相反。这种交叉等待就是死锁的温床。排查时别只盯着 SQL 本身先用EXPLAIN看访问顺序再决定加锁顺序能不能统一。3.3 陷阱三执行计划一改变锁范围跟着“失控”这个坑隐蔽到什么程度SQL 写得一模一样执行计划却被统计信息悄悄换了锁的范围立刻变天。我先说现象。某个查询原本走订单表的idx_status索引只统计“待发货”的几千行WHERE status pending定位精准锁也就这几千行。某天数据量大增优化器觉得索引回表成本太高改走全表扫描。这一下就不是锁几千行的问题了几乎所有符合条件的行甚至扫描过程中访问过的行都可能被纳入锁范围。并发写入瞬间全被堵住数据库负载飙升。解决方向也不是骂优化器而是给关键查询固定执行计划比如pg_hint_plan扩展加 hint优化查询结构让WHERE条件里的表达式和索引匹配避免隐式类型转换导致索引失效批量更新的场景尽量拆小事务分批做一次控制几百行锁自然就小。我还遇到过更冤的某同事在联表查询的ON条件里写了o.id::text s.order_id::text因为字段类型不一致。这会让索引完全失效执行计划变成嵌套循环 全表扫描锁的范围从几百行变成几十万行。改掉类型之后性能原地起飞。联表查询的锁范围问题很多时候不是锁机制的问题是执行计划和索引的问题。3.4 陷阱四隔离级别与快照让结果“凭空消失”回到开头那个事故。READ COMMITTED下同一句联表 SQL 里的多个表扫描各自拿一个快照。这是一个被严重低估的隐蔽点。放大来看事务开始报表 SQL 执行扫描订单表拿快照 S1此时订单 X 的状态是“已发货”。另一个事务同时提交了物流的插入订单 X 的物流记录在快照时间之后才可见。报表 SQL 继续扫物流表拿快照 S2S2 里能看到新物流记录。JOIN 时订单 X 在快照 S1 里是“已发货”物流记录在 S2 里也能查到按说能匹配上。但还有另一种反向序列报表 SQL 先扫物流表快照 S1 里没有订单 X 的物流记录。另一个事务提交了订单 X 的状态更新把它从“待发货”改成“已发货”。报表 SQL 再扫订单表时快照 S2 里订单 X 变成了“已发货”。两边 JOIN逻辑上订单 X 应该有物流记录但物流快照里没有结果就是“已发货的订单查不到物流”。这种不一致在READ COMMITTED下是完全合法的行为。你要么把整个事务调成REPEATABLE READ让所有表的读取使用同一个快照要么在业务层接受这种短时间不一致并通过任务重跑或者最终对账来兜底。REPEATABLE READ也并非银弹。事务拿到的快照越旧其他事务更新提交后旧事务里再做写操作就可能抛出“序列化失败”或者出现更新丢失。它保证了读的一致性牺牲的是并发写入的“实时性”两者要权衡。4. 怎么定位到底锁被谁占了、谁在等谁4.1 pg_locks 一查便知遇到锁问题第一个该打开的是pg_locks视图。它能告诉你数据库里当前存在的所有锁包括锁模式、是否被授予、关联的进程。我常用的排查脚本沿着关联查pg_stat_activity能直接看到是谁在跑、跑的是什么 SQLSELECT t.relname AS table_name, l.pid AS locked_pid, l.mode AS lock_mode, l.granted AS lock_granted, a.usename AS username, a.state AS session_state, left(a.query, 80) AS current_query FROM pg_locks l JOIN pg_class t ON l.relation t.oid JOIN pg_stat_activity a ON l.pid a.pid WHERE t.relname orders ORDER BY l.granted, t.relname;看granted字段很直观。如果某行granted false说明这个会话正在等锁再按table_name和lock_mode对一下基本能定位是哪张表、哪类锁在堵塞。普通 SELECT 的ACCESS SHARE和写操作的ROW EXCLUSIVE不冲突但和ACCESS EXCLUSIVE冲突。一旦有DDL命令长时间没提交所有读查询都会排长队。4.2 用 pg_blocking_pids 顺藤摸瓜pg_locks告诉你是谁在等但没告诉你谁占着。PostgreSQL 提供了一个现成的函数pg_blocking_pids()直接返回阻塞当前进程的 PID 数组。配合pg_stat_activity一条 SQL 就能画出完整等待链SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, left(blocked.query, 60) AS blocked_query, left(blocking.query, 60) AS blocking_query, blocking.xact_start AS blocking_xact_start FROM pg_stat_activity blocked JOIN pg_stat_activity blocking ON blocking.pid ANY(blocked.pg_blocking_pids()) WHERE blocked.wait_event_type Lock ORDER BY blocking.xact_start ASC;执行完优先看blocking_xact_start。如果某个事务已经跑了几十分钟甚至几个小时还没提交那它不是性能慢就是业务逻辑里忘了提交事务或者代码里混入了外部调用导致事务被挂住。这种长事务是锁问题的头号元凶。我在实际环境中见过最长的一个事务挂了两小时原因是代码里在事务中间发了 HTTP 请求对方接口超时数据库事务就这么一直开着。4.3 给锁加个“闹钟”与其靠人盯不如直接给锁设超时。PostgreSQL 提供了lock_timeout表示一条语句等待锁的最长时间超了就报错退出。这个参数非常实用可以全局设置SET lock_timeout 2s;也可以按事务设置。设置成 2 秒意味着如果一条 SQL 拿不到锁最多等 2 秒就会报错而不是无限期卡在等待队列里。对线上业务来说一次快速报错远比永久挂起好处理。另一个容易混淆的是statement_timeout它限的是整条 SQL 的执行时间和锁等待时长关系不大。两条最好同时设置我习惯这样配SET statement_timeout 30s; SET lock_timeout 2s;前者兜底慢 SQL后者兜底锁竞争两个都设上再遇到问题至少会有报错日志可查而不是所有连接一起干瞪眼。还有一个运维小技巧全部会话动态修改不现实直接写在配置里更稳妥# postgresql.conf lock_timeout 2s statement_timeout 30s虽然lock_timeout默认是 0无限等待但生产环境我强烈建议改小哪怕设 5 秒也行。否则真被一个锁卡住所有 API 调用堆积最后是雪崩式的连锁故障。5. 几条实打实的避坑配置与方案5.1 多张表更新时统一加锁顺序死锁的本质是多个事务以不同的顺序去拿同一批锁。比如事务 A 先锁订单表再锁物流表事务 B 先锁物流表再锁订单表两边各持一把锁同时等对方释放就会出现死锁。解决死锁最粗暴也最有效的办法就是让所有事务都按照同一个顺序拿锁。比如约定不管是哪个模块只要同时涉及orders和shipments就先锁orders再锁shipments。这个规则听起来简单但应用在不同团队时很容易被忽略。我的做法是在项目的数据库访问层封装一个统一的加锁入口所有多表更新都走同一个函数而不是各写各的SELECT FOR UPDATE。代码层面也可以做约束。比如写一个阈值判断更新订单之前先根据订单 ID 排个序SELECT id FROM orders WHERE id IN (...) ORDER BY id FOR UPDATE;按 ID 升序统一拿锁能非常有效地避免交叉死锁。这个方法我在多个项目里用了很多年几乎没再遇过死锁报错。5.2 业务并发控制优先用咨询锁很多“锁”不是数据库行锁而是业务语义层面的并发控制。典型场景定时任务防止重复执行同一个订单不能同时被两个处理进程操作。这种场景用SELECT FOR UPDATE锁业务行可能太重也可能因为行锁范围扩散影响到正常读写。PostgreSQL 提供了咨询锁Advisory Lock它不绑任何表而是基于一个自定义的整数键来保证互斥。用法SELECT pg_advisory_xact_lock(123456); -- 执行业务逻辑 COMMIT;这个锁在事务提交或回滚时自动释放很适合做任务级的互斥。比如某个定时任务要在所有节点上只跑一份可以在任务入口直接SELECT pg_try_advisory_xact_lock(42);pg_try_advisory_xact_lock拿不到锁会直接返回 false不会阻塞等待这样其他节点发现自己拿不到锁就自动跳过非常干净。我踩过一次坑用pg_advisory_lock会话级拿了锁忘记在代码里手动释放连接池的连接复用时锁还挂在会话上导致后续请求全被挡住。建议优先使用事务级的pg_advisory_xact_lock它随事务结束自动清理想丢都丢不掉。5.3 外键列、关联列一定要建索引前面反复提到外键造成的锁竞争很多其实可以靠索引来降低锁持有时间。原因是外键约束检查时如果子表外键列没有索引PostgreSQL 就得全表扫描来确认没有未提交的引用行。全表扫描既慢又扩大了锁的扫描范围。建索引几乎是零成本的操作CREATE INDEX idx_orders_customer_id ON orders(customer_id); CREATE INDEX idx_shipments_order_id ON shipments(order_id);对orders有外键指向customers的场景customer_id上的索引能大幅度减少父表更新时的子表扫描成本。联表查询的ON条件列也同理。索引不但加速 JOIN还能减少锁的驻留时间一举两得。还有一个细节如果某个外键列本来就低频使用而且子表数据量极大可以考虑把外键约束改成DEFERRABLE INITIALLY DEFERRED。这样约束检查会被推迟到事务提交时统一执行避免在长事务中间持有锁。但注意延迟约束只能降低“持锁总时长”并不能彻底消除锁而且可能让业务错误在提交时才暴露需要权衡。5.4 隔离级别选择要对场景下菜事务隔离级别不该是“一调到高就放心”也不该是“默认跑到底”。按我的经验纯 OLTP 的短事务用默认的READ COMMITTED就够了性能最好隐患集中在联表长查询里。批量统计报表或者一个事务里多次查询要求完全一致的数据视图用REPEATABLE READ。代价是旧快照会拖住VACUUM大表别长期挂事务。SERIALIZABLE慎用。它会引入谓词锁并发事务可能出现“序列化失败”这种报错需要应用层重试。除非业务真的能受限于重试成本否则不要轻易用。如果需要在READ COMMITTED下确保联表查询的一致性还可以考虑用锁表 单事务的方式BEGIN; LOCK TABLE orders IN SHARE MODE; LOCK TABLE shipments IN SHARE MODE; SELECT ... FROM orders JOIN shipments ON ...; COMMIT;SHARE锁会挡住其他事务的写操作保证两张表在查询期间都不变但也会牺牲并发写入能力。只有在线报表这种低频写场景下才适合。6. 常见问题速查表与最后几句实操心得6.1 问题速查表现象可能原因排查切入点推荐解法联表查询突然卡住DDL 排他锁或长事务锁冲突pg_locks查 ACCESS EXCLUSIVEpg_stat_activity查长事务终止长事务拆分大事务设置 lock_timeout报错 deadlock detected多事务加锁顺序不一致看日志里涉及的 SQL 与锁对象统一加锁顺序按 ID 排序加锁联表结果对不上READ COMMITTED 下多表多个快照复现 SQL观察前后两次查询结果差异用 REPEATABLE READ或业务接受短时不一致更新主表却锁住子表写操作外键检查全表扫子表EXPLAIN 查看外键检查确认子表外键列有无索引补索引延迟约束缩短事务FOR UPDATE 锁的行过多未加 OF 子句、执行计划走全表扫EXPLAIN 分析扫描路径核对查询涉及的所有表指定 OF 表名加索引固定执行计划业务任务重复执行缺少进程级互斥看日志确认任务节点数用咨询锁 pg_try_advisory_xact_lock某条联表查询一直拿不到锁锁模式冲突pg_blocking_pids 查阻塞链释放阻塞事务设置锁超时快速失败调成 SERIALIZABLE 后频繁报错谓词锁下的写偏斜查看 error 日志中 serialization failure业务重试机制降级隔离级别这张表是我多年处理锁问题的备忘录基本覆盖了线上绝大多数锁相关故障的定位思路。6.2 最后几句掏心窝的话锁问题最让人头疼的不是它有多深奥而是表现太像“数据丢了”“代码错了”。我吃过最大的亏就是拿到一个查询异常的报告第一反应去审 SQL 逻辑。后来记住一条铁律任何“结果变少了”“结果对不上”的问题先看并发、再看锁、最后才是代码。给还在踩坑的你几条个人建议一是生产环境一定设lock_timeout。无限等待等于把问题无限放大早失败早恢复才是运维该有的思路。二是所有FOR UPDATE联表查询一律显式写OF 目标表。不要指望 PostgreSQL 帮你做减法它只会做加法。三是外键列索引这件事不要省。它不只是性能优化更是锁竞争治理的关键一环。四是监控面板上加上长事务和锁等待两个指标。长事务超过 5 分钟就告警锁等待超过 2 秒就告警很多大事故都是在萌芽期被这些简单的告警拦下来的。我后面有一次再做同类定时任务直接把报表 SQL 改成了REPEATABLE READ单独跑一个事务并且给所有相关表的外键列补了索引。从那以后那个“查不到物流记录”的告警就再也没出现过。数据库的锁不是洪水猛兽搞懂它的脾气提前把规则定好它就是一个稳定可靠的好队友。