5.8 KiB
5.8 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.postgresql.index-tx-mvcc | 索引、EXPLAIN、事务、隔离级别和 MVCC | lesson | backend |
|
|
intermediate |
|
4 |
|
|
2 |
索引、EXPLAIN、事务、隔离级别和 MVCC
解释说明
索引
- 默认是 B-tree,支持等值、范围、排序。其他类型:GIN(JSONB、数组、全文检索)、GiST(地理位置、范围类型)、BRIN(按时间顺序写入的超大表)、pgvector 的 HNSW / IVFFlat(向量)。
- 联合索引按列顺序排序,查询条件要从最左列开始用(和 MySQL 一样)。
- 部分索引:
CREATE INDEX ... WHERE status = 'pending',只索引需要的行,体积小。 - 表达式索引:
CREATE INDEX ON users (lower(email)),查询时用同样的表达式才能命中。 - 覆盖索引:
INCLUDE (列)把额外列放进索引叶子,查询可以只扫索引(Index Only Scan)。 - 线上建索引用
CREATE INDEX CONCURRENTLY,不阻塞写入(不能放在事务里执行)。
EXPLAIN
EXPLAIN看计划,EXPLAIN ANALYZE真正执行并给出实际耗时和行数(对写语句要小心,会真的执行)。- 关注:
Seq Scan(全表扫描)、Index Scan/Index Only Scan、Bitmap Heap Scan;预估行数(rows)和实际行数差很多时,说明统计信息过时,执行ANALYZE 表名。
事务和隔离级别
- PostgreSQL 默认 读已提交(Read Committed):每条语句看到语句开始时已提交的数据,所以同一事务里两次查询结果可能不同。
- 可重复读(Repeatable Read):整个事务看到的是第一条语句开始时的快照;如果要修改的行已被别的事务改过,会报序列化错误,应用要重试。
- 可串行化(Serializable):最严格,检测到可能的冲突时让事务失败,应用必须有重试逻辑。
MVCC
- 每次更新不是原地修改,而是写一个新版本的行,旧版本对还在用旧快照的事务仍然可见。所以读不阻塞写、写不阻塞读。
- 代价:旧版本(死元组)需要 VACUUM 清理(通常由 autovacuum 自动完成);长时间不结束的事务会让旧版本无法清理,导致表膨胀。
锁
SELECT ... FOR UPDATE锁住选中的行,其他事务要修改或也想加锁时会等待;FOR UPDATE SKIP LOCKED跳过已被锁的行——常用来实现“多个 worker 从任务表取任务”。- 乐观锁:表里加
version列,UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?,影响行数为 0 就是被别人改过了。
案例
- 订单列表按“用户 + 状态 + 时间”查询,建联合索引
(user_id, status, created_at DESC)。 - 后台任务表多个 worker 并发取任务:
SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED,互不阻塞、不会重复领取。 - 订单状态修改用乐观锁,避免“用户取消”和“支付回调”同时修改导致状态错乱(见场景文档)。
代码示例
CREATE TABLE jobs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO jobs (user_id, status, created_at)
SELECT (random() * 1000)::int, (ARRAY['queued', 'running', 'done'])[1 + (random() * 2)::int],
now() - (random() * interval '30 day')
FROM generate_series(1, 50000);
CREATE INDEX jobs_user_status_time ON jobs (user_id, status, created_at DESC);
CREATE INDEX jobs_queued ON jobs (id) WHERE status = 'queued'; -- 部分索引
ANALYZE jobs;
-- 命中联合索引
EXPLAIN ANALYZE SELECT * FROM jobs WHERE user_id = 42 AND status = 'done' ORDER BY created_at DESC LIMIT 20;
-- 只按 status 查:不满足最左前缀,通常是 Seq Scan(数据分布不同,计划可能不同)
EXPLAIN SELECT * FROM jobs WHERE status = 'done';
-- 多个 worker 取任务:在两个 psql 窗口里同时执行,互不阻塞、拿到不同的任务
BEGIN;
SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 5 FOR UPDATE SKIP LOCKED;
-- ……处理任务,然后:UPDATE jobs SET status = 'running' WHERE id IN (...);
COMMIT;
隔离级别的对比实验(两个 psql 窗口 A、B):
-- A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM jobs WHERE status = 'queued';
-- B(自动提交)
UPDATE jobs SET status = 'done' WHERE id = (SELECT min(id) FROM jobs WHERE status = 'queued');
-- A:结果不变(快照);如果把 A 改成默认的 READ COMMITTED,结果会变
SELECT count(*) FROM jobs WHERE status = 'queued';
COMMIT;
易错点
- 索引列上用函数或类型转换:
WHERE date(created_at) = '2026-10-01'用不上created_at的索引,改成范围条件created_at >= ... AND created_at < ...。 LIKE '%关键词%'用不上 B-tree 索引,需要pg_trgm的 GIN 索引或全文检索。- 索引越多越好:每个索引都会拖慢写入、占用空间、增加 VACUUM 工作量。
- 长事务:事务里调用外部接口、等待用户输入,会长时间持有锁和旧快照。事务要短。
- 可重复读 / 可串行化不写重试:序列化失败(SQLSTATE 40001)是正常现象,应用必须捕获并重试。
CREATE INDEX CONCURRENTLY失败会留下无效索引(INVALID),要删掉重建。
面试常问
见 PostgreSQL 面试题:MVCC 和 VACUUM、隔离级别的默认值、SKIP LOCKED 的用途、乐观锁和悲观锁。