你可能也遇到过这种场景:两个用户几乎同时提交了订单,前端都显示“抢购成功”,后台一看库存只剩一件,却卖出去了两件;或者一个服务里更新订单状态和扣减库存,代码里明明是一条一条执行,中间某个环节报错,结果订单成了“已支付”,库存却还是原样,两边数据怎么都对不上。十有八九,问题就出在“事务”这一层没处理好。PostgreSQL 的事务能力在开源数据库里属于第一梯队,MVCC、多级隔离、行级锁、崩溃恢复这些机制一套下来,能解决掉绝大多数并发一致性问题,但如果只是会用 BEGIN 和 COMMIT,那后面还有不少坑等着你。
这篇文章想把 PostgreSQL 事务从原理到实战完整过一遍:ACID 在 PG 里具体怎么落地、MVCC 快照和隔离级别怎么影响你的查询结果、锁和死锁怎么排查、跨数据库的分布式事务该怎么做,以及我踩过和一些常见的反模式。适合刚想系统学 PostgreSQL 的后端开发、正在排查线上数据问题的同学,以及准备把业务从 MySQL 迁徙过来的 DBA。
1. 事务在 PostgreSQL 里解决的是“并发约束”问题
1.1 一个库存扣减场景的真相
我先从最常见的库存扣减场景说起。假设表结构非常简单:
CREATE TABLE products ( id bigint PRIMARY KEY, stock int NOT NULL );扣库存时你的第一反应可能是:
BEGIN; SELECT stock FROM products WHERE id = 1; -- 应用层判断 stock > 0 UPDATE products SET stock = stock - 1 WHERE id = 1; COMMIT;两个事务同时执行,都读到stock = 1,都判断大于 0,然后各自SET stock = stock - 1。关键问题来了:第二次更新会覆盖第一次的更新结果,库存可能变成 0,也可能变成 -1,而不是 1 或 0 这类正确结果。这就是典型的“丢失更新”,而 PostgreSQL 事务中的隔离机制,就是专门解决这类问题的。
这类场景的根因不只是“事务有没有用”,而是“并发访问同一个资源时,数据库怎么阻止你不一致地读写”。读未提交、读已提交、可重复读、可串行化,哪一级隔离下两个事务的交互行为完全不同。这也是为什么很多老手会先问你:你们的隔离级别配的是什么?
1.2 ACID 四个属性在 PG 里分别靠什么保证
事务的四个基本属性是 ACID,很多文章只给定义,我想把 PostgreSQL 里对应的实现串一下:
原子性(Atomicity):一个事务里的所有操作,要么全部生效,要么全部不生效。PG 靠事务日志(WAL)加事务状态标记来实现。事务执行中没写 COMMIT,数据变更实际上只有当前事务自己可见;中途出错回滚,就是通过 MVCC 的版本标记把已产生的变更标记成不可见,从应用视角看像什么都没发生一样。
一致性(Consistency):数据库结构性约束,比如主键、唯一约束、外键、CHECK 约束、触发器,这些都是事务提交时要校验的。PG 的一个额外好处是 DDL 也能放在事务里回滚,比如
CREATE TABLE之后反悔了,直接ROLLBACK,表就不存在,MySQL 里做不到这一点。隔离性(Isolation):由 MVCC 配合锁机制完成。MVCC 负责读放行,行级锁负责写串行化,最终把并发冲突控制在业务可接受范围内。
持久性(Durability):WAL 日志先行,事务提交时必须把日志刷到磁盘,由
synchronous_commit参数控制刷盘强度。默认配置下,只要 COMMIT 返回成功,数据就不会因为数据库崩溃而丢失。你可以为了性能调成off或remote_write,但代价是极端情况下可能会丢最近一小段时间的已提交事务。
1.3 事务边界的三个基本命令
PostgreSQL 里事务控制命令非常简单,但它有一些容易被忽略的行为:
BEGIN; -- 开启事务 INSERT INTO orders ...; UPDATE stock ...; SAVEPOINT sp1; -- 保存点 UPDATE stock ...; -- 如果这一步出错了 ROLLBACK TO sp1; -- 回滚到保存点,不放弃整个事务 COMMIT; -- 提交一个特别值得强调的点:SAVEPOINT和ROLLBACK TO在复杂流程里很好用,它允许你在一个大事务里把某一段子操作回滚掉,事务主体还能继续往下执行。但这玩意儿不能滥用,保存点本身也会增加锁和状态管理开销。
提示:开启事务后,如果应用进程意外退出而没有 COMMIT,PG 会自动回滚这个事务,不需要手动处理。但一个会话里如果一直不结束事务,会影响 VACUUM、会导致事务 ID 膨胀,这个问题后面有专门一节讲。
2. MVCC 与快照:为什么 PostgreSQL 读不阻塞写
2.1 元组里的 xmin 与 xmax 到底在干嘛
PostgreSQL 的每个数据行(PG 里叫元组 Tuple)隐藏着几个系统列:xmin、xmax、cmin、cmax。简单理解:
- 插入一行时,
xmin记录插入事务 ID; - 更新一行时,旧的物理行不直接删除,而是把
xmax标记为更新事务 ID,同时插入一个新行,新行的xmin也是这个更新事务 ID; - 删除一行时,同样只是把
xmax设置为删除事务 ID。
这就意味着:更新等于“给旧行盖个作废章,然后抄一份改过的”。读事务判断自己能否看到这行,就是比对自己的快照与xmin/xmax的大小关系。因为有这套机制,读操作根本不用等写操作,写操作也不用担心读操作,读写天然并行。这也是 PostgreSQL 能把默认隔离级别定为读已提交、并且并发性能依然很稳的核心原因。
做个实验最容易理解。开两个 psql 终端:
-- 终端 A BEGIN; UPDATE products SET stock = stock - 1 WHERE id = 1; -- 此时不要 COMMIT -- 终端 B SELECT stock FROM products WHERE id = 1;B 终端依然能读到旧值,而且完全不会阻塞。A 最终 COMMIT 后,B 再查才会看到新值。这在生产环境里,意味着你完全不用担心“一个慢查询拖住所有写操作”这种 MySQL 早期版本常见的现象。
2.2 读已提交和可重复读的底层差异
MVCC 能区分不同事务看到的数据版本,快照规则就决定了你的隔离级别。PostgreSQL 里的核心规则是:
- 读已提交(Read Committed):事务内每个 SQL 语句执行时重新获取一次快照。也就是说,同一事务内两次
SELECT,可能因为其他事务已经提交而看到不同的结果。 - 可重复读(Repeatable Read):在事务内第一条查询执行时建立快照,之后整个事务都用这一份快照。相同条件下,重复查的结果始终一致。
这正是很多人从 MySQL 切换过来最容易懵的地方。MySQL 的 RR 使用了 Next-Key Lock,还会发生幻读被部分锁住;而 PostgreSQL 的 RR 是纯快照隔离,一个事务内查询结果集合固定,不需要依靠 Gap Lock 来阻止插入。你不需要锁区间来防幻读,因为快照已经决定了你看不到新插入的行。
但要注意,PG 的 RR 在写冲突时会发生“提交太晚”的序列化失败。假设两个事务都先读同一行,然后都尝试更新这一行,后提交的那一个会收到错误:
ERROR: could not serialize access due to concurrent update因为快照里看不到对方已经提交的更新,直接覆盖会产生丢失更新,PG 宁可把这个事务 abort 掉,也不让你做错。
2.3 多版本带来的“表膨胀”问题
所有多版本机制都有代价,PostgreSQL 的代价就是旧版本行不会立即物理消失,需要靠 VACUUM 清理。如果系统里有长时间不结束的事务,旧版本就会被一直保留,表不断膨胀,查询越来越慢,索引也越来越大。
这就是为什么 PG 里有autovacuum进程。默认配置下自动清理是开的,但有几个场景会把它拖垮:
- 长事务横跨数小时不提交;
- 每小时批量更新大量行,VACUUM 赶不上生产速度;
- 手动关闭了 autovacuum。
我见过不少“数据库越来越慢,重启一下又好了”的案例,查到最后都是长事务加表膨胀。所以排查 PG 性能问题时,xact_start字段里的老事务往往是第一嫌疑。
3. 隔离级别的真实面貌与选择思路
3.1 四种隔离级别在 PostgreSQL 里并不全是对称的
PostgreSQL 支持 SQL 标准里的四种隔离级别,但有几个特殊之处:
| 隔离级别 | PG 的实际表现 | 可能出现的异常 |
|---|---|---|
| READ UNCOMMITTED | 等同于 READ COMMITTED,不真正允许脏读 | 不可重复读、幻读 |
| READ COMMITTED(默认) | 每个语句独立快照 | 不可重复读、幻读 |
| REPEATABLE READ | 事务级快照,写冲突会 abort | 写偏斜(Snapshot 层级下检测不到的部分) |
| SERIALIZABLE | SSI(可串行化快照隔离),通过冲突检测强制真正串行 | 几乎无,但会频繁出现序列化失败 |
在 PG 里,READ UNCOMMITTED这个级别并不会让你读到未提交的脏数据,它实际上被降级为READ COMMITTED的行为。很多人以为改了隔离级别就能看到别的未提交事务的数据,这在 PG 里做不到,因为它底层根本不采用那种“读取最新值但不加锁”的实现。
SERIALIZABLE是另一个需要重新理解的概念。PG 不是靠全表锁把事务做成排队执行的,而是采用 SSI 机制,让事务正常并发跑,但通过监控读写集合的重叠来判定是否存在“写偏斜”,一旦发现可能产生非串行执行结果,就主动把某个事务 abort 掉,错误码通常是40001,提示为serialization_failure。所以它的能力是“宁可报错,绝不给你错误数据”。
3.2 什么时候必须把隔离级别调高
默认的READ COMMITTED适合绝大多数场景,但下面几类需求必须考虑更高隔离级别:
- 事务内多次查询要完全一致。比如报表统计,先查总金额,再查明细,最后再查一次总金额,如果中间有别人提交,两次总金额对不上,尴尬。
- 先查后写、基于快照做决策。比如账户转账时读余额判断是否足够,如果同一事务里前后需要一致的余额快照,就要用
REPEATABLE READ。 - 严格串行化场景。比如抢限量商品、座位分配、资金级强一致业务,直接上
SERIALIZABLE,但必须搭配重试机制,因为序列化失败不是偶发,而是并发冲突时的正常反馈。
我个人的稳健做法是:默认不全局修改隔离级别,只对特定业务事务单独指定。例如某个财务对账事务,开头执行:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;这样既不影响其他普通业务的并发吞吐,又能消除业务里“同一事务两次查询不一致”的隐患。全局改成 SERIALIZABLE 的后果通常是线上错误日志里一片40001,你还没办法短时间内改完所有业务代码。
3.3 序列化失败后的重试策略
用SERIALIZABLE或者REPEATABLE READ遇到报错之后,标准实践是让上层捕获到错误码后自动重试整个事务。这里有个关键细节:重试必须是完整重放,不是从报错那一句继续,因为事务已经处于 abort 状态,再执行任何语句都会直接失败。
伪代码如下:
for retry in range(3): try: with connection.transaction(): user = select_user_for_update(...) update_wallet(...) update_order(...) break except SerializationError: connection.rollback() sleep(random.uniform(0, 0.1))重试次数不宜太多,一般 3 到 5 次足够;如果并发冲突极高,重试再多次也只是把数据库负载推高。另一个技巧是:重试延迟使用随机小值,避免所有客户端在同一时刻反复重试造成对冲抖动。
3.4 不要拿 MySQL 的锁模型来套 PG
从 MySQL 迁移到 PG 的团队,最容易在产品文档里写上“把隔离级别设为 RR,防幻读”。这在 MySQL 里有它的道理,因为 InnoDB 的 RR 会借助 Gap Lock/Next-Key Lock 锁住范围。但 PostgreSQL 的 RR 没有 Gap Lock 这种东西,你也不会看到lock mode S锁住一堆不存在的记录。搬到 PG 后,由于快照机制已经保证了一致性,很多锁其实并不需要存在。
反过来说,PG 的SERIALIZABLE比 MySQL 的真的SERIALIZABLE要严谨得多。MySQL 默认 RR,升到 Serializable 也是用锁,和快照并不完全等同。迁移时不要简单把配置项照搬,先做一轮并发压测、观察pg_stat_activity里的锁等待情况,再决定隔离级别。
4. 锁与死锁:数据库卡住时怎么查、怎么解
4.1 PG 里的锁模式说实话比 MySQL 更细分
PostgreSQL 的锁从粒度上分表级锁和行级锁。表级锁由弱到强大致是:ACCESS SHARE、ROW SHARE、ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE。逐级互斥,低层锁相互兼容,高层锁基本会阻塞所有读写。
一个非常现实的现象:ALTER TABLE、TRUNCATE、REINDEX这类 DDL 拿的是ACCESS EXCLUSIVE,它会把读写全挡住。所以很多团队的规范化操作是:在线变更表结构用pt-osc或者pg_repack这类工具,避免直接 ALTER 造成业务不可用。
行级锁最常见的是FOR UPDATE。它的作用是把选中的行锁住,防止其他事务修改或删除。还有几个变体:
FOR NO KEY UPDATE:比FOR UPDATE轻一点,不阻塞普通 UPDATE 修改非键字段;FOR SHARE:共享锁,读锁不阻塞其他读锁,但阻塞其他写;FOR KEY SHARE:更轻的读锁,主要给外键校验场景用。
向上面提到的高并发抢单小技巧:SELECT ... FOR UPDATE SKIP LOCKED。它跳过已经被其他事务锁定的行,适合做任务队列、批量领取、分批处理。否则多个 worker 同时FOR UPDATE同一批 id,会发生大量排队,效率极低。
4.2 死锁的完整排查链路
死锁的经典触发条件是:事务 A 锁了订单行再去锁库存行,事务 B 锁了库存行再去锁订单行,两边谁都不让,数据库靠deadlock_timeout周期性检测,发现后主动 abort 其中一个事务。
排查时不要瞎猜,直接在数据库里看当前等待关系:
SELECT pid, state, wait_event_type, wait_event, query, xact_start FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_start;然后对每个疑似阻塞的 pid,找到它阻塞了谁:
SELECT pid, pg_blocking_pids(pid), query FROM pg_stat_activity WHERE pg_blocking_pids(pid) <> '{}';pg_blocking_pids()是 PostgreSQL 特有的排障神器,它能把一个会话被哪些会话阻塞的 pid 列表列出来,配合锁信息表pg_locks一起看:
SELECT l.pid, l.locktype, l.mode, l.granted, r.relname FROM pg_locks l LEFT JOIN pg_class r ON l.relation = r.oid WHERE l.granted = false;我碰到一次真实的死锁:业务 A 先更新订单行后更新库存行,业务 B 先更新库存行后更新订单行。两个事务同时提交,日志里 PG 打出死锁详情,其中一条是这个:
Process 1234 waits for ShareLock on transaction 5678; blocked by process 2345. Process 2345 waits for ShareLock on transaction 1234; blocked by process 1234.定位到这种日志之后,解法和规范就是:统一所有代码里多行更新的顺序。比如全项目约定“先更新库存,再更新订单”,两个事务就不会互相持有对方需要的锁。
4.3 锁等待参数不是越大越好
PostgreSQL 有几个和锁相关的参数,默认值偏保守,但线上最好显式调一调:
deadlock_timeout:默认 1 秒,死锁检测周期。事务等锁超过这个时长才会触发检测,正常情况下不用调太小。lock_timeout:默认 0,表示无限等待。强烈建议为业务 SQL 设置一个几秒的锁等待上限,比如 5 秒,一旦等不到锁直接报错,而不是让请求挂死。idle_in_transaction_session_timeout:事务开启后一直空闲超时的保护,比如默认 60 秒,防止应用拿到连接、开了事务、又长时间不提交。statement_timeout:单条 SQL 的超时限制,避免一条慢 SQL 拖着事务一直占锁。
设置示例:
SET lock_timeout = '5s'; SET idle_in_transaction_session_timeout = '60s'; SET statement_timeout = '30s';我见过线上事故就是因为某个客户端连接异常断开,但事务没有被杀掉,长时间持有行锁,所有后续请求全部卡在锁等待里。这个参数在云数据库控制台通常可以秒级修改,建议直接配上。
4.4 事务设计里的锁反模式
总结几条我见过的坑:
- 事务里做网络请求。在事务内调用外部 HTTP 接口,等于把锁的持有时间拉长到秒级以上,外部服务一慢,整个数据库连接池跟着被打满。
- 事务里先 SELECT 大量行再逐行处理。比如循环里一条一条 UPDATE 订单状态,锁前缀释放不了,积累成一个巨大的锁集合。
- 跨事务批次过大。一次 UPDATE 影响几十万行,锁范围很大,容易阻塞其他正常业务。
- 忘记 COMMIT 或 ROLLBACK。连接池复用时尤其常见,下一次拿到这个连接还以为自己有事务,其实已经处在一个残留事务的中间状态。
5. 跨库分布式事务:PostgreSQL 能做什么,别指望什么
5.1 一个订单加库存为什么会变成分布式问题
单库时代,订单状态和库存可以在同一个事务里原子提交,不存在一致性问题。一旦拆库或拆微服务,订单库、库存库、支付库各自独立,“扣库存”和“更新订单”没法用一条BEGIN...COMMIT完成。你再也不能指着数据库说“帮我保证两边同时成功或同时失败”。
以“订单与库存”这个最典型的场景为例,如果先创建订单再调库存服务扣减,库存扣减失败,订单已经入库,后续用户会看到一个已下单但根本没扣到库存的订单。反过来先扣库存再创建订单,订单创建失败,库存又白白少了。这就引出了分布式事务的一整套解决思路。
5.2 两阶段提交:PREPARE TRANSACTION 能用但别乱用
PostgreSQL 原生支持两阶段提交,语法就是:
PREPARE TRANSACTION 'order_tx_123'; COMMIT PREPARED 'order_tx_123'; -- 或者 ROLLBACK PREPARED 'order_tx_123';它能把一个事务的变更在第一个节点上准备好,但还没有最终提交,等待协调者统一决定。这属于 XA 协议中的 RM 角色,配合 Java 的 JTA、PHP 的 XA 扩展可以用。但是用之前要知道几个硬约束:
max_prepared_transactions默认是 0,需要手动配置并重启数据库才启用;- 预备事务会长时间持有锁并阻塞 VACUUM,不能当成常规路径;
- 如果协调者宕机,会出现“in-doubt”事务,数据库不知道是提交还是回滚,需要人工介入。
坦白讲,如今新项目里直接把两个数据库用 XA 串起来做全同步分布式事务的越来越少了,因为扩展性差、维护成本高。它更适合作为遗留系统的合规方案,而不是新架构的首选。
5.3 订单与库存场景里更现实的几种模式
如果你在做一个订单和库存跨服务调用的系统,我建议按下面的优先级评估:
第一选择:把库存扣减和订单创建放在一个事务里。如果业务量不大,尽量别拆库,这是最简单、最不容易出错的方案。很多团队还没到必须拆库的规模,却提前把系统设计成了分布式,属于过度设计。
第二选择:本地消息表。在订单库里建一个outbox消息表,创建订单和写入 outbox 在同一个本地事务里完成,之后由异步任务把 outbox 里的消息发出去,库存服务消费消息去扣减。因为“写订单”和“写消息”同库原子,所以不会出现订单成功了消息却没发出去的问题。这个模式非常稳,实现成本低,很多公司在生产环境里长期使用。
第三选择:Saga 模式。把整个业务流程拆成一个一个本地事务,每个事务完成后发起下一步,如果后续失败,则反向执行补偿操作。比如先创建订单,再扣库存,扣库存失败则取消订单。注意 Saga 不是“同时成功”,而是“最终一致”,中间状态用户可能短暂看到订单已创建但未扣库存。
第四选择:TCC。Try 阶段做资源预留,Confirm 阶段真正提交,Cancel 阶段回滚。适合强一致、低并发场景,但实现最复杂,每个服务都要写三个方法,测试成本和心智负担都不小。
下面这个表格可以帮你快速选择:
| 方案 | 一致性类型 | 复杂度 | 适合场景 |
|---|---|---|---|
| 单库事务 | 强一致 | 低 | 业务规模未拆库 |
| 本地消息表 | 最终一致 | 低 | 订单/库存等异步链路 |
| Saga | 最终一致 | 中 | 跨服务长流程 |
| TCC | 强一致(资源预留) | 高 | 保底资金、高价值资源等 |
| 2PC(XA) | 强一致 | 高 | 遗留系统或标准 XA 场景 |
有一点必须说清楚:分布式事务的“最终一致性”不是推卸责任,而是需要设计兜底对账。就算你用 Saga,也得有定时任务去扫描异常状态、人工介入入口、幂等控制。没有对账体系的最终一致等于裸奔。
5.4 幂等是分布式事务的隐形前提
分布式环境里,消息可能重复投递,重试可能重复执行,数据库层面对同一个请求不能重复扣款。所以每个下游接口都要做幂等:库存扣减请求带上全局唯一业务 ID,数据库在这张流水表上加唯一约束,重复插入自动失败。
这和 PG 事务没有直接关系,但对于用 PostgreSQL 存业务数据的团队特别重要。很多分布式事务失败,问题并不在 PG 的事务能力,而在于上游没有幂等,导致重试把数据写脏了。
6. 事务使用中的实操习惯与常见反模式
6.1 事务注解放在哪里是有讲究的
用过 Spring 的人应该都很熟@Transactional。它默认只对RuntimeException和Error回滚,如果业务抛了一个受检异常,事务是不会回滚的,需要显式写rollbackFor = Exception.class。这一点几乎每个团队都踩过。
另一个坑是自调用失效:一个类里methodA调用同类里的methodB,@Transactional标注在methodB上不会生效。因为 Spring 的事务代理只在外部调用时生效,内部调用走的是this引用,绕过了代理。解决方法是拆到不同 Bean,或者用AopContext.currentProxy()。
还有个大原则:事务边界尽量放在服务层,不要放在 Controller 层。否则一次请求从进入到返回都占着事务和数据库连接,连接池很容易被拖垮。也尽量不要在事务里做大量计算或不相关查询,事务的每一毫秒都是锁资源占用。
6.2 大批量写入别把整个事务当“万能 Buff”
一个事务里插入 10 万行,听起来原子性好,但实际效果是:锁范围大、WAL 写入积压、内存中事务状态变大。如果中途某一行有约束冲突,整个事务全部回滚,应用还可能因此出现超时。
实操中常见的做法是按批提交:
batch_size = 1000 for i in range(0, len(rows), batch_size): with connection.transaction(): insert_batch(rows[i:i+batch_size])当然,如果业务真的要求“要么全部成功要么全部失败”,那必须用一个大事务,这个优先级大于性能优化。我的经验是:对于纯初始化数据、日志类数据、临时计算类数据,分批提交既安全又高效;对于订单、支付这类核心业务状态变更,事务宁可小一点、封闭性好一点,切忌把一个几十秒的长事务包在里面。
6.3 事务 ID 回卷与长事务的高危信号
PostgreSQL 的事务 ID 是 32 位的,大约 42 亿个,虽然看起来很大,但事务回卷是真实会发生的事,PG 依靠 VACUUM FREEZE 机制把老事务标记为 frozen 来避免回卷。如果 autovacuum 长期跟不上,某个数据库会进入保护模式,报类似错误:
database is not accepting commands to avoid wraparound data loss这种错误一旦出现,数据库基本处于只读或拒绝服务状态,只能强制做 aggressive vacuum,可能耗时很长。预防手段就是控制长事务,并且让 autovacuum 正常工作。
怎么发现长事务?很简单:
SELECT pid, xact_start, state, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY duration DESC;如果一个事务处于idle in transaction状态超过几分钟,就要警惕了。它可能不执行任何语句,却一直占着事务槽、阻塞 VACUUM、掩盖旧版本数据。建议直接 kill 掉,或配置参数自动回收。
6.4 版本选择与安装上的一点现实建议
回到很多人刚起步时的问题:PostgreSQL 到底下载哪个版本?我的建议很直接:不要追最新小版本,也不要守着老版本。生产环境选官方社区版或云厂商托管版里已经稳定跑了半年以上的大版本,比如当前主流推荐 16、17 系列都挺稳。如果是在 Ubuntu 上自己装,优先apt install postgresql或官网的 APT 仓库,没必要源码编译。源码编译通常是为了改内核、做定制插件或深入学习,性价比不高,日常使用官方二进制包最省心。
装完之后也别急着写业务,先确认几个参数:max_connections、shared_buffers、effective_cache_size、work_mem,再动手压测。事务相关的synchronous_commit和deadlock_timeout、lock_timeout也建议在一开始就规划好。
6.5 用两个终端亲手验证一次事务隔离
理论说多了容易晕,强烈建议你自己做一次实验:开两个 psql 终端,按下面的脚本走一遍读已提交和可重复读的区别。
终端 A:
BEGIN; SELECT * FROM accounts WHERE id = 1; -- 先不要提交终端 B:
UPDATE accounts SET balance = balance + 100 WHERE id = 1; COMMIT;回到终端 A 再执行一次同样 SELECT,如果当前是默认读已提交,第二次查询会看到更新后的余额;如果先SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;再开事务,第二次查询看到的还是事务开始时的余额。这比看十篇文档都直观。
我自己带团队时,每个新人都要求做一遍这个实验,把“快照”这两个字变成肌肉记忆。实际排障时,很多“我明明查到了,但他没查到”的问题,最后一查都是隔离级别和快照时间点的问题。
最后再分享一个真实经历。之前排查一个线上库存对账不平的问题,数据库层锁和事务都查了,最后发现是应用里并发调度的幂等键不唯一,两个线程把同一个退款请求提交了两次。PostgreSQL 的事务机制再强,也拦不住应用层没有幂等。这提醒我始终把“事务”看成一条链:数据库事务只是链上的一环,真正的数据一致性,需要从接口设计、并发控制到数据库配置一层层扣。每次遇到数据不对,先问自己:事务边界有没有画对?锁有没有拿得比实际需要更久?重试和幂等有没有兜住?这三关过了,绝大多数问题都不会到 PostgreSQL 这一层来。