14 深入专题:事务、MVCC 与并发控制

14 深入专题:事务、MVCC 与并发控制

深入事务隔离级别、MVCC 快照、XID 回卷、锁机制与死锁检测,理解 PostgreSQL 强并发能力背后的底层原理。

COMMIT AND CHAIN 语法的作用是什么?

COMMIT [WORK|TRANSACTION] [AND [NO] CHAIN] 中,AND CHAIN 表示当前事务结束后立即启动一个新事务,且新事务继承刚结束事务的事务特征(transaction characteristics,如隔离级别、READ ONLY 等)。不指定 CHAIN 则不启动新事务。这减少了开启新事务的交互次数(网络往返),尤其适合需要连续多事务且特征相同的场景。示例:事务内 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ 后,循环里 COMMIT AND CHAIN / ROLLBACK AND CHAIN,每次新事务都保持 repeatable read 隔离级别(事务特征被继承)。事务特征包括 ISOLATION LEVEL、READ WRITE/READ ONLY、[NOT] DEFERRABLE 等,通过 SET TRANSACTION 或 SET SESSION CHARACTERISTICS AS TRANSACTION 设置。这对需要减少交互次数、保持事务特征一致的批量处理场景有用。

FOR UPDATE SKIP LOCKED 的工作原理是什么?它适合和不适合什么场景?

SKIP LOCKED 解决多消费者队列的队头阻塞:多个 worker 按同一顺序领取任务,没有 SKIP LOCKED 时 Worker B 会等在队头被锁的行上;用 FOR UPDATE SKIP LOCKED 后遇到拿不到行锁的候选行不等待、跳过、继续找后面的可锁行。实现:锁定子句对应 LockWaitPolicy 枚举的 LockWaitSkip(LockWaitBlock/LockWaitSkip/LockWaitError),执行器 LockRows 节点从子计划取候选行,调用 table_tuple_lock(),返回 TM_WouldBlock 就 goto lnext 取下一条;heap 层 LockWaitSkip 用 ConditionalLockTupleTuplock/ConditionalMultiXactIdWait/ConditionalXactLockTableWait 条件尝试。关键语义:1) 返回结果有意不完整,跳过的行不代表不存在;2) 只作用行级锁,表级 ROW SHARE 锁仍按普通方式获取;3) LIMIT 对成功返回的行生效,不是先截断再锁;4) 不保证公平,热点行可能饥饿,需超时回收和扫尾。适合队列/批处理/补偿任务;不适合金融撮合、库存精确扣减等强顺序场景。SKIP LOCKED 不能和 WITH TIES 同时使用。

GetOldestXmin 是什么?为什么"曾获得过快照的空闲事务"比普通只读事务更危险?

GetOldestXmin 是系统中存在的最老事务(不管 2pc、空闲事务、执行中 SQL,也不管隔离级别,只看最老的),它决定 VACUUM 能清理哪些垃圾版本。问题在于:对 RC 隔离级别,SQL 快照实际指 SQL 发起时的状态,发起前已提交事务产生的垃圾对该 SQL 已不需要看到;但 GetOldestXmin 只看事务启动后获得的第一个快照,在这个快照之后产生的垃圾 tuple 都不会被清理。所以"曾获得过事务快照"的空闲事务(例如 begin 后 select 过又挂着)会固定 xmin,非常危险;而空闲中的只读事务(从未获得快照)不影响,因为没有 backend_xid/xmin。同理,已 prepare 但未 commit/rollback 的 2pc 也危险,GetOldestXmin 包含 2pc 开启时的快照。验证方法:vacuum verbose 输出中 “1 dead row versions cannot be removed yet, oldest xmin: XXX” 的 XXX 就是卡住的 XID。优化:内核层可在 GetOldestXmin 时用当前最小未分配事务号代替空闲/2pc 事务的 oldestxmin;参数层用 old_snapshot_threshold 和 idle_in_transaction_session_timeout。

LWLock 的等待事件如何解读?ProcArray / BufferMapping / WALInsert / WALWrite 分别代表什么热点?

LWLock 等待事件是共享内存热点信号,不是业务锁冲突信号。常见映射:1) ProcArray:活跃事务数组,GetSnapshotData() 拿 LW_SHARED 并行取快照,事务结束更新需 LW_EXCLUSIVE,高并发快照获取、事务集中提交、连接数过高、长事务都会造成该竞争。2) BufferMapping:共享缓冲区映射表(分 128 个分区),对 BufferTag 哈希后拿分区锁,指向共享缓冲区映射竞争,可能原因包括随机访问太分散、工作集超过缓存、热点块高频替换。3) WALInsert:保护 WAL 记录插入内存 buffer(8 个插入锁),偏 WAL 内存插入竞争。4) WALWrite:保护 WAL buffer 写盘/刷盘路径,偏提交频率、同步提交、存储延迟。排查顺序:先区分 wait_event_type(Lock/LWLock/IO/Buffer/IPC),再按 wait_event 分类,用 pg_wait_events 查事件描述,回到具体子系统看证据,最后用时间序列确认是持续热点还是瞬时尖峰。不要一看到 BufferMapping 就盲目调大 shared_buffers。

LWLock、Spinlock、重量锁、谓词锁(SIReadLock)这四类锁的区别和适用场景是什么?

PostgreSQL 多进程架构下有四类进程间锁:1) Spinlock:保护极短内部状态(几十条指令内),等待者忙等,不提供死锁检测,超过几十条指令或跨内核调用就不该用。2) LWLock:保护共享内存数据结构(ProcArray、BufferMapping、WALInsert、WALWrite 等),支持 LW_SHARED/LW_EXCLUSIVE 模式,等待时睡眠不烧 CPU,无死锁检测、无普通锁超时语义,不适合可能长等待的业务级互斥。3) Heavyweight lock(重量锁):保护 SQL 可见对象(relation、transactionid、tuple、object、advisory),支持多种锁模式、冲突矩阵、完整死锁检测、事务结束自动释放,可观测于 pg_locks。4) Predicate lock(SIReadLock):Serializable 下记录读写依赖,不是普通读写锁模式,不阻塞普通操作。关键点:业务 SQL 看到 wait_event_type=‘Lock’ 优先查 pg_locks 和对象锁冲突;看到 ‘LWLock’ 要回到共享内存子系统和 wait_event 名称判断。lock_timeout 只作用于 SQL 锁等待,对 LWLock 无效。

MVCC 读不阻塞写、写不阻塞读是如何实现的?为什么普通 SELECT 还要 AccessShareLock?

PG 的 MVCC 通过快照 + 行版本实现读写互不阻塞:每个 SQL 看到某个时间点的数据快照,读只查可见性(xmin/xmax 与快照比对),不阻止别人写新版本;写产生新行版本,不覆盖旧版本,所以不阻止别人读旧版本。因此普通 SELECT 和普通 UPDATE 之间不互相阻塞(这正是 MVCC 相对传统 2PL 的优势:传统两阶段锁下读写会互相阻塞)。那为什么普通 SELECT 还要拿 AccessShareLock(表级锁)?因为 MVCC 只解决"读哪个版本",不解决"结构能不能被同时改"——AccessShareLock 与 AccessExclusiveLock 冲突,保证 SELECT 执行期间表结构(schema)不会被 DROP/TRUNCATE/ALTER 改变,否则查询会访问到正在被删除/重写的表和元数据。所以 MVCC 处理行版本可见性,表锁处理对象级结构保护,两者是正交的。

NOWAIT 和 SKIP LOCKED 有什么区别?它们只作用于行锁吗?

NOWAIT 和 SKIP LOCKED 是 SELECT FOR UPDATE/SHARE 的等待策略(不是锁强度)。区别:NOWAIT 表示如果要锁的行发生锁冲突,立即报错(LockWaitError),不等待;SKIP LOCKED 表示跳过有锁冲突的行不等待,例如 10 行符合条件但 3 行冲突,就跳过着 3 行锁其他 7 行(LockWaitSkip)。锁强度由 FOR UPDATE/NO KEY UPDATE/SHARE/KEY SHARE 决定,等待策略由 NOWAIT/SKIP LOCKED 决定。重要限制:NOWAIT 和 SKIP LOCKED 只作用于行级锁,所需的 ROW SHARE 表级锁仍按普通方式取得;如果表级锁也不想等待,应先显式 LOCK TABLE … NOWAIT。另外 PG 不支持 update|delete … skip locked|nowait 语法,需用 CTE + FOR UPDATE SKIP LOCKED 模拟一次交互:WITH picked AS (SELECT id FROM t WHERE … FOR UPDATE SKIP LOCKED) UPDATE … FROM picked WHERE …。多个锁定子句同时存在时,NOWAIT 优先级高于 SKIP LOCKED(枚举顺序被 applyLockingClause() 用来处理优先级)。

PG 14 为什么增加 startup 进程与 backend 进程之间的死锁检测?

在基于流复制的只读实例(hot standby)场景,recovery conflict on lock 涉及的死锁可能发生在 hot-standby backend 和 startup 进程之间。之前的 bug:如果 backend 拿了 AccessExclusiveLock 最终触发死锁,能被 backend 内调用的死锁检测器发现;但如果 startup 进程拿了 AccessExclusiveLock 触发死锁,则无法被检测到,死锁可能在 deadlock_timeout 后依然存在。根因是处理 recovery conflict on lock 的代码完全没考虑死锁情形,假设涉及 startup 和 backend 的死锁能被 backend 内调用的死锁检测器检测到——这个假设是错的。修复(commit 8900b5a9,backpatch 到 9.6):当处理 recovery conflict on lock 达到 deadlock_timeout 时,startup 进程也调用死锁检测器——具体是请求所有持有冲突锁的 backend 检查自身是否死锁。9.5 因缺少基础设施代码且已是最后一个小版本,决定不 backpatch。

