
1. 先说清楚一件事PostgreSQL 的事务和并发控制到底在解决什么问题我最早做业务系统的时候对数据库事务的理解特别浅觉得“事务不就是 BEGIN 加 COMMIT要么全成功要么全回滚嘛”。直到接了一个库存系统的遗留项目凌晨两点被叫起来说线上订单居然出现超卖才真正意识到事务的“一致性”不是靠一条 COMMIT 就能保证的并发场景下数据是怎么被互相干扰的、锁是怎么排队的、隔离级别又在哪个环节起作用这些才是事务处理的核心。PostgreSQL 的事务处理和并发控制恰恰是它跟 MySQL 这类数据库差异最大的地方之一。PostgreSQL 16 在事务快照、锁管理、清理机制和监控视图上做了不少打磨但底层逻辑依然是经典的 MVCC多版本并发控制加锁机制。简单说它让读写不互相堵让写写之间通过锁和隔离级别来协调。如果你打算把 PostgreSQL 当成生产库用或者正在刷入门教程准备深入这部分是绕不过去的。这篇文章我用语法、案例加实战的方式把 PostgreSQL 16 的事务处理、隔离级别、锁、MVCC、死锁排查这些内容过一遍。基础部分我会讲清楚“为什么”不给只背命令的答案进阶部分会直接给可用的排查 SQL 和设计方案。适合刚接触 PostgreSQL 的开发者也适合用了一阵子但对并发行为把握不准的人。2. 从语法到机制事务块、保存点和状态机2.1 事务的三种结束方式不只是 COMMIT 和 ROLLBACKPostgreSQL 里开启事务的常规方式是BEGIN也可以用简写BEGIN TRANSACTION。与之对应的结束命令有COMMIT和ROLLBACK。这套东西看着简单但有几个容易踩的细节。第一在 psql 或大部分客户端里默认是开启自动提交的也就是你单独执行一条 SELECT、UPDATE它自己就是一个隐式事务执行完就提交了。只有显式写了 BEGIN 之后后续语句才处于同一个事务块中。这个特性在写应用时影响很大比如一个连接池里的连接如果之前发生过异常事务没有收尾那么你拿到的连接可能还停留在一个“失败的事务里”后续语句全都执行不了。第二事务不一定只能全局回滚。PostgreSQL 提供了SAVEPOINT可以理解为是在事务里打一个书签。某个语句出了问题不用把整个事务扔掉只需要ROLLBACK TO SAVEPOINT回到书签处之前成功的语句依然有效。语法很简单BEGIN; INSERT INTO orders(order_no, amount) VALUES (A001, 100); SAVEPOINT sp1; UPDATE inventory SET stock stock - 1 WHERE product_id 1; -- 假设这里扣减有问题需要取消这次扣减但保留订单 ROLLBACK TO SAVEPOINT sp1; -- 继续其他操作 COMMIT;这段逻辑在很多复杂导入或批量处理流程里非常有用。比如我写过的一个对账程序需要循环处理几千条明细其中某一两条格式非法。最优雅的做法就是每条明细处理前设置一个保存点出错时回到保存点记录下来继续处理下一条而不是整批回滚。否则一旦第 5000 条出错前面 4999 条全部白干重来一次代价很高。第三PostgreSQL 没有 Oracle 那种“事务在遇到错误后仍然可以继续部分操作”的柔和行为。你执行一个语句报错了整个事务会直接进入 aborted 状态后续任何语句都会报“current transaction is aborted, commands ignored until end of transaction block”。你必须 ROLLBACK或者用保存点把错误部分隔离掉。很多从 Oracle 转过来的同事初期都会被这个坑到写存储过程或者批处理脚本时忘记处理异常结果什么语句都跑不了。2.2 DDL 也能放进事务这是个双刃剑PostgreSQL 的一个重要特性是 DDL 支持事务。也就是说你可以这样写BEGIN; CREATE TABLE temp_log (...); CREATE INDEX idx_temp_log_ts ON temp_log(ts); -- 发现问题 ROLLBACK;这样表、索引都不会被创建整个过程原子化。这在做数据迁移时特别舒服迁移脚本里建表、改列、刷新数据可以整体回滚不用像某些数据库那样建到一半留下一堆残留对象。但它另一面是 DDL 会持有很重的锁。比如ALTER TABLE ... ADD COLUMN这类操作在 PostgreSQL 16 里大部分版本已经优化成只改元数据不重写表但锁级别仍然是 ACCESS EXCLUSIVE会阻止所有并发读写。所以在生产环境做 DDL 迁移时要选择业务低峰期并在代码里加上锁等待超时控制比如SET lock_timeout 5s; ALTER TABLE orders ADD COLUMN remark text;lock_timeout是 PostgreSQL 提供的一个非常实用的参数。它让一条语句等待锁的时间超过设定值后就主动放弃而不是无限期挂在那边占着连接。否则你在业务高峰期误执行了一个重锁操作后面所有读写请求都会堆在一起雪崩就是这么来的。3. 隔离级别不是玄学PostgreSQL 16 四种隔离级别的真实行为3.1 默认的读已提交每条语句独立快照PostgreSQL 默认的隔离级别是读已提交Read Committed这也是大多数业务系统的选择。它的核心行为是事务里的每一条 SQL 语句都会获取一个独立的、最新的数据库快照。也就是说同一个事务里执行两次相同的 SELECT可能拿到不同的结果。听起来挺不稳定的对吧但绝大部分业务恰恰需要这种表现。比如统计订单总数时前一秒插入的订单在下一秒查询就应该能看到不需要等到事务结束才可见。这种“每条语句见最新已提交数据”的语义最符合人对实时性的直觉。不过要注意读已提交解决不了“同一事务内多次读不一致”的问题。举个例子你在事务里先查了一遍订单状态是“待支付”然后应用程序里做了一堆计算再查一次发现状态已经被人改成“已支付”。这不是数据库出 Bug而是读已提交的预期行为。你要的是整个事务期间看到同一个快照就得用可重复读或串行化。3.2 可重复读和串行化快照在事务开始时固定PostgreSQL 的可重复读Repeatable Read实现方式是事务第一条执行语句时获取事务快照后续整个事务的读操作都基于这个快照。这就保证了同一个事务里同一行数据反复读结果都一样不会出现不可重复读也不会出现幻读。这里和 SQL 标准有个差异点SQL 标准认为可重复读隔离级别只解决不可重复读不要求解决幻读但 PostgreSQL 因为基于快照实现连幻读一起解决了。换句话说PostgreSQL 的可重复读实际强度高于默认理解。代价是当两个事务基于同一份快照去更新同一行时后提交的那个事务会检测到并发更新冲突直接报错ERROR: could not serialize access due to concurrent update这个机制是防止丢失更新。两个事务都读到库存剩余 10都打算减去 1如果都成功结果应该是 8 还是 9如果都调用UPDATE inventory SET stock stock - 1在没有冲突检测的情况下后提交的会覆盖先提交的导致最终只减了一次。PostgreSQL 在这里用冲突检测拦住了这种行为强制让后提交的事务失败逼应用重试从而保证正确性。串行化Serializable则更进一步使用 SSI可串行化快照隔离机制通过跟踪事务之间的读写依赖关系判断是否存在可能导致非串行化结果的环。一旦发现就报错并且附带一个标准的 SQLSTATE 码40001。这是一个需要应用层捕获并重试的错误后面实战部分我会给重试模板。3.3 用一张表看透四种隔离级别能挡什么隔离级别脏读不可重复读幻读写偏斜典型冲突报错读未提交Read Uncommitted实际读取的是已提交快照不产生脏读可能可能可能无特殊报错读已提交Read Committed不会可能可能可能行锁等待超时可重复读Repeatable Read不会不会不会可能concurrent update 报错串行化Serializable不会不会不会不会40001 serialization failure需要额外解释的是写偏斜Write Skew。它说的是两个事务各自读到一份数据各自做判断然后各自更新不同的行最终合并结果破坏约束。比如排班系统值班规则是同一时刻至少有一人值班。事务 A 看到张三值班、李四值班判断若李四在岗就取消张三事务 B 同时看到同样数据判断若张三在岗就取消李四。两个事务都提交后没人值班。可重复读挡不住这种场景只有串行化能通过读写依赖追踪发现冲突。如果你的业务存在这种“先读后写、逻辑跨多行”的场景就要考虑用串行化加应用重试或在应用层加分布式锁。4. MVCC 与锁PostgreSQL 并发控制的两条腿4.1 行版本、xmin/xmax 和清理机制PostgreSQL 里一次 UPDATE 本质上不是修改原行而是生成一个新版本的行。旧版本的行不会立刻消失而是标记为“过期”等 VACUUM 来清理。每个行版本头上都带着xmin创建这个版本的事务 ID和xmax删除或更新这个版本的事务 ID查询时通过当前事务快照判断哪个版本可见。这个设计的好处极其明显读操作不会阻塞写操作写操作也不会阻塞读操作。一个长时间运行的 SELECT 查询在它执行期间其他事务提交了无数次 UPDATE这个查询依然能看到它启动时的快照扫描过程中不会因为行被改动而报错或重读。但问题也很现实频繁更新会产生大量死元组dead tuple如果不及时清理表会膨胀索引扫描和顺序扫描都会变慢磁盘占用持续上涨。PostgreSQL 16 的autovacuum默认是开启的它会按阈值自动触发但高并发更新场景下仍然需要你监控清理进度。16 的pg_stat_progress_vacuum视图可以看到当前 vacuum 的进度比以前方便不少。我自己的体会是不要完全依赖默认配置。业务表如果每天有百万级别的更新建议手动评估并显式设置fillfactor比如fillfactor70让每个页面预留更多空间给行版本更新降低更新时的页面拆分概率。这算是一个偏冷门但对热表很有效的优化手段。4.2 表级锁和行级锁从锁冲突矩阵看真实代价PostgreSQL 有两类锁表级锁和行级锁。表级锁在 DDL、TRUNCATE、VACUUM、INSERT/UPDATE/DELETE 等操作时都会被触达。行级锁主要出现在SELECT ... FOR UPDATE、FOR SHARE以及会修改行的语句上。表级锁有八种从弱到强大致是ACCESS SHARE普通 SELECT 持有ROW SHARESELECT FOR UPDATE / FOR SHARE 持有ROW EXCLUSIVEINSERT、UPDATE、DELETE 持有SHARE UPDATE EXCLUSIVEVACUUM、CREATE INDEX CONCURRENTLY 持有SHARECREATE INDEX不带 CONCURRENTLY持有SHARE ROW EXCLUSIVE某些 ALTER 操作持有EXCLUSIVE某些 ALTER、REFRESH MATERIALIZED VIEW 持有ACCESS EXCLUSIVEALTER TABLE、DROP TABLE、TRUNCATE 持有平时我们总说“UPDATE 不会阻塞 SELECT”因为 ROW EXCLUSIVE 和 ACCESS SHARE 不冲突。但ALTER TABLE持有的 ACCESS EXCLUSIVE 和所有其他锁都冲突所以在 DDL 期间一切读写都被挡住。记住这张简化冲突逻辑只要两边都是 ACCESS SHARE就不冲突只要有一方是 ACCESS EXCLUSIVE必然冲突其他情况要具体查矩阵。行级锁方面FOR UPDATE是最强的行锁别的FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE都会等它。生产上最常见的锁等待场景就是两个事务同时更新同一行后到者的锁请求会进入等待队列。此时你要能快速定位是谁在等谁后面第 5 节会给查询方法。4.3 死锁检测不是 Bug是数据库的正常自我保护死锁的产生需要一个循环等待条件。事务 A 锁了行 1要更新行 2事务 B 锁了行 2要更新行 1。互相等对方释放谁也走不下去。PostgreSQL 有死锁检测机制会在deadlock_timeout默认 1 秒之后开始检测发现死锁后主动回滚其中一个事务并报错误码40P01。注意死锁检测不是实时的默认至少等 1 秒。对于高并发系统1 秒的等待已经可以堆积很多请求了。你可以通过参数调整deadlock_timeout让它更快触发但太小的值会频繁检测本身也消耗 CPU。一般建议维持默认或调成 500ms 左右同时从业务逻辑上减少死锁产生的可能性。比如多个资源总是按固定顺序加锁就能大幅降低循环等待。5. 实战案例复盘我踩过的并发控制坑5.1 库存扣减超卖原子 UPDATE 和 SELECT FOR UPDATE 的关键区别先看一个经典错误写法BEGIN; SELECT stock FROM inventory WHERE product_id 1; -- 应用层判断 stock 0 UPDATE inventory SET stock stock - 1 WHERE product_id 1; COMMIT;在单事务内看起来没问题但并发量一上来就出事。两个连接同时 SELECT 到 stock 1都判断可以扣都执行 UPDATE最终结果是 -1这就是超卖。正确做法至少有三种。第一种用原子 UPDATE 条件判断UPDATE inventory SET stock stock - 1 WHERE product_id 1 AND stock 1 RETURNING stock;这条语句在执行时自己判定库存是否足够UPDATE 上锁和判断是同一个原子操作不存在先读后写的窗口。影响行数为 0 说明扣减失败。更精确的控制扣减数量可以用stock ?且扣减值等于?。第二种使用SELECT ... FOR UPDATE锁定目标行后再在应用层判断BEGIN; SELECT stock FROM inventory WHERE product_id 1 FOR UPDATE; -- 这次读取会锁定行其他事务的更新、甚至 FOR UPDATE 都会等待 -- 如果 stock 条件满足执行 UPDATE然后 COMMIT 释放锁 UPDATE inventory SET stock stock - 1 WHERE product_id 1; COMMIT;这种适合场景更复杂的业务比如扣减条件不是简单的大于等于需要查关联表做判断。缺点是你必须保证事务尽快结束否则行锁一直占着别人全部排队。第三种先查后更新但把业务判断放进 UPDATE 的 WHERE 条件里不单独 SELECT 判断。其实和第一种类似但适合要记录扣减前值的场景。无论如何核心原则就一句话库存校验和扣减必须在同一条语句或同一把行锁保护下完成绝不能在无锁状态下先读后写。5.2 可重复读下 lost update 怎么处理如果你把隔离级别切到可重复读上面的第一个错误写法也会触发冲突报错。因为两个事务基于同一快照读到 stock 1随后都去 UPDATE后提交者会报could not serialize access due to concurrent update。这在某些团队里会被当成系统故障其实它是数据完整性保护机制在起作用。处理方式是在应用层捕获这个错误并重试整个事务。很多 ORM 框架如 Spring 的Retryable可以处理手写的话大概是这个骨架-- 伪代码 for retry in 1..3: BEGIN; try: SELECT stock FROM inventory WHERE product_id 1; if stock 0: UPDATE inventory SET stock stock - 1 WHERE product_id 1; COMMIT; break except SQLSTATE 40001 or 25P02: ROLLBACK; continue注意重试不是让你在同一个事务里重新执行那条 UPDATE而是把整个事务回滚后开启新事务重新跑一遍业务逻辑。因为数据快照已经变了必须在新的快照下重新判断所有前置条件。5.3 串行化隔离级别的真实代价串行化是四档隔离级别里最强的一档但它不是银弹。使用它的业务场景比如多点同时写入的库存台账、多人协同编辑的权限校验确实能获得很高的正确性保证代价是冲突率上升。在事务提交时如果数据库检测到依赖环会报40001 serialization failure。统计下来冲突量大时重试率可能达到百分之十几甚至更高这对响应时间有很大影响。因此串行化一般只用在特定短事务上而不是整个系统全局开启。独立的短事务占锁时间短冲突窗口小即使偶尔重试对用户体验影响也可控。而那些可以在业务层通过SELECT FOR UPDATE解决的冲突没有必要动用串行化。5.4 没有索引的 UPDATE 带来的锁范围放大还有一个隐藏很深的坑更新时 WHERE 条件没走索引导致表里大量行被扫描并加锁。PostgreSQL 的锁是加在具体行上的但如果查询走了顺序扫描执行器会从头到尾扫描所有页面对每一行判断条件过程中可能在大量行的版本头加上xmax标记或等待锁。表面现象是你的 UPDATE 很慢并且数据库的锁等待视图里出现一堆本不该冲突的会话。这就是为什么生产表上的 UPDATE、DELETE 条件字段必须有合适的索引。除了性能索引还直接影响锁的范围和精细化程度。遇到锁等待异常时优先用EXPLAIN看语句是否走了索引扫描而不是一头扎进调参。6. 锁等待和长事务的监控与排查姿势6.1 一个 SQL 查清谁在阻塞谁遇到数据库突然一堆会话卡住第一步不是重启也不是 kill 连接而是看pg_stat_activity里的等待事件。我自己常用的查询SELECT a.pid, a.state, a.wait_event_type, a.wait_event, a.query_start, a.xact_start, a.application_name, left(a.query, 200) AS current_query, b.pid AS blocking_pid, left(b.query, 200) AS blocking_query FROM pg_stat_activity a LEFT JOIN pg_stat_activity b ON a.pid b.pid AND ( b.pid IN ( SELECT unnest(pg_blocking_pids(a.pid)) ) ) WHERE a.state idle ORDER BY a.xact_start;pg_blocking_pids(a.pid)是 PostgreSQL 直接提供的函数返回当前阻塞指定进程的所有后端进程 PID 数组。配合pg_locks可以继续深挖是哪种锁冲突SELECT l.locktype, l.mode, l.relation::regclass AS rel, l.transactionid, l.pid, a.query_start, left(a.query, 200) AS query FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid l.pid WHERE l.granted false ORDER BY a.query_start;granted false意味着锁请求还没获得正在等待中。这个视图直接暴露了等待队列最前排的会话是谁、等什么锁、已经等了多久。如果等待时间几秒钟内消失属于正常抖动如果持续数十秒甚至更久基本就是某个事务没提交或者有长事务卡住没释放。6.2 最常见的凶手idle in transaction我排查线上数据库锁问题时十次里有七八次会看到一个会话处于idle in transaction状态。它的意思是事务已经开启但当前没有执行任何语句。这通常是应用代码里某个分支执行完 SQL 后忘了 COMMIT 或 ROLLBACK连接一直占着事务锁也不释放。这种隐藏问题的危害很大。占着锁还在其次更麻烦的是它会导致其他会话全部卡在锁等待上。PostgreSQL 16 里有一个参数非常值得开启idle_in_transaction_session_timeout 60000单位是毫秒上面配置表示事务处于空闲状态超过 60 秒就自动断开释放所有锁。对大多数业务来说超过 60 秒还在空闲的事务基本能判定是泄漏或异常杀掉它损失很小。注意这个参数要配置在需要防呆的前台连接上备份类长事务连接的超时策略可以单独设置。另外statement_timeout和lock_timeout也是两个防呆参数。前者限制单条语句最长执行多久后者限制单条语句等待锁多久。这三个参数配合起来起码能让数据库在应用出问题时不会整个被拖死。6.3 死锁日志怎么看开启以下配置后死锁的详细信息会写入数据库日志log_lock_waits on deadlock_timeout 1s log_min_messages warning当死锁发生时日志里会出现类似deadlock detected的段落包含两个事务的 PID、各自持有的锁、等待的锁以及对应语句。查看日志时重点看两件事一是发生死锁的语句是不是业务高频路径上的语句二是两个事务加锁的顺序是否一致。如果同一个业务路径多次死锁那多半是代码里对不同资源的加锁顺序不一致比如一个路径先更新订单再更新账户另一个路径先更新账户再更新订单。统一顺序能解决大部分死锁。7. 事务与并发控制的几个常见误区最后再啰嗦两句很多初学者会把事务、隔离级别、锁当成三套割裂的知识点其实它们在 PostgreSQL 里是一套协作机制。事务决定原子性和一致性边界隔离级别决定事务能看到什么快照锁决定并发修改时谁能先执行。下面几个误区我几乎每次带人都会强调。第一事务不是“数据不会错”的免死金牌。事务保证的是原子性、一致性、隔离性和持久性但前提是你在应用层写对了事务边界。如果一个 UPDATE 语句在提交前没有经过任何业务校验事务照样会提交一个错误的负数库存。隔离级别也只是一个强度选项选错了照样出现并发问题。第二不要过度依赖锁来保证一切。能用原子 UPDATE 条件判断解决的超卖问题就不需要用SELECT FOR UPDATE锁一整行更不需要整个系统开串行化。锁用的越重并发能力越低排查越复杂。优先在设计层面通过条件更新、版本号机制避免不必要的冲突。第三长事务是万恶之源。一个事务开太久哪怕它什么都没做也会让其他事务的行版本清理受阻严重时导致表膨胀、查询性能下降。所以业务代码里事务体一定要短能在一个毫秒级事务里完成的操作绝不要夹带着远程调用、等待外部接口返回之类的操作。像“事务里调外部 API”这种写法看起来没毛病实际上会让数据库锁持有时间被外部延迟无限放大。最后分享一个我个人的使用习惯在任何 PostgreSQL 生产库上我至少会设置三个兜底超时参数——lock_timeout、statement_timeout、idle_in_transaction_session_timeout并把log_lock_waits打开。这套组合能在绝大多数异常场景下让系统自己快速恢复而不是等到人工半夜爬起来 kill 连接。事务和并发控制听起来都是数据库内部的机制但真正决定线上稳定性的往往就是这些写在事务边界里的小习惯。