PostgreSQL 事务与锁
PostgreSQL 的事务与锁必须和 MVCC 一起理解。它的核心目标不是“所有读写都加锁排队”,而是:
让普通读尽量不阻塞写,让写也尽量不阻塞普通读,同时在写写冲突、约束检查、DDL、显式锁场景下保证一致性。
学习目标
学完这一页,你要能解释:
- Read Committed、Repeatable Read、Serializable 的快照差异。
- PostgreSQL 更新为什么生成新 tuple,旧 tuple 为什么不能马上删。
- 普通
select、select for update、update分别加什么影响。 - 行锁、表锁、谓词锁、咨询锁分别适合什么场景。
- 长事务为什么会导致 VACUUM 清不掉、表膨胀、索引膨胀。
- 线上如何排查锁等待、死锁、长事务和版本膨胀。
事务解决什么问题
事务让一组操作具备 ACID:
| 特性 | 含义 | 业务例子 |
|---|---|---|
| Atomicity 原子性 | 要么都成功,要么都失败 | 资产入库和流水记录不能只成功一半 |
| Consistency 一致性 | 操作前后满足约束 | 资产状态必须在合法枚举内 |
| Isolation 隔离性 | 并发事务互不破坏 | 两个人不能同时把同一资产领取成功 |
| Durability 持久性 | 提交后可恢复 | 宕机后已提交状态不能丢 |
PostgreSQL 用 MVCC、锁、WAL、约束共同实现这些目标。
MVCC 版本链怎么理解
PostgreSQL 表里的每次更新通常不是原地覆盖,而是生成新 tuple。
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 | 当前语句或事务认为哪些事务已提交、未提交、活跃 |
快照怎么判断可见
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 示例:
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 |
抢占资产示例:
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 lock | update/delete/select for update | 控制同一行写写冲突 |
| Table-level lock | DDL、显式 lock table | 控制表结构和表级操作 |
| Advisory lock | 应用自定义互斥 | 可做轻量业务锁,但要谨慎释放 |
| Predicate lock | Serializable 隔离级别 | 检测谓词范围冲突 |
咨询锁 Demo:
select pg_try_advisory_lock(1001);
-- 执行业务互斥逻辑
select pg_advisory_unlock(1001);它不锁具体表行,适合“同一租户同一批次只能跑一个任务”这类应用级互斥。缺点是数据库不知道你的业务含义,应用必须保证释放和异常处理。
写写冲突过程
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 行。这正是乐观状态流转常用写法。
死锁怎么产生
sequenceDiagram
participant A as 事务A
participant B as 事务B
A->>A: 锁住资产1
B->>B: 锁住资产2
A->>B: 等待资产2
B->>A: 等待资产1避免方式:
- 多个资源按固定顺序更新,例如都按
id asc。 - 缩短事务,不在事务里调远程接口。
- 更新条件命中索引,减少扫描和锁等待范围。
- 批量任务分批提交。
- 对抢占类逻辑使用条件更新或
skip locked。
任务抢占 Demo:
select id
from biz.collect_task
where status = 'WAITING'
order by id
for update skip locked
limit 10;skip locked 会跳过已被其他事务锁住的行,适合多 worker 抢任务,但不适合要求严格公平顺序的业务。
长事务为什么危险
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 会话 | 开了事务忘记提交 |
排查长事务:
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;锁等待怎么排查
查看等待:
select pid,
usename,
wait_event_type,
wait_event,
state,
query
from pg_stat_activity
where wait_event is not null;查看锁:
select locktype,
relation::regclass,
mode,
granted,
pid
from pg_locks
where relation is not null
order by granted, relation::text;定位阻塞关系:
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;处理顺序:
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:并发状态流转
建表:
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:
begin;
update biz.medical_asset
set status = 'REPAIR',
updated_at = now()
where asset_no = 'A001';
-- 暂时不提交会话 B:
begin;
update biz.medical_asset
set status = 'USED',
updated_at = now()
where asset_no = 'A001'
and status = 'IDLE';
commit;观察:
- B 会等待 A 的行锁。
- A 提交后,B 重新检查
status = 'IDLE'。 - 如果 A 已改成
REPAIR,B 更新 0 行。 - 应用层应根据影响行数返回“状态已变化,请刷新”。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 事务里调用远程接口 | 锁持有时间不可控 | 先落库,再异步通知或 outbox |
| 大批量删除一次提交 | WAL 暴涨,锁时间长 | 分批或分区归档 |
| 长报表事务 | VACUUM 无法清理旧版本 | 限时、分页、只读副本、离线数仓 |
| 不带状态条件更新 | 后提交覆盖先提交 | 条件更新 + 影响行数判断 |
| DDL 高峰执行 | 强锁阻塞业务 | 评估锁级别,低峰执行 |
| 误以为普通读一定最新 | 快照读可能看到旧版本 | 根据隔离级别理解一致性 |
面试标准回答
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 扫描范围。追问:
- PostgreSQL 普通读为什么不阻塞写?
select for update skip locked适合什么场景?- 长事务为什么会导致表膨胀?
- 死锁和普通锁等待有什么区别?
关联知识点
| 知识点 | 说明 |
|---|---|
| PostgreSQL 从零到生产级掌握 | 主学习线 |
| MVCC、VACUUM 与 WAL 原理 | tuple 版本、快照、清理 |
| 索引与优化器 | 索引如何减少扫描和锁范围 |
| 性能排查 | 慢 SQL、锁、长事务排查 |