PG 14 为什么引入 idle_session_timeout?它和 idle_in_transaction_session_timeout 有什么区别?

idle_session_timeout(PG14 引入)用于自动终止长时间空闲的会话(idle,未开启事务的空闲连接),解决大量 idle 连接的问题。idle 连接产生的原因:DB 性能抖动导致业务拥塞,业务端新建更多连接处理请求,但没配置自动释放或未到释放超时。危害:1) 每个会话有私有内存,缓存访问过的对象元数据(尤其分区表每个分区独立元数据),连接多了可能触发 OOM;2) 占满连接导致其他业务连接不足。区别:idle_session_timeout 管"未开启事务的空闲会话",idle_in_transaction_session_timeout 管"已开启事务但空闲(idle in transaction)的会话"——后者危害更大(持锁、固定 xmin)。PG14 之前的版本没有原生 idle_session_timeout,靠第三方插件 pg_timeout 实现。这两个参数是防连接池泄漏和会话泄漏的数据库层兜底。

PG 为什么说"锁粒度只能到行",字段级锁/多版本控制缺失对业务有什么影响?

PG 的行锁是锁管理的最小颗粒,MVCC 多版本控制也是行级别。这意味着:1) 同一行不同字段无法并行更新(更新 info 和 ts 会互相阻塞);2) 对 JSON、array、tsvector 等多值类型,无法做到元素级的锁和元素级多版本。业务影响:需要经常并行更新同一行不同字段/不同元素的场景(如高频热点行、大 JSON 文档的并发修改)会遭遇行锁争用,吞吐受限。现有解法都是绕行:拆表(把需并行更新的字段拆到多表用 PK 关联)、合并更新到同一会话/事务。这背后的反思是:为什么锁粒度必须止于行?为什么多版本不能下推到字段/元素级?这是产品演进的潜在方向(类似某些列存或文档数据库的字段级 MVCC),PG 社区目前未实现。

PG 为什么难打印慢 SQL 的锁等待信息?有哪些变通方案?

问题:log_min_duration、auto_explain、pg_stat_statements 都不单独统计 SQL 的锁等待时长——log_min_duration 打印超时 SQL 但不记录锁等待耗时;auto_explain 打印执行计划但锁等待耗时算在整个 SQL 里不单独拆;log_lock_waits 记录等待超 deadlock_timeout 的会话但不打印 SQL,且每隔 deadlock_timeout 打一条难汇总。这导致分析锁等待引起的问题非常麻烦,且锁等待通常是业务逻辑问题,需开发者介入,门槛高。变通:1) 经常采集 pg_locks、pg_stat_activity 动态视图做等待统计(如 pgsentinel 插件、AWS performance insight 类似物);2) 有开发能力的企业用 eBPF 采样做低开销采集(如 DBdoctor、pg-lock-tracer)。期望内核未来在 log_min_duration/auto_explain 记录锁等待时长,log_lock_waits 能汇总同一请求的锁等待信息(含 SQL 和堵塞信息)。

PG 函数和存储过程内的事务控制能力有什么区别?为什么自治事务支持不完整?

PG 的 1 个函数是 1 个原子操作,要么全部成功要么全部回滚(注意:exception 里算一个新子事务,触发 exception 时函数体操作全部回滚,exception 体内执行正常则可提交)。限制:1) 函数内不能使用 commit、rollback、savepoint 等事务控制语句;2) 存储过程(procedure)内只能使用 commit、rollback,不能使用 savepoint、rollback to savepoint、release savepoint;3) 没有真正的自治事务(autonomous transaction)。这影响用 function/procedure 做复杂业务逻辑的场景,无法灵活处理事务控制。模拟 savepoint/rollback to savepoint 用变量 + exception 很复杂,且嵌套 exception 时会报 cannot commit while a subtransaction is active。变通方案:通过 dblink 开启新会话来模拟自治事务,但复杂度大增。这与 Oracle/DB2 的语句级回滚 + 自治事务差距明显,是 PG 相对 Oracle 的兼容性短板之一。

PG 增大字段长度会锁表吗?DDL 锁等待为什么会引发"雪崩"?

  1. 所有 DDL 操作都会锁表(堵塞读写);2) DDL 有的只需修改元数据(毫秒级),有的需要 rewrite table(取决于表大小和索引多少)。增大字段长度这类操作需要 AccessExclusiveLock,与所有模式冲突。危险点:如果 DDL 未能及时获取表的排他锁(例如有其他长事务持有表的共享锁),DDL 的排他锁就进入等待队列,此时会堵塞其他该表的一切 DML 和查询操作——这就是"雪崩":一个 DDL 排队,后面所有普通读写都排到它后面,即使它们与最早的持锁者不冲突,也被等待队列顺序软阻塞。建议:1) 评估 DDL 耗时;2) 低峰操作;3) 必要时清理堵塞 DDL 的长事务或后台任务(autovacuum);4) 执行 DDL 前设置 lock_timeout=‘1s’ 防止雪崩,拿不到锁就快速失败退出而不是排队。若 rewrite 时间太长,可考虑模拟 online DDL(如 pg_repack 或新建表+切换)。

PG 的 XID 是 32 位,wraparound(事务回卷)如何影响事务和 vacuum?

XID 是 32 位,超过约 40 亿会回卷,导致旧版本看起来像未来版本。为避免,每个表需周期性 vacuum 并把足够老的行版本标记为 frozen,relfrozenxid、datfrozenxid、autovacuum_freeze_max_age 等围绕此边界工作。长事务、prepared transaction、复制槽的 xmin/catalog_xmin 会固定清理边界,冻结无法推进,最终触发 anti-wraparound autovacuum 甚至拒绝分配新 XID。

PG12 的 COMMIT/ROLLBACK AND CHAIN 是什么?

PG12 支持 COMMIT AND CHAIN 和 ROLLBACK AND CHAIN,在结束当前事务的同时立即开启一个继承上一事务特征(隔离级别等)的新事务,减少客户端交互次数和事务边界切换开销。适合需要连续多个事务、又希望减少往返的批处理场景。

PG14 为什么考虑增加 pg_lwlock_blocking_pid 做 lwlock blocking 诊断?

当前 LWLock(轻量锁)等待没有跟踪数据,只能从 wait_event 知道"在等某个 lwlock 事件"(如 WALInsert),无法知道谁堵塞了谁。PG14 曾讨论引入 pg_lwlock_blocking_pid 函数支持 lwlock 等待跟踪,返回等待者的请求模式、最后一个持有锁的 PID、持有模式、持有者数量等信息(如 (LW_WAIT_UNTIL_FREE,10232,LW_EXCLUSIVE,1))。难点是:LWLock 结构很轻(tranche + state 32 位原子 + waiters 链表),为了跟踪阻塞关系需要改锁结构、记录等待/持有关系,锁的存储可能变得更重,社区仍在讨论中(权衡诊断能力 vs 每个轻量锁的内存/性能开销)。这是 PG 在可观测性上的探索:重量锁已有 pg_blocking_pids 完整诊断,轻量锁因高频低延迟特性,很难在不增加开销的前提下实现同等诊断,是内核可观测性设计的典型取舍。

PG14 的 pg_locks.wait_start 字段和 PG18 的 log_lock_failure 参数分别解决什么问题?

PG14 增加 pg_locks.wait_start 字段跟踪锁等待开始时间:之前分析锁等待时长只能 join pg_stat_activity 用 query_start 或 state_change 近似,无法得到精确锁等待时长;wait_start 记录锁开始等待的时间点,便于排查等待耗时和先后顺序。为避免 gettimeofday 频繁调用带来的性能影响,复用了 deadlock_timeout 定时器启动时已调用的 gettimeofday 结果;fast-path 锁不会等待,wait_start 为 0。PG18 增加 log_lock_failure GUC(默认 off)记录锁获取失败详细日志:当 SELECT … NOWAIT 因锁冲突立即失败时,记录所有持有或等待该锁的进程信息(pid、mode),帮助诊断锁失败原因;为防日志过载,排除 SKIP LOCKED(跳过的锁太多会产生噪音),未来可扩展到 LOCK TABLE … NOWAIT 等命令。二者分别增强锁等待"时长观测"和锁失败"原因诊断"能力。

PG14 逻辑复制的 logical decoding 如何支持 2PC(两阶段事务)?

PG14 扩展了内置逻辑复制的 output plugin API,新增 6 个 callback 支持解码 prepared xacts:begin_prepare、filter_prepare、prepare、commit_prepared、rollback_prepared、stream_prepare。之前的限制:两阶段事务在 subscriber 上被翻译成普通事务,GID 不转发给 subscriber,两个阶段命令都不通知 subscriber。这个 patch 提供基础设施,让逻辑解码插件能被告知 PREPARE TRANSACTION、COMMIT PREPARED、ROLLBACK PREPARED 命令及对应 GID,语义区别是事务尚未提交、之后可能 abort。test_decoding 插件实现了这些新方法。这为下游按 2PC 边界复制事务(保持 prepare/commit/rollback 语义)打基础,配合后续 ReorderBuffer 在 prepare 时解码。注意这与本地 PREPARE TRANSACTION 语义是不同层次:本地 2PC 是作为 XA 参与者支持外部全局事务,逻辑复制 two_phase 决定 WAL decoding 和 subscriber 是否按 prepare/commit prepared 事件复制。

PG16+ 官方文档新增的"事务处理内部原理"章节覆盖了哪些内容?

