Skip to content

PostgreSQL 事务与锁

PostgreSQL 的事务与锁必须和 MVCC 一起理解。它的核心目标不是“所有读写都加锁排队”,而是:

让普通读尽量不阻塞写,让写也尽量不阻塞普通读,同时在写写冲突、约束检查、DDL、显式锁场景下保证一致性。

学习目标

学完这一页,你要能解释:

  1. Read Committed、Repeatable Read、Serializable 的快照差异。
  2. PostgreSQL 更新为什么生成新 tuple,旧 tuple 为什么不能马上删。
  3. 普通 selectselect for updateupdate 分别加什么影响。
  4. 行锁、表锁、谓词锁、咨询锁分别适合什么场景。
  5. 长事务为什么会导致 VACUUM 清不掉、表膨胀、索引膨胀。
  6. 线上如何排查锁等待、死锁、长事务和版本膨胀。

事务解决什么问题

事务让一组操作具备 ACID:

特性含义业务例子
Atomicity 原子性要么都成功,要么都失败资产入库和流水记录不能只成功一半
Consistency 一致性操作前后满足约束资产状态必须在合法枚举内
Isolation 隔离性并发事务互不破坏两个人不能同时把同一资产领取成功
Durability 持久性提交后可恢复宕机后已提交状态不能丢

PostgreSQL 用 MVCC、锁、WAL、约束共同实现这些目标。

MVCC 版本链怎么理解

PostgreSQL 表里的每次更新通常不是原地覆盖,而是生成新 tuple。

mermaid
flowchart TD
    A["旧 tuple<br/>status=IDLE<br/>xmin=10"] --> B["事务20执行 update"]
    B --> C["旧 tuple 写 xmax=20"]
    B --> D["新 tuple<br/>status=USED<br/>xmin=20"]
    D --> E["事务20 commit"]
    E --> F["新事务看到 USED"]
    C --> G["旧版本等待 VACUUM"]

为什么不能直接覆盖:

如果直接覆盖会怎样
老查询正在读旧状态一致性读被破坏
更新事务回滚找不到旧值恢复
读写都等待同一把锁并发能力下降
崩溃恢复困难缺少清晰版本边界

tuple 中与可见性相关的字段可以简化理解为:

字段含义
xmin创建这个 tuple 的事务
xmax删除或更新这个 tuple 的事务
Snapshot当前语句或事务认为哪些事务已提交、未提交、活跃

快照怎么判断可见

mermaid
flowchart TD
    A["事务读取 tuple"] --> B["检查 xmin"]
    B --> C{"创建事务对快照可见吗"}
    C -- "否" --> D["跳过"]
    C -- "是" --> E["检查 xmax"]
    E --> F{"删除/更新事务对快照可见吗"}
    F -- "是" --> G["说明该版本已失效"]
    F -- "否" --> H["返回该版本"]

这就是为什么普通读不需要等待正在更新的事务:读可以根据自己的快照选择旧版本。

隔离级别

隔离级别PostgreSQL 行为适用场景
Read Committed每条语句开始时生成快照默认常用,接口短事务
Repeatable Read一个事务内使用事务开始时快照多次查询要保持一致
Serializable使用 SSI 检测可串行化冲突金额、库存等强一致复杂并发

Read Committed 示例:

sql
begin;

select status from biz.medical_asset where asset_no = 'A001';
-- 另一事务提交修改后
select status from biz.medical_asset where asset_no = 'A001';
-- 第二次 select 可能看到新值

commit;

Repeatable Read 中,同一个事务内普通查询看到的是事务开始时的一致快照。

普通读、锁读、写操作区别

SQL行为
select ...普通快照读,通常不锁住行,不阻塞 update
select ... for update锁住选中的行,防止别人修改
update ...对目标行加行锁,写写冲突会等待
delete ...对目标行加行锁并产生 dead tuple
alter table ...可能申请较强表锁,影响业务 SQL

