后端开发和数据库运维干久了,几乎都会碰上一类诡异的线上问题:两个服务同时改同一条用户数据,后提交的反而把先提交的覆盖了;库存明明查出来还有10件,真正减的时候却提示不足;压测一上去,数据库日志刷出一屏 deadlock detected。代码逻辑翻来覆去都没错,改什么都一样,最后才发现真正的根子出在事务并发控制上。
手头这份笔记日期标着0313,是我最近复盘一个线上库存并发更新问题时整理的,把 PostgreSQL 的隔离级别、锁、MVCC 从头到尾过了一遍。想到这块内容问的人确实不少,干脆梳成一篇长文分享出来。内容照顾两类读者:刚接触 PostgreSQL 的人,可以顺着这篇把事务和并发控制的框架搭起来;有经验的开发或 DBA,可以重点看锁排查、参数调优和应用层改写的部分。
1. 事务 ACID 在 PostgreSQL 里到底是怎么落地的
很多人把 ACID 背得很熟,但不知道 PostgreSQL 是靠什么机制实现的。原子性和持久性这两条,核心功臣是 WAL(Write-Ahead Logging,预写日志)。
在 PG 里,事务改数据不是直接改磁盘上的数据文件,而是先把变更记录追加到 WAL 日志,等 WAL 安全落地了,数据页的修改才在内存里慢慢应用。这就像店里记账先记流水账,把“顾客赊账300块”写在账本上,之后才去改总账目。如果营业中途停电,老板只要翻出流水账,就知道哪些账要补、哪些账没生效,而不是两眼一抹黑。
事务提交的时候,PG 会强制把 WAL 刷到磁盘,这一步完成后才返回客户端“COMMIT”成功。崩溃重启时,PG 根据 WAL 重放变更,已经提交的事务会被恢复,没提交的事务则被回滚。数据库要么停在事务开始前的状态,要么停在事务完整提交后的状态,不会停在中间。
写库时不要为了所谓的性能去关 fsync 或者把 synchronous_commit 调到 off——除非你明确知道丢了几个事务也能接受。这个我在测试环境试过,极端情况下数据会出现不一致,真正回溯起来比丢几个事务麻烦得多。
1.1 隔离性:并发场景下“看见什么”的本质
隔离性在 PG 里主要靠快照(Snapshot)机制实现。每个事务开始后,系统会给它一个可见性快照,之后这个事务读数据,实际上是在读快照对应的版本集合。别的事务修改并提交了,也不会立刻改变你手里快照能看到的内容。这也是 PG 读操作不阻塞写、写操作不阻塞读的最根本原因。
理解这一点有个很管用的类比:当你在编辑线上文档的时候,别人也在改同一份文档,但你本地看到的是自己打开那一刻(或者你手动刷新那一刻)的版本,不会因为他改了十几个字屏幕就跟着跳。MVCC 就是让每个人握着各自的版本,提交的时候再合并。
PG 的每行数据都带着 xmin/xmax 这类版本信息,用来判断“这行对哪个事务可见”。这个概念刚开始接触会觉得抽象,但它其实就是系统在问:这行的出生时间是不是在我这个事务开始之前,死亡时间(删改标记)是不是在我这个事务开始之后。两个条件都满足,我才看得见。
1.2 MVCC 与锁:PostgreSQL 并发控制的两条腿
很多人以为“并发控制”就是加锁,实际上 PG 用的是两条腿走路:MVCC 解决读写冲突,锁解决写写冲突。前者让读写并行不打架,后者确保同一行数据不会被两个人同时改坏。
两个事务同时 UPDATE 同一行,靠的是行锁。先拿到锁的事务把数据改了,另一个事务就得等。等前面的提交或回滚之后,后面的事务基于最新版本重新判断自己的 UPDATE 条件再继续。这就是为什么“先查后改”存在风险:你查的时候数据是好的,改的时候可能已经被别人改过一轮,而你看到的条件已经过期。
MVCC 带来的副产品是旧版本数据不会立即消失,这些被称为 dead tuple 的行会被 VACUUM 在后台清理。如果你的库里长事务特别多,垃圾版本清不掉,表会越来越大,查询越来越慢。这块后面会专门讲。
2. 隔离级别与那些会让人抓狂的并发异常
2.1 四个隔离级别,一张表看懂行为差异
SQL 标准定义了四种隔离级别,PG 都支持,但有一个特例需要先说清楚:PG 里的 READ UNCOMMITTED 实际表现等同于 READ COMMITTED,也就是说它没有真正实现脏读。
我用一张表总结 PG 四个级别的行为和典型误用场景(这是基于 PG 实际行为,不是标准定义的理想情况):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 写偏斜 | 典型适用场景 |
|---|---|---|---|---|---|
| READ UNCOMMITTED(实际等价 READ COMMITTED) | 不可能 | 可能 | 可能 | 可能 | 不建议单独使用 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 可能 | 绝大多数 OLTP |
| REPEATABLE READ | 不可能 | 不可能 | 不可能(PG快照) | 可能 | 统计、报表类 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | SSI 可检测 | 资金账等强约束场景 |
这里有个细节很多人忽略:PG 的 REPEATABLE READ 因为用快照实现,连幻读都被消灭了,比标准定义里“允许幻读”要严格。但写偏斜仍然存在,只有 SERIALIZABLE 级别的 SSI 机制能检测出来。
2.2 READ COMMITTED:默认级别并不等于省心级别
PG 默认隔离级别是 READ COMMITTED,大多数人也没改过。它的行为是“每条语句开始前拿一个新快照”,也就是说,同一个事务里第一条 SELECT 和第二条 SELECT 可能看到完全不同的数据。
我在一个订单金额统计需求上被真实坑过。当时想在一个事务里先算今日总金额,再算本月总金额,期间运营刚好改了一笔大单的状态,两条 SQL 统计口径不一样,出来的数字对不上,运营拿着报表来问,怎么同一个时间段两个数差这么多。
实际演示这句话:
-- 会话A BEGIN ISOLATION LEVEL READ COMMITTED; SELECT balance FROM accounts WHERE id = 1; -- 假设此时返回 100 -- 会话B UPDATE accounts SET balance = 50 WHERE id = 1; COMMIT; -- 会话A 再次查询,同一事务内 SELECT balance FROM accounts WHERE id = 1; -- 返回 50,看到的是新快照 COMMIT;这在默认级别下是正常行为。如果业务要求一条事务里所有查询必须基于同一次快照,就要用 REPEATABLE READ。
还有一点:READ COMMITTED 下“先查后改”很容易丢更新。两个会话都查到 stock=10,然后各自执行 stock = stock - 1,最终库存可能是9而不是8。解决办法在第四章讲。
提示:默认级别下,同一事务两次查询看到不同数据是正常行为,不是 bug。判断隔离级别是否满足业务,要以“这个事务需要一致的快照吗”为标准。
2.3 REPEATABLE READ 与 SERIALIZABLE:写偏斜那道坎
值班表场景是理解写偏斜最好的例子。假设 on_call 表有 8 个人在值班,其中 A 和 B 两条记录 status=true,业务约束是至少要有一个人在场用于值班,防止没人值守。事务1把 A 的值班状态改为 false,事务2同时把 B 改为 false。
因为两个事务各改各的行,在 REPEATABLE READ 下互不阻塞。它们基于同一份快照判断,都看到“有两人在值班”,于是都认为自己可以修改。最后 A、B 都变成不在岗,约束被打破了,系统却没有报任何错误。这叫写偏斜,两个写操作单独看都没问题,合起来破坏了业务约束。
要拦住这种情况,只能上 SERIALIZABLE。PG 的 SERIALIZABLE 采用 SSI(可串行化快照隔离)机制,能够检测到这类读写依赖冲突,在事务提交时返回 serialization_failure,SQLSTATE 是 40001。收到这个错误,应用层需要做有限次重试,把这个事务再跑一遍。
补充一个 REPEATABLE READ 下的并发写行为:两个事务 RR 模式下并发 UPDATE 同一行,后到者会等待前一个事务结束,如果前面提交了,等待方可能直接报出“could not serialize access due to concurrent update”,同样类似串行化失败。所以不要以为 RR 只有“不会不可重复读”这个好处,它在重写同一行时也会更敏感。
2.4 版本选择:想学并发控制,该装哪个 PG
既然聊到隔离级别,顺带回答一个经常被私信问的问题:想学 PostgreSQL,该下载哪个版本?
我的建议很直接:
- 学习、实验,用最新稳定版或直接下便携版,解压就能跑,不用折腾系统服务,适合开两个窗口调事务行为。我本地就放着一个 PG16 便携版,需要验证某种锁行为时,直接 initdb 一个临时集群,测完就删。
- 生产环境则要保守一些,选社区支持期内的稳定版。写本篇笔记时 PG17 已在社区活跃,我见多数生产库还稳在 PG16,因为它的 VACUUM 效率、逻辑复制都有明显改进,并发场景下更不容易因为清理跟不上而膨胀。版本越新特性越多,但升级前要把兼容性测试做透。
Windows 上用安装包部署的朋友,服务起不来见过太多次了,优先看数据目录下的日志目录(默认是 log 或 pg_log),常见原因是端口被其他程序占用、安装时选的 locale 与系统不一致、服务账户权限不足。本地开发调试便携版省心很多,但正式环境还是建议用标准安装并注册成服务。
3. 锁与阻塞:会话卡住时怎么快速定位
3.1 行锁与表锁:PG 加锁是分层次的
MVCC 能解决读写冲突,但解决不了写写冲突,这部分交给锁。PG 的锁分两个层级:行锁和表锁。
行锁最常出现在 UPDATE/DELETE 和 SELECT ... FOR UPDATE 中。普通 UPDATE 会给目标行加行锁,事务提交或回滚才释放。两个事务同时改同一行,必须排队。
表锁则更重。比如 ALTER TABLE 这类 DDL 会拿 ACCESS EXCLUSIVE 锁,它会阻塞一切读写,包括最普通的 SELECT。为什么生产环境凌晨做表结构变更经常把业务打死,就是这个原因。哪怕只是加一列,也可能让整个应用停在那里等锁。
表级锁里还有 ACCESS SHARE(SELECT 持有)、ROW EXCLUSIVE(UPDATE/DELETE 持有)等,它们之间存在兼容矩阵。日常管理你不需要背完整矩阵,但脑子里要有这个概念:锁不是一个开关,而是一个分层体系,DBA 和管理员的很多工作就是在跟这个体系打交道。
3.2 pg_stat_activity 与 pg_locks:先找到那个等着拿锁的会话
遇到数据库“卡死”,最怕的是不知道谁卡了谁。排查的思路分两步:先找等待锁的会话,再顺着它找持有锁的会话。
一条很实用的查询,能直接列出正在等待锁的会话:
SELECT pid, state, wait_event_type, wait_event, query, xact_start FROM pg_stat_activity WHERE state <> 'idle' AND wait_event_type = 'Lock';wait_event_type 等于 Lock 时,说明这个会话正在等锁。接下来用 pg_blocking_pids 函数找到底是谁在挡住它:
SELECT pid, query, pg_blocking_pids(pid) AS blocking_pids FROM pg_stat_activity WHERE pid = 1234;blocking_pids 返回的是阻碍当前会话的进程 ID 列表,拿到这些 PID 再去 pg_stat_activity 里看它们在跑什么 SQL、什么时候开始的,基本就能定位问题。
还可以结合 pg_locks 看锁的具体模式:
SELECT l.pid, l.mode, l.granted, c.relname, a.query FROM pg_locks l LEFT JOIN pg_class c ON l.relation = c.oid LEFT JOIN pg_stat_activity a ON a.pid = l.pid WHERE l.locktype = 'relation';granted 为 false 的行就是在等待锁,同表 granted 为 true 的行就是当前占有者。
确认是某个会话长期持锁后,该终止就终止,但终止前先和业务确认会话在跑什么。我见过有人直接 pg_terminate_backend 把正在执行大查询的会话杀掉,结果业务方处理到一半的数据全部回滚,影响面比等锁还大。
3.3 死锁是怎么产生的,以及 deadlock_timeout 怎么工作
死锁在 PG 里是家常便饭。最常见的是两个事务按不同顺序更新同一批记录,比如事务A先更新 id=1 再更新 id=2,事务B先更新 id=2 再更新 id=1,两边各拿到一把锁,又都在等对方手里的另一把,谁也走不下去。
PG 不是时时刻刻都在扫死锁,那样太费资源。它有一个 deadlock_timeout 参数,默认 1 秒,也就是说当一个会话等锁超过 1 秒,后台才会触发死锁检测。检测到死锁后,PG 会选一个事务回滚,报错信息类似“deadlock detected”,SQLSTATE 是 40P01。
对应用来说,遇到 40P01 和 40001 都应该做重试。对设计来说,最好的办法是让所有事务按同样的顺序访问资源,比如统一先更新 user 表再更新 order 表,交叉场景自然就消失了。
另外可以在连接层设置 lock_timeout,让单个锁等待不要无限挂起。业务 SQL 卡在锁等待上比报错更难受,因为它没有任何返回,DBA 排查起来也很被动。给锁等待设个上限,最多等 3 秒,超时就返回错误,配合监控报警,效果比一直挂在那里好得多。
4. 事务并发控制的实践参数与常见坑
4.1 超时类参数:别让一个坏事务拖死整个库
先放一张我经常拿来检查生产库的参数表,这些参数直接关系并发控制:
| 参数 | 默认值 | 作用 | 我的建议 |
|---|---|---|---|
| max_connections | 100 | 最大连接数 | 连接越多锁竞争越激烈,配合连接池控制真实并发 |
| statement_timeout | 0(不限制) | 单条语句最大执行时间 | 报表类执行会很久的库可以放宽,OLTP建议设置 |
| lock_timeout | 0(不限制) | 等锁的最大时间 | OLTP 建议 1~3秒,避免无限挂起 |
| idle_in_transaction_session_timeout | 0(不限制) | 事务内空闲的最长时间 | 强烈建议设置,比如 60 秒 |
| deadlock_timeout | 1s | 死锁检测触发间隔 | 一般保持默认 |
为什么特别强调 idle_in_transaction_session_timeout?因为很多线上事故都是这样来的:业务代码里开了事务,查了数据,然后因为各种原因卡在业务逻辑上,一直没提交,连接就挂在那。这个事务持有的快照会让 VACUUM 清理不了对应的旧版本,连带拖累整个库的查询性能。设置事务内空闲超时后,超时会自动断开连接回滚事务,比 DBA 半夜爬起来手动杀会话好太多。
max_connections 也不要盲目调大。连接数一高,锁等待和上下文切换随之增加,很多时候数据库变慢不是 CPU 不够,而是锁竞争太激烈。正确的做法是前端用连接池,把真正并发的事务数控制在合理范围。
4.2 长事务、膨胀与 VACUUM:并发控制的隐藏成本
长事务是 PostgreSQL 里最隐蔽的杀手。MVCC 要求一个事务在快照内看到的数据版本必须保留,任何人不能提前清掉。如果一个事务跑了很长时间,它启动之后产生的所有旧版本数据都清不掉,表不停膨胀,索引效率下降,全表扫描和更新都变慢。
查询长事务用这个:
SELECT pid, state, xact_start, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state <> 'idle' AND xact_start IS NOT NULL ORDER BY xact_start;duration 超过几分钟就应该注意,超过几十分钟基本可以定位为事故。
VACUUM 是 PG 回收 dead tuple 的核心手段。它不能与长事务并存——长事务持有旧快照时,VACUUM 会主动跳过它启动时间点之前产生的垃圾版本。这也是为什么 PG 版本越新,VACUUM 效率优化越多,对高并发 OLTP 越友好。
给运维同学一个实用习惯:每周看一次 pg_stat_user_tables 里的 n_dead_tup 和 last_autovacuum,如果 n_dead_tup 长期高且表很大,说明 autovacuum 可能没跟上,或者有长事务在顶着。提前处理比事后膨胀到查询超时才去救要容易太多。
注意:不要轻易手动执行 VACUUM FULL,它会拿到 ACCESS EXCLUSIVE 锁并阻塞业务,生产环境尽量靠自动化 autovacuum。
4.3 应用层改写:从“先查后改”到“条件更新”
事务并发控制最终还是落到 SQL 写法上。最容易踩的坑是“先查后改”:
SELECT stock FROM products WHERE id = 10; -- 返回 10 UPDATE products SET stock = stock - 1 WHERE id = 10; -- 并发时丢更新两个事务都查到 10,都执行减一,最终可能是 9,而不是 8。要修很简单,把条件更新直接用起来:
UPDATE products SET stock = stock - 1 WHERE id = 10 AND stock >= 1 RETURNING stock;这条 SQL 本身带行锁,并发时只有一个事务能成功,返回值可以判断是否扣减成功。库存扣减这类操作根本不需要先 SELECT。
如果业务确实需要先锁定一行再处理后续逻辑,再用 SELECT ... FOR UPDATE。比如订单支付流程先锁订单行,再计算优惠、调用外部接口,确保整个期间订单状态不被别的事务改掉。FOR SHARE 则是多个事务可以一起读,但不能改,适合“多人同时查看但都不能编辑”的场景。
做任务队列消费时,FOR UPDATE 配合 SKIP LOCKED 很好用。多个 worker 同时捞任务,SKIP LOCKED 让它们跳过已被别人锁住的行,各拿各的:
SELECT task_id FROM task_queue WHERE status = 'pending' ORDER BY task_id FOR UPDATE SKIP LOCKED LIMIT 10;没有 SKIP LOCKED 的话,所有 worker 都会堵在同一批任务上,排队拿锁,效率极低。
应用层还要有重试意识。serialization_failure(40001)和 deadlock(40P01)不是代码写错了,是并发调度导致的正常回滚,业务上应该允许重试。我一般这样写伪逻辑:
from psycopg2.extensions import TransactionRollbackError max_retries = 3 for attempt in range(max_retries): try: with conn.transaction(): do_work() break except TransactionRollbackError: if attempt == max_retries - 1: raise把可重试错误和真正的业务错误分开,重试几次后还是失败再报警,比一次性失败让用户重来体验好很多。
4.4 常见并发场景选型速查
这一节用一个速查表总结典型的业务场景应该怎么写:
| 业务场景 | 常见错误 | 推荐做法 | 说明 |
|---|---|---|---|
| 库存/余额扣减 | 先 SELECT 再 UPDATE | UPDATE 条件更新 + RETURNING | 原子操作,避免丢更新 |
| 支付流程 | 不加锁直接改状态 | 先 SELECT FOR UPDATE 再更新 | 保证整段逻辑期间状态稳定 |
| 防止重复提交 | 靠应用层 Redis 判断 | 唯一索引兜底 + 条件插入 | Redis 会过期,数据库约束更可靠 |
| 任务队列并发消费 | 所有 worker SELECT 同一批 | FOR UPDATE SKIP LOCKED | 跳过已锁行,各行其是 |
| 资金对账强约束 | 用 REPEATABLE READ 就以为安全 | SERIALIZABLE + 重试 | 只有 SSI 能检测写偏斜 |
| 大批量数据更新 | 一条 UPDATE 扫全表 | 分批多次小事务 | 控制每批锁持有时间,降低阻塞 |
这张表不是教条,是我实际处理过的场景归纳。核心思路是:能原子更新就原子更新,需要锁定就明确加锁,期待并发安全就把重试写好。事务隔离级别负责兜底,应用层写法才是第一道防线。
最后再分享一个个人习惯。事务并发控制这件事,参数和视图都是工具,真正决定系统稳不稳的是你有没有预警机制。我每套生产环境都会留一条定时任务,定期查询 pg_stat_activity 里的事务时长和等待锁的会话,任何超过阈值的都推给值班群。很多问题在用户感知之前就被提前拦下来了,省掉的是一整夜的数据库排查时间。
另外也提醒一句:不要盲目把隔离级别升到 SERIALIZABLE。我曾经见过一个团队把所有事务都改成最高级别,结果每天都在处理 serialization_failure,业务吞吐掉了一大截。先想清楚业务能容忍哪些异常,再决定用哪个级别。多数时候,条件更新加行锁,比调高隔离级别可靠得多,也省心得多。