PG16+ 官方文档新增 Chapter 74 Transaction Processing,提供事务管理系统内部原理概述(此前事务内部细节散落在源码 README 和文档各处)。章节包括:74.1 Transactions and Identifiers(事务与标识符——XID、VXID、xid8、FullTransactionId、子事务 XID 分配规则);74.2 Transactions and Locking(事务与锁——事务如何获取/持有锁、锁与事务生命周期的关系);74.3 Subtransactions(子事务——pg_subtrans、savepoint、子事务可见性与父事务关系);74.4 Two-Phase Transactions(两阶段事务——PREPARE TRANSACTION/COMMIT PREPARED/ROLLBACK PREPARED、prepared transaction 的锁和恢复语义)。这个章节把分散在 mvcc.sgml、xact.sgml、wal.sgml 及源码 README(access/transam/README、lmgr/README)中的事务内核知识系统化,是理解 PG 事务/锁/并发内部机制的重要官方入口。

PG17 的 WAL 锁竞争优化做了什么?为什么需要 write barrier?

PG17 为"无锁读取 WAL buffer 内容"做准备,做了两个前置优化(commit c3a8e2a7 和 766571be):1) xlblocks 数组元素改用 64 位原子(pg_atomic_uint64),避免之前需注释解释为何无锁读不会出现 torn reads(撕裂读);2) 在 AdvanceXLInsertBuffer() 中增加 write barrier——先把 xlblock 成员标记为 InvalidXLogRecPtr,发出 write barrier,再初始化它,确保 xlblock 在内容初始化期间不会"看起来有效"(旧页可能部分清零但看似有效)。读取方不持锁读值时若得到 InvalidXLogRecPtr 是安全的,会抓 mapping lock 重试。背景是 WAL buffer 是高频写路径,WALInsert/WALWrite/WALBufMapping 等 LWLock 竞争是高并发小事务瓶颈之一,通过让读 WAL buffer 内容无需持锁,减少锁竞争、提升 WAL 路径并发。

PG18 增加 fast-path lock slots 提升什么性能?

PG18 增加 fast-path lock slots 数量,提升访问多对象的高并发 OLTP 业务性能。访问对象多时原本 fast-path 槽位不足会退化为重路径,增大槽位后更多锁走快捷通道,降低锁管理开销,提升高并发小事务吞吐。

PG18 如何扩展 fast-path lock slots?为什么说原来 16 个槽位不够用?

9.2 引入的 fast-path 只允许每个 backend 最多 16 个弱 relation 锁,这个硬编码上限一直偏小,因为查询要锁所有 relation——不止表,还有索引、视图、分区等;planning 阶段甚至要锁计划中"可能用到"的所有关系。随着分区表广泛使用、分区数增多,复杂查询轻松用掉几百甚至上千个锁,多核系统上访问共享锁表成为严重争点。PG18(commit c4d5cb71)移除了硬编码上限:fast-path 数组改为启动时根据 max_locks_per_transaction 计算大小,采用 2^n 公式,上限 1024 个 lock groups(即 16k 锁),默认 64 时对应 64 个 fast-path 槽。槽位组织为 16 路组相联缓存(可想象成每 group 16 槽的哈希表),每个 relation 用 hash(relid) 映射到唯一 group,再线性搜索,保持良好局部性。用开放寻址哈希表会因接近满表时效率差、访问随机性高而不适用。

PG18 的 GetLockStatusData 效率优化解决了什么问题?

GetLockStatusData() 是 pg_locks 视图背后的函数,负责采集锁状态数据。高并发小事务场景下,频繁查询 pg_locks(监控、排查)本身会带来开销:它要从普通锁管理器(重量锁主锁表)和谓词锁管理器采集数据,可能影响性能。PG18 优化了 GetLockStatusData 效率,降低采集锁状态的成本,使高并发小事务场景下查询 pg_locks 更轻量。这与 PG14 的 GetSnapshotData 优化、PG16 的 SSE2 加速、PG18 的 fast-path slots 扩展、log_lock_failure 日志等一起,都是围绕"高并发小事务/锁管理"这条主线的持续性能与可观测性改进。实践含义:即使优化后,也不建议秒级全量轮询 pg_locks,排障时查询没问题,监控要控制范围和频率(官方文档也提示采集锁管理器信息可能对性能有影响)。

PG18 的 pg_stat_session 视图提供什么能力?

pg_stat_session 是 PG18 新增视图,提供会话级别各状态的耗时/计数统计(pg_stat_activity 只能查会话"当前"状态,无法看累计分布)。字段:pid、active_time(running/fastpath 状态耗时毫秒)、active_count(切换到 active 状态次数)、idle_time、idle_count、idle_in_transaction_time、idle_in_transaction_count、idle_in_transaction_aborted_time、idle_in_transaction_aborted_count。它由 pg_stat_get_session(NULL) 函数支撑,每个 client backend 一行。价值:可以量化一个会话历史上花了多少时间在 idle in transaction(空闲事务)、active、idle 等状态,帮助判断会话是否有大量空闲事务时间、是否连接池滥用、事务是否长期挂着不干活。相比 pg_stat_activity 的瞬时快照,pg_stat_session 提供累计视角,是定位"为什么这个连接长期占用资源"的有力补充。

PG18 的 pg_stat_session 视图是什么?

PG18 新增 pg_stat_session 视图,对会话各状态的耗时和计数进行分析(如活跃、空闲、空闲事务、CPU 时间、等待时间等),类似把 pg_stat_activity 的瞬时状态聚合为累计统计。DBA 可据此分析会话在各类状态上花了多少时间,定位连接资源浪费和异常会话。

PG19 如何把 64 位事务号 FullTransactionId 覆盖面扩展到 2PC?

PG19 将 64 位事务号(FullTransactionId,含 epoch 的 64 位表示)的覆盖面扩展到 2PC(两阶段事务),让 prepared transaction 也能用 64 位事务号标识,解决 32 位 XID 在长时间、高事务量场景下的回卷风险,增强 prepared transaction 的事务号精度和安全性。

PL/pgSQL 的 EXCEPTION 块为什么是"性能杀手"?如何替代?

PL/pgSQL 的 EXCEPTION 块本质是开启一个子事务(Subtransaction),每进入一次就消耗一个 XID 并生成一个保存点。它的问题:1) 浪费有限 XID 资源;2) 一旦循环里嵌套 EXCEPTION,子事务深度/数量飙升,超过 PGPROC 64 个缓存上限后溢写到 pg_subtrans,触发 SubtransControlLock 锁竞争和 SLRU 磁盘扫描;3) WAL 和 XID 消耗暴增(5000 条插入可烧掉 64 万 XID),导致 autovacuum freeze 提前甚至全库只读。替代方案:1) 处理唯一键冲突用 INSERT … ON CONFLICT DO NOTHING 等原生语法,不要用 EXCEPTION 捕获;2) 数据预校验——在应用层或临时表先过滤脏数据;3) 严禁在循环内部嵌套 EXCEPTION 块;4) 只在处理无法预知的非约束性错误时才用 EXCEPTION。防御性策略:把"WHEN OTHERS THEN NULL"这类看似万能的写法替换为明确的条件处理。

PREPARE TRANSACTION 的内核路径是怎样的?为什么 prepare 时必须刷 WAL?

PrepareTransaction() 主路径:1) 触发 deferred trigger、关闭 portal;2) 拒绝不适合 prepared 的事务(访问过临时对象、导出过 snapshot 等);3) MarkAsPreparing() 保留 GID 和 GlobalTransactionData(检查 max_prepared_transactions=0 则报错、GID 超长报错、GID 查重);4) StartPrepare() 写 2PC header,收集子事务、待删除文件、统计、缓存失效消息;5) 一组 AtPrepare_*() 回调把锁、谓词锁、MultiXact 等写成 2PC record;6) EndPrepare() 写 XLOG_XACT_PREPARE WAL record 并 XLogFlush();7) MarkAsPrepared() 把 dummy PGPROC 加入 ProcArray;8) PostPrepare_Locks() 把事务锁迁移到 dummy PGPROC。为什么必须刷 WAL:参与者对协调者说"yes"后就承诺未来能提交或回滚,若 prepare record 只在内存,节点崩溃后会忘掉该事务,协调者再发 commit 时参与者丢失承诺,协议破裂。源码注释:“If we crash now, we have prepared”。锁迁移必须在 ProcArrayClearTransaction() 之前,否则其他进程可能看到锁还在但 XID 已不像 running。

PostgreSQL 14 如何优化 GetSnapshotData 的高并发性能?

GetSnapshotData() 是 PG 扩展到大量连接的最大瓶颈,生产负载曾出现 98% CPU 时间花在这里。主要原因是 PGXACT->xmin:即使最简单的只读事务也会在生命周期内多次修改 MyPgXact->xmin(快照获取和释放各一次、EOXact 处理又改),导致 GetSnapshotData() 扫描时命中其他 CPU/socket 拥有的 cacheline,系统越大后果越严重。PG14 的优化(commit 1f51c17c 等):1) 不计算全局 horizon——GetSnapshotData() 不再读取每个 proc 的 xmin,而是用两个阈值(definitely_needed / maybe_needed)延迟精确 horizon 的计算,只在确实需要 prune 时才重算;2) 把 PGXACT->xmin 移回 PGPROC,避免与其他高频更新的 PGXACT 成员共享 cacheline;3) 紧密打包 xids/vacuumFlags/nsubxids 到独立数组。效果:pgbench 只读场景 100 连接时 tps 从约 105 万提升到约 190 万,5000 连接仍保持超 150 万 tps。

PostgreSQL 16 如何用 SSE2 指令集加速高并发小事务写性能?

高并发小事务写性能差的一个原因是 XidInMVCCSnapshot() 需要在线性数组(snapshot->xip/subxip)中搜索 XID,大量并发写者时扫描 xip 数组对可扩展性影响明显。PG16 引入 SSE2 SIMD 指令集优化线性数组搜索(commit b6ef16756 引入优化例程,commit 37a6e5df3 把它用于 XidInMVCCSnapshot):在 x86-64 上用 SSE2 intrinsics 加速搜索,其他平台退化为普通 for 循环。性能提升:128 并发写者吞吐提升 5%,1024 写者的病态场景提升 50%。之所以优化 unsigned 32-bit 数组是因为 XidInMVCCSnapshot() 只处理 xid/subxid。哈希表方案虽然扩展性更好,但因代码复杂度和内存分配顾虑被否决。这属于 CPU 指令集加速在事务快照可见性判断路径上的应用,配合 PG14 的 GetSnapshotData 优化,共同缓解高并发小事务的快照/可见性瓶颈。