抢占资产示例:

sql
begin;

select id, status
from biz.medical_asset
where asset_no = 'A001'
for update;

update biz.medical_asset
set status = 'USED'
where asset_no = 'A001'
  and status = 'IDLE';

commit;

for update 适合“先查再改且必须锁定候选行”的场景。如果只是简单状态流转,也可以通过带条件的 update 一步完成,再检查影响行数。

常见锁类型

场景说明
Row-level lockupdate/delete/select for update控制同一行写写冲突
Table-level lockDDL、显式 lock table控制表结构和表级操作
Advisory lock应用自定义互斥可做轻量业务锁,但要谨慎释放
Predicate lockSerializable 隔离级别检测谓词范围冲突

咨询锁 Demo:

sql
select pg_try_advisory_lock(1001);

-- 执行业务互斥逻辑

select pg_advisory_unlock(1001);

它不锁具体表行,适合“同一租户同一批次只能跑一个任务”这类应用级互斥。缺点是数据库不知道你的业务含义,应用必须保证释放和异常处理。

写写冲突过程

mermaid
sequenceDiagram
    participant A as 事务A
    participant B as 事务B
    A->>A: update asset set status='REPAIR' where id=1
    A->>A: 持有行锁,未提交
    B->>B: update asset set status='USED' where id=1
    B-->>A: 等待 A 提交或回滚
    A->>A: commit
    B->>B: 重新检查条件后继续

注意:等待结束后,PostgreSQL 会重新检查 where 条件。如果你写了 and status = 'IDLE',而 A 已经把状态改了,B 可能更新 0 行。这正是乐观状态流转常用写法。

死锁怎么产生

mermaid
sequenceDiagram
    participant A as 事务A
    participant B as 事务B
    A->>A: 锁住资产1
    B->>B: 锁住资产2
    A->>B: 等待资产2
    B->>A: 等待资产1

避免方式:

  1. 多个资源按固定顺序更新,例如都按 id asc
  2. 缩短事务,不在事务里调远程接口。
  3. 更新条件命中索引,减少扫描和锁等待范围。
  4. 批量任务分批提交。
  5. 对抢占类逻辑使用条件更新或 skip locked

任务抢占 Demo:

sql
select id
from biz.collect_task
where status = 'WAITING'
order by id
for update skip locked
limit 10;

skip locked 会跳过已被其他事务锁住的行,适合多 worker 抢任务,但不适合要求严格公平顺序的业务。

长事务为什么危险

mermaid
flowchart TD
    A["长事务开启"] --> B["持有很老的 Snapshot"]
    B --> C["其他事务 update/delete"]
    C --> D["产生大量 dead tuple"]
    D --> E["VACUUM 尝试清理"]
    E --> F{"老 Snapshot 可能还需要旧版本吗"}
    F -- "是" --> G["不能清理"]
    G --> H["表膨胀、索引膨胀、查询变慢"]

常见来源:

来源说明
导出报表一次查询跑很久
批处理脚本begin 后处理大量逻辑不提交
连接池事务未关闭应用异常路径漏 commit/rollback
人工 psql 会话开了事务忘记提交

排查长事务:

sql
select pid,
       usename,
       state,
       now() - xact_start as xact_age,
       wait_event_type,
       wait_event,
       query
from pg_stat_activity
where xact_start is not null
order by xact_age desc;

锁等待怎么排查

查看等待:

sql
select pid,
       usename,
       wait_event_type,
       wait_event,
       state,
       query
from pg_stat_activity
where wait_event is not null;

查看锁:

sql
select locktype,
       relation::regclass,
       mode,
       granted,
       pid
from pg_locks
where relation is not null
order by granted, relation::text;

定位阻塞关系:

sql
select blocked.pid as blocked_pid,
       blocked.query as blocked_query,
       blocker.pid as blocker_pid,
       blocker.query as blocker_query