PostgreSQL 2PC 的三个命令是什么?prepared transaction 为什么危险?

2PC(两阶段提交)的用户命令是 PREPARE TRANSACTION ‘gid’、COMMIT PREPARED ‘gid’、ROLLBACK PREPARED ‘gid’,面向外部事务管理器,模型接近 X/Open XA。PREPARE TRANSACTION 执行后事务脱离原会话(对原会话像 ROLLBACK),写入暂时不可见,但 prepared transaction 会继续持有锁和 XID 边界,并通过 dummy PGPROC 挂在全局事务、锁和可见性基础设施里。危险在于:1) prepared 状态被长期遗忘时,它继续占用锁(pg_locks.pid 可能为空,需联查 pg_prepared_xacts),阻塞 DDL/DML;2) 它仍被认为 in-progress,固定 MVCC 清理边界,干扰 VACUUM 回收,极端情况造成 XID wraparound 风险;3) 短生命周期只靠 WAL 和共享内存,跨 checkpoint 才写 pg_twophase 文件,若 pg_twophase 长期有文件说明存在未结束的 prepared transaction。官方建议不用就保持 max_prepared_transactions=0。健康系统里 prepared 应在秒级关闭,超过分钟级要告警。

PostgreSQL 三个实质隔离级别(Read Committed / Repeatable Read / Serializable)的快照边界和差异是什么?

PostgreSQL 内部实现三个隔离级别(READ UNCOMMITTED 按 READ COMMITTED 处理):1) Read Committed:每条语句开始时取新快照,能读到本事务内其他会话已提交的最新结果,不读未提交数据,但同一事务两次查询可能看到不同结果。2) Repeatable Read:事务第一条非控制语句取一次快照并固定,防不可重复读,PG 中也不出现幻读;但本质是 Snapshot Isolation,仍可能出现 serialization anomaly(写偏斜),写冲突时报 could not serialize access due to concurrent update。3) Serializable:在 Snapshot Isolation 之上加 SSI(Serializable Snapshot Isolation),监控并发事务间的 rw-conflict(读写依赖),出现 Tin->Tpivot->Tout 危险结构时回滚事务,保证成功提交的并发事务等价于某个串行顺序,代价是可能返回 SQLSTATE 40001,应用必须完整重试整个事务。快照边界在 snapmgr.c 的 IsolationUsesXactSnapshot() 分支体现。

PostgreSQL 为什么事务启动时不立即分配 XID?VXID 和 XID 有什么区别?

PostgreSQL 事务系统分三层:底层事务/子事务、每条查询前后的控制代码(StartTransactionCommand/CommitTransactionCommand/AbortCurrentTransaction)、用户可见的 BEGIN/COMMIT/ROLLBACK/SAVEPOINT。StartTransaction() 启动时分配的是 VXID(VirtualTransactionId,由后端进程号 + 本地递增编号组成),不一定分配真正的 XID。AssignTransactionId() 的注释说明:事务和子事务只有在需要时才分配永久 FullTransactionId;如果子事务需要 XID,父事务会先获得 XID,从而保证子事务 XID 晚于父事务。这个"延迟分配 XID"的设计很重要:大量只读短事务不消耗 XID,降低 XID 增长速度,减少 pg_xact 维护压力。反过来,调用 pg_current_xact_id() 会强制分配 XID;如果只是观测,应优先用 pg_current_xact_id_if_assigned() 避免不必要消耗。

PostgreSQL 事务提交的内部路径是怎样的?synchronous_commit=off 会丢数据吗?

提交关键不是"把变量设成 committed",而是让崩溃恢复能证明它已提交。CommitTransaction() 调用 RecordTransactionCommit(),流程:1) 收集待删除文件、子事务、缓存失效消息等;2) 若有 XID,进入 commit critical section,写 XactLogCommitRecord();3) 根据 synchronous_commit 和是否有强制同步条件,决定立即 XLogFlush() 还是异步提交;4) WAL 满足后,用 TransactionIdCommitTree()/AsyncCommitTree() 更新 pg_xact 中主事务和子事务的提交状态;5) 需要同步复制时调用 SyncRepWaitForLSN();6) 调用 ProcArrayEndTransaction() 让其他后端不再把它看作运行中事务,再释放锁和资源。synchronous_commit=off 允许先向客户端返回成功、稍后刷 WAL,这不会破坏数据库一致性(状态等价于这些事务干净回滚),但崩溃时可能丢失少量已返回成功的事务。因此它适合可重放、可补偿的低价值事件,不适合核心账务。

PostgreSQL 子事务(savepoint/EXCEPTION)的性能开销有多大?64 这个数字是什么?

子事务开销:1) 每个 savepoint 消耗一个 XID;2) 每个 savepoint 消耗 8K 会话本地内存(CurTransactionContext);3) 关键分水岭是 64——PGPROC 里最多缓存 PGPROC_MAX_CACHED_SUBXIDS=64 个活跃子事务 ID。深度 <64 时子事务 ID 存在快速局部内存,开销微乎其微;超过 64 或单事务累积大量子事务后,PG 必须把 ID 溢写到慢速 SLRU 缓存(pg_subtrans),可见性判断要回头查 pg_subtrans,并与其他事务争抢 SubtransControlLock。实测:单用户下溢出 64 层开销可能只增加约 1%,但并发一上升(10+ 用户)因锁竞争执行时间会 50x-100x 爆炸。5000 行插入的基线对比:标准循环 0.02s/48 bytes WAL/1 XID;单层 EXCEPTION 0.03s/43936 bytes WAL/5001 XID;溢出 128 层 3.82s/5536832 bytes WAL/640002 XID——子事务记录保存点元数据的 WAL 量是普通插入的 10 万倍。监控不要只看 CPU,去查 pg_stat_slru 的 blks_read 是否激增。

PostgreSQL 崩溃恢复时如何处理 prepared transaction?为什么它不能像普通未提交事务那样被回滚?

普通未提交事务崩溃后被视为 abort,但 prepared transaction 不能这样处理:它已经对外部事务管理器承诺"我准备好了",必须重启后继续等待最终决定。恢复路径:restoreTwoPhaseData() 在恢复开始时扫描 pg_twophase 把状态加入 TwoPhaseState;WAL redo 遇到 XLOG_XACT_PREPARE 时 xact_redo() 调用 PrepareRedoAdd() 重建 prepared 状态;遇到 prepared commit/abort 时先按事务结束记录处理,再调用 PrepareRedoRemove() 删除条目或文件;PrescanPreparedTransactions() 在启动阶段扫描 prepared xact 推进 nextXid、处理子事务范围;RecoverPreparedTransactions() 在恢复结束前重建 dummy PGPROC、子事务关系和锁;hot standby 还有 StandbyRecoverPreparedTransactions() 让备库查询把 prepared 当活跃事务处理。恢复测试 009_twophase.pl 和 023_pitr_prepared_xact.pl 覆盖了这些场景——PITR 到 PREPARE TRANSACTION 之后的 restore point,恢复出的节点仍需显式 COMMIT PREPARED 数据才可见。

PostgreSQL 的 2PC(两阶段提交)是如何工作的,代价是什么?

2PC 用 PREPARE TRANSACTION ‘gid’ 把本地事务推进到可提交/回滚状态并持久化,再由协调者统一发 COMMIT PREPARED 或 ROLLBACK PREPARED。prepared 事务会用 dummy PGPROC 继续持有锁和 XID,prepared 状态通过 WAL(XLOG_XACT_PREPARE)和跨 checkpoint 时的 pg_twophase 文件持久化。代价:占用锁和 MVCC 清理边界、额外 WAL、需要 max_prepared_transactions(默认 0 禁用)和外部事务管理器,长时间遗忘会拖住 vacuum。

PostgreSQL 的 MVCC 快照是如何判断一个行版本对当前事务是否可见的?xmin/xmax 各代表什么?

PostgreSQL 的 heap 表更新不是原地覆盖,而是产生新行版本,行版本头里有 xmin 和 xmax:xmin 记录创建该行版本的事务 ID,xmax 记录删除或更新该行版本的事务 ID(无效时为 0)。快照结构(snapshot.h)由 xmin、xmax、xip[]、subxip[] 组成:xmin 是小于它的事务都已结束的边界,xmax 是大于等于它的 XID 对快照不可见的边界,xip 是快照时刻仍在运行的顶层事务列表。可见性判断核心在 heapam_visibility.c 的 HeapTupleSatisfiesMVCC():xmin 未提交或在 xip 中→不可见;xmin 已提交且 xmax 无效→可见;xmin 可见且 xmax 只是锁行(非删除/更新)→可见;xmin 可见且 xmax 已提交且对该快照可见→不可见;xmin 可见但 xmax 在快照时仍在运行→可见(因为删除尚未发生)。hint bits 会缓存 XID 提交/回滚状态,减少反复查询 pg_xact 的开销。这解释了为什么 UPDATE 会产生膨胀:旧版本不能立即删除,因为可能有旧快照还需要看到它。

PostgreSQL 的 Serializable 隔离级别是怎么实现的?为什么说它"不是加大锁"?

PG 的 Serializable 不是传统的 Strict Two-Phase Locking(严格两阶段锁),而是基于 Snapshot Isolation 之上增加 SSI(Serializable Snapshot Isolation)检测,核心在 src/backend/storage/lmgr/README-SSI 和 predicate.c。它监控并发事务之间的 rw-conflict(读写依赖)关系,读不阻塞写、写不阻塞读;只有当冲突图出现可能导致异常的"危险结构"(Tin -> Tpivot -> Tout 两个相邻读写冲突)时,才回滚某个事务。工程含义:1) Serializable 不等于没有代价,它需要谓词锁(SIReadLock)、冲突跟踪和可能的事务重试;2) 它也不一定更慢——如果你本来要显式表锁或大量 SELECT FOR UPDATE 来防业务异常,Serializable 反而可能减少阻塞;3) 应用必须按 SQLSTATE 40001 做完整事务重试,不能只重试最后一条 SQL;4) SERIALIZABLE READ ONLY DEFERRABLE 适合长报表,它可能在开始时等待一个安全快照,随后避免普通 Serializable 的冲突开销。可通过 pg_locks 中 mode=‘SIReadLock’ 观察谓词锁。

PostgreSQL 的 XID 为什么是 32 位?XID 回卷(wraparound)是什么,如何防范?

PostgreSQL 的 TransactionId(XID)是 32 位无符号整数,超过约 40 亿事务后会发生环绕(wraparound),重新从低值开始。为了防止"旧版本突然看起来像未来版本",每个表必须周期性 vacuum,并把足够老的行版本标记为 frozen(冻结)。相关参数和字段围绕这个边界工作:relfrozenxid、datfrozenxid、vacuum_freeze_min_age、vacuum_freeze_table_age、autovacuum_freeze_max_age。监控用 age(datfrozenxid)、age(relfrozenxid) 查看事务年龄,age 越大越接近回卷风险。冻结无法推进的典型根因是:idle in transaction、老 prepared transaction、复制槽的 xmin/catalog_xmin、长时间运行的报表查询,这些都会固定清理边界。极端情况下需要停库进入单用户模式手工执行 freeze 降低年龄。另外 PG 用 64 位 xid8(含 epoch)表达完整事务号,XID 在事务首次写数据时才分配,只读事务不消耗 XID(可用 pg_current_xact_id_if_assigned() 观测而不强制分配)。

PostgreSQL 的 advisory lock 有哪些应用场景?

advisory lock 是应用自定义的、由应用提供的 key 决定的锁,与数据库对象无关。典型场景:限制每个分组最多有多少条记录、实现分布式互斥、串行化某段业务逻辑、防止并发重复处理等。它分 session 级和 transaction 级(xact),session 级需显式 unlock,xact 级随事务结束自动释放。

PostgreSQL 的 fast-path lock 是什么,fastpath_exceeded 指标说明什么?

fast-path lock 是 PG 为常见低冲突 relation 锁设计的快捷通道,优先放进 backend 自身的 fast-path 槽位,避免进全局主锁表(重路径)。fastpath_exceeded 统计本可走 fast-path 但因槽位不够(max_locks_per_transaction 限制)退回普通路径的次数,它不是锁冲突次数,而是锁获取路径退化压力指标,升高意味着锁管理成本变贵,常见于分区过多、事务过宽、单 SQL 接触大量对象。

PostgreSQL 的 fast-path lock 是什么?fastpath_exceeded 指标升高说明什么?

fast-path lock 是 PG 给常见低冲突 relation 锁准备的"快捷通道",核心目标是不让所有锁都进全局主锁表(主锁表是共享结构,并发高时更贵)。普通 SELECT/INSERT/UPDATE/DELETE 都会对涉及表取弱 relation 锁,若每次都抢主锁表分区 LWLock 会成为瓶颈。9.2 起每个 backend 可在自己的 PGPROC 里记录有限数量的弱锁(AccessShareLock/RowShareLock/RowExclusiveLock),靠 FastPathStrongRelationLocks 计数在强锁请求时把匹配弱锁迁移到主表。PG19 的 pg_stat_lock 视图新增 fastpath_exceeded 指标,统计"锁本可走 fast-path 但因 max_locks_per_transaction 槽位容量不够而退回普通路径的次数"。它升高不等于锁冲突或锁等待,而是"锁获取从便宜路径退化到昂贵路径的压力信号",典型根因是分区过碎、事务过宽、单条 SQL 访问对象过多。正确动作是先减对象数、减事务宽度、优化分区裁剪,最后才考虑调大 max_locks_per_transaction。

PostgreSQL 的事务隔离级别和快照边界是怎样的?

PG 实现三个实质隔离级别:Read Committed 每条语句取新快照,防脏读但同一事务两次读可能不同;Repeatable Read 事务第一条非控制语句取快照,防不可重复读和幻读但仍可能 serialization anomaly;Serializable 在快照隔离上加 SSI 检测 rw-conflict 危险结构,成功提交可串行化但可能报 40001 需重试。READ UNCOMMITTED 按 READ COMMITTED 处理。

PostgreSQL 的死锁检测算法是怎么工作的?为什么说它采用"乐观等待"?

PG 采用 optimistic waiting(乐观等待):进程拿不到锁时不立即做死锁检查,而是先睡眠,并设置 deadlock_timeout(默认 1 秒)的延时定时器;超时还没拿到锁才运行死锁检测代码。核心在 deadlock.c,把等待关系看成 waits-for graph(WFG):A 等 B 则有 A->B 的边。已持有冲突锁形成 hard edge;队列前方等待者因顺序挡住后方形成 soft edge。若从当前等待进程出发能回到自己,就存在涉及自己的死锁。检测算法 FindLockCycle() 递归沿 waits-for 边向外搜索:若所有路径终止于运行中的进程则无死锁;若回到起点则报告死锁并中止起点事务(cancel 一个请求即可打破环,不必杀掉全部事务);若回环到其他节点说明死锁不涉及自己,忽略。soft edge 引发的死锁可通过重排等待队列解除(topological sort 尝试),无法重排或全是 hard edge 才 abort 事务。谁先检测出死锁就 rollback 谁。

PostgreSQL 的重量锁(heavyweight lock)和轻量锁(LWLock)有何区别?

重量锁(relation/tuple/transactionid 等)记录在锁表,可被用户查询(pg_locks),支持死锁检测,用于表、行、事务 ID 等用户级并发控制。轻量锁(LWLock)是内核内部共享内存结构的短临界区保护(如 buffer mapping、WAL、CLog),通常持有时间极短,不参与死锁检测,属于内核实现细节,高并发下 LWLock 争用会成为瓶颈。

PostgreSQL 的重量锁(heavyweight lock)有哪些锁模式?冲突矩阵如何判断?

标准重量锁模式定义在 lockdefs.h:AccessShareLock(普通 SELECT,只与 AccessExclusiveLock 冲突)、RowShareLock(SELECT FOR UPDATE/SHARE,与 ExclusiveLock、AccessExclusiveLock 冲突)、RowExclusiveLock(INSERT/UPDATE/DELETE/MERGE,与 ShareLock 及更强锁冲突)、ShareUpdateExclusiveLock(VACUUM/ANALYZE/CREATE INDEX CONCURRENTLY)、ShareLock(非并发 CREATE INDEX)、ShareRowExclusiveLock(CREATE TRIGGER,自冲突)、ExclusiveLock(REFRESH MATERIALIZED VIEW CONCURRENTLY,只允许普通读并发)、AccessExclusiveLock(DROP/TRUNCATE/VACUUM FULL/多数 DDL,与所有模式冲突)。冲突规则不是 if/else,而是 lock.c 的 LockConflicts[] 位图(conflictTab[requested_mode] & lock->grantMask 快速判断)。注意:名字里带 ROW 的(RowExclusiveLock 等)也是表级锁,不是行锁;行锁信息存磁盘元组里,等待行锁通常表现为等待持有者的 transactionid。

PostgreSQL 锁等待队列的插入算法有什么特殊之处?为什么会出现"软阻塞"?

当请求与已授予锁或队列中更早的冲突请求不兼容时,进程进入 LOCK.waitProcs。正常情况下插入队尾,但有例外:如果当前进程已经持有该锁对象上的某些锁,而队列中已有等待者请求会被它挡住,那么当前进程会被插入到第一个这样的等待者之前(rearrange wait order)。这种插入方式的目的就是消除 soft deadlock——等待队列中也可能出现死锁(soft deadlock),通过调整等待顺序来解决。还有一个特殊情形:如果插入点之前没有冲突,就直接授予锁而不等待。这解释了生产现象:普通 SELECT 被挡住,不一定直接与当前持锁者冲突,可能是它排在一个等待 AccessExclusiveLock 的 DDL 后面,被等待队列顺序"软阻塞"了。死锁检测代码在必要时也会重排等待队列来打破只涉及 soft edge 的环。

SKIP LOCKED 在 PG 中的典型应用是什么?

SKIP LOCKED 配合 SELECT … FOR UPDATE SKIP LOCKED,用于并发队列消费:多个 worker 同时从队列表取出未处理的行,每个 worker 只锁定并处理自己抢到的行,跳过已被其他 worker 锁定的行,避免互相阻塞,实现高效的任务分发。常配合 NOWAIT 使用。

advisory lock 的会话级和事务级有什么区别?连接池场景下有什么坑?

advisory lock 复用普通锁管理器,lock method 是 USER_LOCKMETHOD,SQL 层只暴露 shared/exclusive 两类语义。生命周期分两种:1) 事务级(pg_advisory_xact_lock / pg_try_advisory_xact_lock):到当前事务结束时自动释放,没有显式 unlock 函数,适合短临界区、请求内互斥、任务抢占。2) 会话级(pg_advisory_lock / pg_try_advisory_lock):COMMIT 或 ROLLBACK 后仍持有,需 pg_advisory_unlock / pg_advisory_unlock_all 显式释放,会话结束时服务器自动清理,DISCARD ALL 也会释放。连接池场景是最大坑:连接池里"关闭连接"往往只是归还给池,不是 session 结束,会话级锁会残留,下一个借用该连接的请求可能继承一个完全不知道的锁状态。规避:请求内临界区优先用事务级锁;必须用会话级时在 finally 中释放,或归还前执行 pg_advisory_unlock_all()。另外会话级锁多次获取会 stack,锁三次要 unlock 三次才释放(来自 LOCALLOCK 的 nLocks 计数)。