from pg_stat_activity blocked
join pg_stat_activity blocker
  on blocker.pid = any(pg_blocking_pids(blocked.pid))
where cardinality(pg_blocking_pids(blocked.pid)) > 0;

处理顺序:

mermaid
flowchart TD
    A["接口卡住或 SQL 等待"] --> B["查 pg_stat_activity"]
    B --> C["查 pg_blocking_pids"]
    C --> D["找到阻塞会话"]
    D --> E{"是否长事务或未提交"}
    E -- "是" --> F["修事务边界,必要时终止会话"]
    E -- "否" --> G["看 SQL 是否扫太多行或 DDL 强锁"]
    G --> H["补索引、拆批、错峰 DDL"]

不要把 pg_terminate_backend 当常规方案。它只能救火,根因通常在事务边界、索引、批量任务、DDL 策略。

商业项目怎么用

场景建议
资产状态流转update ... where status = ?,检查影响行数
多 worker 抢任务for update skip locked,任务要幂等
大批量导入分批事务,避免长事务和 WAL 暴涨
报表导出控制查询时间,避免阻塞 VACUUM
定时清理历史分批删除或分区归档
跨系统调用不要把 HTTP/RPC 放在数据库事务里

可运行 Demo:并发状态流转

建表:

sql
create schema if not exists biz;

drop table if exists biz.medical_asset;

create table biz.medical_asset (
  id bigserial primary key,
  asset_no varchar(64) not null unique,
  status varchar(20) not null,
  updated_at timestamp not null default now()
);

insert into biz.medical_asset(asset_no, status)
values ('A001', 'IDLE');

会话 A:

sql
begin;

update biz.medical_asset
set status = 'REPAIR',
    updated_at = now()
where asset_no = 'A001';

-- 暂时不提交

会话 B:

sql
begin;

update biz.medical_asset
set status = 'USED',
    updated_at = now()
where asset_no = 'A001'
  and status = 'IDLE';

commit;

观察:

  1. B 会等待 A 的行锁。
  2. A 提交后,B 重新检查 status = 'IDLE'
  3. 如果 A 已改成 REPAIR,B 更新 0 行。
  4. 应用层应根据影响行数返回“状态已变化,请刷新”。

常见坑

后果正确做法
事务里调用远程接口锁持有时间不可控先落库,再异步通知或 outbox
大批量删除一次提交WAL 暴涨,锁时间长分批或分区归档
长报表事务VACUUM 无法清理旧版本限时、分页、只读副本、离线数仓
不带状态条件更新后提交覆盖先提交条件更新 + 影响行数判断
DDL 高峰执行强锁阻塞业务评估锁级别,低峰执行
误以为普通读一定最新快照读可能看到旧版本根据隔离级别理解一致性

面试标准回答

text
PostgreSQL 使用 MVCC 实现一致性读,更新通常生成新 tuple,旧 tuple 通过 xmin/xmax 和快照判断可见性,后续由 VACUUM 清理。Read Committed 下每条语句使用语句级快照,Repeatable Read 下事务内使用事务开始时快照。普通 select 通常不阻塞 update,但 select for update、update、delete 会加行锁,写写冲突会等待。长事务会持有很老的快照,阻止 VACUUM 清理 dead tuple,导致表膨胀和查询变慢。排查锁等待要看 pg_stat_activity、pg_locks、pg_blocking_pids,定位阻塞会话、事务边界和 SQL 扫描范围。

追问:

  1. PostgreSQL 普通读为什么不阻塞写?
  2. select for update skip locked 适合什么场景?
  3. 长事务为什么会导致表膨胀?
  4. 死锁和普通锁等待有什么区别?

关联知识点

知识点说明
PostgreSQL 从零到生产级掌握主学习线
MVCC、VACUUM 与 WAL 原理tuple 版本、快照、清理
索引与优化器索引如何减少扫描和锁范围
性能排查慢 SQL、锁、长事务排查