advisory lock 能做哪些业务场景?它的局限是什么(为什么不能当约束)?

advisory lock 适合单库内的任务去重、leader election、后台 job 抢占、对不存在稳定数据行的资源做临时互斥,以及想避免锁表膨胀和异常退出清理成本的应用协议。典型场景:同一租户同一时刻只跑一个对账任务,用 pg_try_advisory_xact_lock(namespace_id, tenant_id) 抢锁。局限:1) 它不是约束,只是应用协议——别人只要不拿同一个 advisory key 就能照常 DML,绕过协议的代码不会被拦住;强一致规则(唯一性、库存非负、时间段不重叠)应交给 unique index、exclusion constraint、foreign key 或隔离级别。2) 它不写 WAL,不传播到 standby,主库拿锁不会阻塞备库查询,不能做主备一致互斥。3) 每个不同 key 占用共享锁表资源(受 max_locks_per_transaction 限制),大量细粒度 key 会耗尽共享内存。4) 阻塞版本函数可能让业务线程长期挂起,高并发服务应优先 try-lock + 退避。

cached plan must not change result type 错误的原因和解法是什么?

这个错误发生在使用 prepared statement(或 PL/pgSQL 中的 cached plan)时,同一个 SQL 文本在不同执行间返回结果集的结构(列类型/列数)发生了变化。因为 PG 会缓存执行计划,计划缓存假设结果类型不变,一旦类型变化(典型场景:SELECT * FROM 视图或函数返回类型变化、表结构变更、递归 CTE、多态函数参数变化等)就会报 “cached plan must not change result type”。解法:1) 让 SQL 的结果类型稳定,避免依赖会变化的结构;2) 使用 DEALLOCATE 或重新 prepare 使计划失效;3) 在 PL/pgSQL 中用 EXECUTE 动态执行(动态 SQL 不走 cached plan)绕过;4) 版本升级或结构变更后让依赖的计划重新生成。本质是 plan cache 与结果集结构的一致性约束,遇到此错误应检查是否 DDL 变更了涉及对象的返回结构,或 SQL 本身的结果类型依赖运行时变化。

fast-path 锁为什么需要 FastPathStrongRelationLocks 计数?强锁请求时如何处理?

fast-path 锁把弱 relation 锁(AccessShareLock/RowShareLock/RowExclusiveLock)放在 backend 本地 PGPROC 槽位里,不进主共享锁表。但这里有个正确性问题:如果所有弱锁永远留在本地,那么当有人请求强锁(如 AccessExclusiveLock)时,冲突判断看不到这些本地弱锁,会错误地授予强锁。解决机制是 FastPathStrongRelationLocks 计数:强锁请求会增加对应分区的 strong-lock 计数,然后扫描各 backend 的 fast-path 槽,把匹配的弱锁迁移到主共享锁表,保证冲突判断和死锁检测看到完整状态。这就是"平时读写少付成本,强 DDL 来时仍能获得正确冲突判断"的设计。同理,重量锁获取路径中,强 relation 锁会先阻止新的 fast-path,并把相关 fast-path 锁迁移进主表。所以 fast-path 不是无条件的优化,它有清晰的触发迁移条件来保证正确性。

lock_timeout、statement_timeout、deadlock_timeout、idle_in_transaction_session_timeout 各控制什么?

这几个超时参数作用域不同:1) statement_timeout:限制整个语句执行时间(含 CPU/IO/锁等待),超时终止语句,适合防慢查询拖垮系统。2) lock_timeout:只限制等待锁的时间,锁等待超时立即失败(不终止整个语句的后续可能),适合 DDL 预检、防止雪崩,建议会话/角色/作业级别设置而非全局。3) deadlock_timeout:死锁检测的延时,进程拿不到锁先睡眠,超过该时间才运行死锁检测;默认 1 秒,调太小会频繁唤起死锁检测浪费 CPU,调太大真实死锁报告变慢。4) idle_in_transaction_session_timeout:空闲事务超时,超时终止连接(释放快照和锁),防 idle in transaction 固定 xmin;5) idle_session_timeout:空闲会话超时(PG14 支持,配合插件 pg_timeout 早期实现)。区别要记牢:statement_timeout 管整个语句,lock_timeout 只管锁等待,deadlock_timeout 不是超时而是检测延迟。log_lock_waits 在等待超过 deadlock_timeout 后记录锁等待日志。

pg_statement_rollback 插件是怎么实现"语句级回滚"的?它比客户端方案好在哪?

PostgreSQL 原生在事务中遇到错误后整个事务 abort(current transaction is aborted),无法继续。Oracle/DB2 会在每条语句前隐式设 savepoint,失败可回滚到语句前状态。pg_statement_rollback 插件在服务端实现自动 savepoint 和语句级回滚:1) 每条写语句前自动执行 SAVEPOINT;2) 出错后应用调用 ROLLBACK TO SAVEPOINT “PgSLRAutoSvpt” 即可继续事务;3) 通过 pg_statement_rollback.enabled、savepoint_name、enable_writeonly 等 GUC 配置。相比 psql 的 ON_ERROR_ROLLBACK、JDBC 的 autorollback 等客户端方案,服务端方案省去了额外的 SAVEPOINT/RELEASE SAVEPOINT 网络往返,吞吐损耗很小(pgbench 实测 TPC-B 场景从 742 tps 降到约 722 tps,约 3% 开销)。注意:enable_writeonly 默认开启,避免对 SELECT 也自动 savepoint 而塞满子事务缓存(PGPROC_MAX_CACHED_SUBXIDS=64),如果事务内语句超 64 条会触发 pg_subtrans 磁盘扫描性能下降。

pg_xact 和 pg_subtrans 分别存什么?每个事务状态占多少位?

pg_xact(历史名 CLOG)由 clog.c 管理,记录事务提交状态,每个事务状态占 2 bit(一个字节记录 4 个事务,一个 BLCKSZ 页面记录 BLCKSZ*4 个事务状态),TransactionIdSetTreeStatus() 会把顶层事务和子事务树状态一起设置。pg_subtrans 由 subtrans.c 管理,记录 subxid 的直接父 XID,它不是长期历史表,只需保留当前打开事务所需的信息,信息随包含事务结束即失效。子事务过多时,PGPROC 里最多缓存 PGPROC_MAX_CACHED_SUBXIDS(当前值 64)个 subxid,溢出后可见性检查会更多依赖 pg_subtrans(涉及 SubtransControlLock 竞争)。实践含义:不要在一个大事务里创建成千上万个 savepoint,也要警惕 PL/pgSQL EXCEPTION 在循环里隐式制造大量子事务。

plan_cache_mode 参数是什么?OLTP 和 OLAP 分别该怎么设置?

plan_cache_mode(PG12 引入)控制 prepared statement 的执行计划缓存策略,可选 auto / force_generic_plan / force_custom_plan。custom plan 是每次对新的参数值重新生成计划,generic plan 是重复执行时复用同一计划,默认 auto 自动选择。auto 的行为是前 5 次执行生成 custom plan,若 custom plan 成本始终不低于 generic plan 则切换到 generic plan,避免数据倾斜导致计划退化。设置建议:1) OLAP(复杂分析查询)并发低、每次条件输入选择性差异大,不同参数可能需不同执行计划,建议 force_custom_plan;可针对不同用户/数据库设置,如 alter role AP用户 set plan_cache_mode to force_custom_plan;2) OLTP 并发高、数据倾斜少,建议 auto;若数据保证完全不倾斜可用 force_generic_plan 减少 plan 生成开销。注意该设置在缓存计划被执行时生效而非 prepare 时,且计划缓存行为可能随版本变化,强制计划类设置应定期重评估。

plan_cache_mode 参数有哪几种模式,各适合什么场景?

plan_cache_mode 有三种值:auto(默认,自动选择)、force_custom_plan(每次用新参数重新生成计划)、force_generic_plan(复用同一通用计划)。OLAP 复杂分析查询条件选择性差异大,建议 force_custom_plan(可对特定 role/database 设置);OLTP 高并发数据不倾斜建议 auto 或 force_generic_plan。

slotsync worker 遗漏释放 PostmasterContext 的问题是什么?log_lock_waits 测试增强有什么意义?

PG19 有两个修复:1) commit 93dc1ace 修复 slotsync worker 遗漏释放继承的 PostmasterContext 的问题。postmaster 启动时创建 PostmasterContext(工作内存上下文)用于分配启动数据;fork 出的子进程(autovacuum、syslogger 等)会正确释放它,但 slotsync worker 遗漏了这一步,导致该上下文在 slotsync 进程生命周期内持续保留未释放(虽然是内存泄漏式的资源浪费,且 slotsync 是逻辑复制中把 primary 上启用 failover 的逻辑复制槽同步到 standby 的重要 worker)。2) commit ca2b544 为 log_lock_waits 增加完整 TAP 测试覆盖——log_lock_waits 是重要的锁等待诊断参数(等待超 deadlock_timeout 后记录锁等待日志),之前缺乏专门的 TAP 测试验证其行为正确性。这两个"小"改动分别提升复制槽管理稳定性和锁等待日志行为的可靠性保障。

不同会话能同时更新同一条记录的不同字段吗?为什么?

不能。数据库最小粒度的锁是行锁,同一行的行级别排他锁在一个时刻只能被一个会话持有,其他会话要等待。即使更新的是不同字段(一个更新 info、一个更新 ts),也会互相阻塞。验证:会话 1 update t set info=‘abc’ where id=1 不提交,会话 2 update t set ts=now() where id=1 会阻塞,等待链条表现为 locktype=‘transactionid’,持锁方 Mode=ExclusiveLock granted=true,等待方 Mode=ShareLock granted=false(等待事务 ID 锁)。日志开启 log_lock_waits=on 会看到 “process 98 still waiting for ShareLock on transaction 735 after 1001.169 ms”。因为 PG 的 MVCC 和行锁是行粒度,更新任一行都会把整行加锁并产生新版本,无法字段级并行。业务层面的解法:1) 把需要并行更新的字段拆到多张表用 PK 关联;2) 把同一行更新合并到一个会话/事务;3) 产品层面期望未来能把锁粒度/多版本控制下推到字段甚至 JSON/array 元素级别(目前 PG 不支持)。

为什么 PG 高并发数据写入吞吐达不到磁盘极限?几个重大锁是什么?

高并发写入吞吐无法达到磁盘极限(如磁盘 4GB/s 但写不满)主要是几个重大锁的串行化:1) 申请 WAL 片段的串行排他锁;2) 高并发小事务的 ProcArrayEndTrans、GetSnapshotData 开销(快照计算);3) 每次数据文件空间不足时一次只扩展 1 个 block,批量写入时频繁扩展数据块——数据文件扩展串行排他锁、索引构建和页扩展串行排他锁;4) 扩展数据块导致文件长度变化需修改 inode,文件系统层面 inode 锁。极限测试法(可达 NVMe SSD 物理极限 3.6GB/s)用:unlogged table(避免写 WAL)、无索引无 PK/UK 约束(避免页分裂)、多表并行导入(避免单表扩展锁冲突)、大 block size(减少扩展冲突)、COPY 协议。PolarDB 支持 polar_bulk_extend_size 一次扩展多个数据块,缓解数据文件/索引页扩展的排他锁瓶颈,对 IOT/时序/feedlog 等高速写入场景收益大。

为什么 PG 高并发短连接/小事务性能差?涉及的快照和可见性瓶颈在哪?

PG 多进程架构下,高并发小事务性能差的根本原因是几个高频共享元数据访问点:1) 快照获取 GetSnapshotData() 是 O(连接数) 的,且要扫描每个进程的 PGXACT(xmin 频繁修改导致 cacheline ping-pong,见 PG14 优化);2) tuple 可见性判断 XidInMVCCSnapshot() 要在 xip/subxip 数组中线性搜索 XID(PG16 用 SSE2 加速);3) 事务结束 ProcArrayEndTransaction 需拿 ProcArrayLock 排他锁更新活跃事务数组;4) WAL 插入的锁竞争;5) buffer 管理和锁表分区竞争。这些元数据访问点都会随连接数和事务密度上升而成为瓶颈。优化方向:连接池控制并发数(PG 对几千连接不友好,虽然 PG14 后 5000 连接仍可百万 tps 但应用仍应限制)、缩短事务、批量提交、减少无效写。这也是为什么连接池(pgbouncer 等)对 PG 尤其重要。

为什么 Serializable 的 SIReadLock 谓词锁不阻塞写?它和普通锁在 pg_locks 里都出现但有何不同?

SIReadLock(Serializable 隔离级别的谓词锁)用于记录并发事务之间的读写依赖(rw-conflict),不是用来阻塞访问的普通锁。它记录"这个事务读过哪些 relation/page/tuple",当另一个事务写这些目标时,通过 rw-conflict 检测潜在的危险结构(Tin -> Tpivot -> Tout),必要时回滚事务。因为它的目的是"事后检测异常"而不是"事前阻塞冲突",所以 SIReadLock 不阻塞写操作。相比之下,普通重量锁(relation、transactionid、advisory 等)的目的是通过冲突矩阵在访问前阻塞不兼容的操作。所以二者都出现在 pg_locks 里(locktype=‘SIReadLock’ 时 mode=‘SIReadLock’),但语义完全不同:谓词锁是 Serializable 的依赖跟踪工具,普通锁是并发互斥工具。这也解释了 Serializable 下读不阻塞写、写不阻塞读的原因。谓词锁粒度(tuple/page/relation)取决于执行计划,可能升级(锁升级会增加 serialization failure 概率)。

为什么 idle in transaction 事务危害很大?backend_xid 和 backend_xmin 分别代表什么?

idle in transaction(空闲事务)的危害:有 backend_xid 或 backend_xmin 的会话(除了 vacuum),不管处于什么状态,超出这个值之后新启动事务产生的垃圾 tuple 都不能被 vacuum 回收。区别:backend_xid 是当前事务本身分配的 XID(该事务写过数据);backend_xmin 是该事务快照中能看到的最老 XID(该事务只是读过数据,如 RR 隔离级别 select 后挂着)。影响:1) 若系统还有大量 update/delete,时间久了导致表、索引膨胀,浪费空间、性能变差、备份/恢复变长;2) 时间非常久可能触发事务回卷警告,极端需停库进入单用户模式手工 freeze。事务 abort 后会自动释放 snapshot(backend_xmin/xid 清空),所以 abort 的事务相对安全。解决方案:业务层避免框架自动开启事务;设置 idle_in_transaction_session_timeout 自动释放长时间空闲事务;设置 old_snapshot_threshold 避免 vacuum 长时间做不下去。空闲的只读事务(未获得过快照)不影响,因为没有 backend_xid/xmin。

为什么空闲事务和慢 2PC 会导致表膨胀(GetOldestXmin)?

GetOldestXmin 返回系统中最老的事务号,无论隔离级别,只看最老。RC 事务的 SQL 快照本应只是语句发起时,但空闲事务拿到过快照后,其 xmin 会卡住 GetOldestXmin,使之后产生的垃圾 tuple 无法被 vacuum 清理。未结束的 2PC 事务同理。优化思路:内核让空闲/2PC 事务的 oldestxmin 用当前最小未分配事务号代替;参数上可设 old_snapshot_threshold 或 idle_in_transaction_session_timeout。

为什么长时间等待业务处理不建议封装在一个长事务里?

长事务(begin; sql1; …等待业务处理…; sql2; …; end)有两个核心问题。问题 1:从最老的 backend_xid/backend_xmin 事务号之后产生的垃圾无法被回收,影响 freeze xid,导致:1) 表膨胀,存储成本增加、内存消耗增加、性能变差、备份/恢复时间变长;2) freeze xid 极端影响——XID 是 32 位需重复使用,长时间不 freeze 可能需停库进入单用户模式手工 freeze;3) 积累久了可能出现大量表同时需要 freeze,导致 IO 暴增、WAL 日志暴增、standby 延迟甚至中断、归档压力暴增。问题 2:长时间持有锁,堵塞未来的 SQL 锁请求,增加死锁隐患。解决办法:事前从业务层拆分大的原子操作为多个小原子操作并做好回退逻辑;数据库设置 statement_timeout、lock_timeout、idle_in_transaction_session_timeout、idle_session_timeout、old_snapshot_threshold 兜底。长时间不结束的 2PC 危害相同。

什么是 MultiXact?它和行锁、外键有什么关系?

MultiXact(multitransaction)是 PG 用来表示"多个事务同时以共享方式锁同一行"的结构。场景:多个事务同时对同一行执行 SELECT FOR SHARE(或 FOR KEY SHARE),单个 xmax 字段装不下多个事务 ID,PG 就用一个 MultiXactId 表示这组事务的集合。它和外键密切相关:外键约束检查时会对被引用行加 FOR KEY SHARE 锁,多个会话并发插入引用同一父行时,就会在该父行上形成 MultiXact。影响:1) 等待行锁时可能表现为等待 multixact 类型的锁而非单个 transactionid;2) MultiXact 有自己的 SLRU(pg_multixact)和回卷管理(relminmxid/mxid_age 监控);3) 大量 FOR SHARE/FOR KEY SHARE 并发(典型是外键热点父行)会造成 MultiXact 膨胀和争用,是"外键热点"性能问题的根源之一。SKIP LOCKED 遇到 MultiXact 时用 ConditionalMultiXactIdWait 条件等待。

在带 LIMIT 的查询里直接调用 advisory lock 函数有什么陷阱?如何安全批量加锁?

官方文档明确警告:不要假设 LIMIT 一定先于锁函数求值。危险写法 SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100 中,优化器和执行器的表达式求值顺序可能导致锁函数在 LIMIT 之前对更多行求值,拿到超出预期的锁,尤其是会话级锁会形成难排查的残留。安全写法是先用子查询确定候选集合:SELECT pg_advisory_lock(q.id) FROM (SELECT id FROM foo WHERE id > 12345 ORDER BY id LIMIT 100) AS q,让"最多 100 个 id"这个集合先物化或形成边界,再对集合内 id 调用锁函数。另外 advisory lock 函数 proparallel=‘r’(parallel restricted),不要假设它可以安全下推到并行 worker,尽量在进入并行查询前完成锁控制。key 设计要有命名空间,两个 int key(namespace, resource_id)往往比 hash 后的 bigint 更易运维。

如何快速定位是谁堵塞了某个等待中的进程?

用 pg_blocking_pids(pid) 函数,返回阻塞指定进程获取锁的 PID 数组(硬阻塞或软阻塞)。对于 SSI 隔离级别下请求安全快照冲突,用 pg_safe_snapshot_blocking_pids(pid) 返回阻塞其获取 safe snapshot 的 PID。结合 pg_stat_activity 的 wait_event_type/wait_event 可看到等待链。

如何用 advisory lock 实现"堵塞式读、不堵塞写"(串行读、并行写)的变态需求?

需求:允许并行写,不允许并行读(同一行/同一资源的读要串行)。用 advisory lock 实现,因为普通写(不同行不冲突)天然并行,只需对"读"加互斥。两种方式:1) 不堵塞读但也不返回未拿锁的读结果:select * from a where id=? and pg_try_advisory_xact_lock(?),拿不到共享锁的行不返回(0 行),拿到才返回,事务结束自动释放。2) 堵塞读,等其他读结束:begin; select pg_advisory_xact_lock(?); select * from a where id=?,拿不到锁就等待,等前一个读事务结束释放后再读。3) 一句搞定用 CTE:with locks as (select 1 as locks from pg_advisory_xact_lock(1)) select a.* from a,locks where id=1。核心是 advisory lock 与事务生命周期绑定(xact_lock 事务结束自动释放),写操作不参与 advisory 锁所以不互相阻塞,读操作通过 advisory 锁串行化。

如何用 advisory lock 实现串行读、并行写?

advisory lock 不依赖表结构,是应用自定义的锁。对同一行用 pg_try_advisory_xact_lock(id) 包在查询里:拿不到锁的会话不返回该行记录(0 rows),拿到锁的返回,事务结束自动释放锁。这样写操作互不冲突(只要不是同一行),读操作对同一行串行化,实现并行写、串行读。

用 CTID 实现 update/delete limit 时,并发 DML 下有什么隔离性问题?怎么解决?

用 ctid in (select ctid from tbl where …) 实现 update/delete limit 时存在并发隔离性问题:session1 的子查询先执行得到 ctid(如 (0,1)),期间 session2 更新了同一行(id 从 1 改成 2),因为存在 HOT(Heap-Only Tuple)链,ctid(0,1) 会链到 ctid(0,2) 再到 tuple2 的新版本,session1 按旧 ctid 变更时会把已经是 id=2 的行改成 3,产生错误的更新。三种解法:1) recheck——在 update 外层加回原条件 and id=1,recheck 后更新记录数为 0;2) RR 模式——使用 REPEATABLE READ 隔离级别,发现记录已被更新时抛出异常 could not serialize access due to concurrent update;3) for update 锁定——用 for update SKIP LOCKED 先锁住 limit 的行(如 ctid = any(array(select ctid from (select ctid from tbl where id=1 limit 1 for update SKIP LOCKED) t))),session2 会等待,session1 正常变更。核心:CTID 是物理行号,会随 HOT 更新和 VACUUM 变化,不能作为稳定的逻辑主键。

用 advisory lock 限制"每个分组最多 N 条记录"为什么必须用 Read Committed 隔离级别?

场景:表里有 gid 字段,要求每个 gid 最多 5 条记录。写法是写入时对同一个 gid 的写入互斥(advisory lock),检查该 gid 是否已满,不满则写入,写完释放锁。为什么只能 RC 隔离级别?因为 RR 和 SSI 隔离级别下,其他 gid(或同 gid 其他会话)在你事务开启后写入的记录,你看到的仍是事务开始时那个较老的快照,会误以为还没写满,从而突破上限——这就是并发下触发器 COUNT 看不到其他会话未提交插入的根本原因(MVCC 快照隔离)。只用触发器 COUNT 检查而不加锁,两个会话并发各插 3 条会同时通过检查,最终得到 6 条(这是社区多次讨论过的经典并发问题)。因此正确方案是:同一 gid 写入前先拿 advisory lock 串行化,保证检查与写入的原子性;并在 trigger 中判断如果不是 RC 模式则报错。

线上遇到锁等待,如何定位"谁堵塞了谁"?pg_blocking_pids 的原理是什么?

定位锁等待用 pg_blocking_pids(pid),它返回堵塞指定后端进程获取锁的 PID 数组;SSI 隔离级别下请求安全快照冲突用 pg_safe_snapshot_blocking_pids(pid)。实现位于 lockfuncs.c:它报告的 PID 包括"持有与该进程当前请求冲突的锁"(hard block)的进程,以及"请求了同样冲突锁且排在等待队列前面"(soft block)的进程。排查步骤:1) select distinct pid from pg_locks where not granted 找到被害者;2) select pg_blocking_pids(pid) 找到嫌疑人;3) 从 pg_stat_activity 看双方 state/query,从 pg_locks 看双方持有的锁类型和 granted 状态。推荐优先用 pg_blocking_pids() 而不是手写 pg_locks 自连接,因为手写很难正确处理冲突矩阵和等待队列顺序。对于多层级联阻塞,可用递归 CTE 沿 pg_blocking_pids 链向上追溯,找到最终源头(往往是 idle in transaction 的事务)。

行级锁在 pg_locks 里为什么看不到?等待行锁时看到的是什么?

PostgreSQL 的行级锁信息存储在磁盘元组(tuple)的 xmax 字段里,不直接以普通内存锁行的形式出现在 pg_locks。所以 pg_locks 里看不到具体的"行锁"对象。如果进程等待某个行锁,通常会表现为等待当前持有者的 transactionid——即 pg_locks 里出现 locktype=‘transactionid’ 的记录,等待方 mode=ShareLock granted=false,持锁方 mode=ExclusiveLock granted=true。这是因为 PG 的并发更新同一行时,等待者实际是在等待"持有该行的事务结束",这个等待通过事务 ID 锁(transactionid 类型的重量锁)来表达和参与死锁检测。同理 MultiXact(多个事务共享/锁同一行)场景会表现为等待 multixact 类型的锁。排查行锁等待时,用 pg_blocking_pids 找到持锁 pid,再从 pg_stat_activity 看持锁方的事务状态(往往是 idle in transaction)。

连接池(pgbouncer 等)对 PG 为什么重要?事务模式和会话模式有什么区别?

PG 是多进程架构,每个连接一个后端进程,连接本身开销大(进程、私有内存、元数据缓存),且高并发短连接下 GetSnapshotData、ProcArray 等共享结构竞争随连接数上升而恶化。连接池的价值:1) 复用连接,避免频繁建立连接的开销;2) 限制到数据库的实际连接数,控制共享结构竞争;3) 降低每个进程缓存大量元数据导致的内存占用。pgbouncer 的事务模式(transaction pooling):每个事务完成后连接可被其他客户端复用,要求客户端不能依赖会话级状态(session-level 的 prepared statement、advisory lock、临时表、SET 等会话状态),否则会串号;会话模式(session pooling)保持连接与客户端绑定,兼容性更好但复用度低。pgbouncer 1.21 开始在事务模式支持 prepared statement。advisory 会话级锁在事务池下尤其危险(连接归还不等于 session 结束,残留锁影响下个请求)。

重量锁的三层结构 LOCALLOCK / LOCK / PROCLOCK 分别解决什么问题?

重量锁不是一张大表,而是分本地和共享两层:1) LOCALLOCK:每个 backend 私有,记录自己对某个 LOCKTAG+LOCKMODE 获取了多少次、属于哪个 ResourceOwner,同一事务重复获取同一锁只增加本地计数(nLocks),不必反复改共享表;这也是"同一会话重复获取同一把锁总是成功"的原因。2) LOCK:共享内存中每个可锁对象一条,包含 grantMask(已授予位图)、waitMask(等待位图)、requested[]、granted[]、procLocks、waitProcs,负责对象级总体计数和等待队列。3) PROCLOCK:共享内存中某个 backend 对某个 LOCK 的持有/等待状态,包含 holdMask、releaseMask,同时挂到 LOCK 和 PGPROC 的链表上。这个分层是性能和正确性的折中:共享表只保存跨后端必须共享的信息,本地计数避免无意义的共享内存写入。获取路径 LockAcquire()→LockAcquireExtended() 先找 LOCALLOCK,已持有则本地计数,符合 fast-path 条件则走 fast-path 槽,否则进共享锁表分区判断冲突。

锁等待场景下,为什么无法定位事务内"捣蛋的早期 SQL"?如何解决?

场景:session a 的事务里早期 sqla1 堵塞了 session b 的 sqlb2,但 pg_stat_activity 只能看到 session a 的 last query(sqla3),无法定位真正造成堵塞的 sqla1。原因:PG 只记录每个会话"当前"那条 SQL 和"当前"持有的锁,不记录事务内历史 SQL 及其对应的历史锁信息。解决前提是有地方存储 sqla1 及其锁信息、以及 sqlb2 请求的锁信息:1) SQL 审计日志(log_statement)存历史 SQL;2) trace_locks 参数(需编译时定义 LOCK_DEBUG)打印每次锁操作的详细信息;3) log_lock_waits 记录等待超时的会话;4) 用 pg_blocking_pids 找到堵塞 session,再结合审计日志和 trace_locks 时间线定位事务内哪条 SQL 导致堵塞。缺点是 trace_locks + SQL 审计不适合高 QPS 业务(性能影响大),更适合用 eBPF 采样方案(如 DBdoctor、pg-lock-tracer)低开销采集。畅想:若每个未结束事务在内存记录每条 SQL 及锁信息并通过视图展示,将极大方便分析锁冲突链路。

高并发下为什么会有大量 idle 连接?它和 idle in transaction 的危害有什么不同?

大量 idle 连接的产生通常是:DB 性能出现抖动导致业务请求拥塞,业务端通过新建更多连接处理拥塞请求,但没配置自动释放空闲连接或未到超时。idle(未开启事务)连接的危害主要是资源占用:1) 每个会话有私有内存,缓存访问过的对象元数据(尤其分区表每个分区独立元数据),长连接访问对象多时内存占用大,连接多了可能触发 OOM;2) 占满连接导致其他业务连接不足。而 idle in transaction(开启事务但空闲)的危害更严重,因为它还持有事务快照(backend_xmin/xid),固定 VACUUM 清理边界导致表膨胀、freeze 卡住,还可能持有锁。解决:idle 连接用 idle_session_timeout 自动释放(PG14+,早期用 pg_timeout 插件);idle in transaction 用 idle_in_transaction_session_timeout。同时业务端要做降级保护(丢请求或队列化),控制到 DB 的最大并发。