Files

112 lines
5.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend.postgresql.index-tx-mvcc
title: 索引、EXPLAIN、事务、隔离级别和 MVCC
type: lesson
domain: backend
stack: [postgresql]
tags: [索引, EXPLAIN, 事务, 隔离级别, MVCC, 锁]
level: intermediate
prerequisites: [backend.postgresql.schema-sql]
est_hours: 4
related: [backend.postgresql.scenario.order-state-audit, backend.mysql.tx-locks-mvcc, backend.postgresql.interview]
week: [2, 3]
order: 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`,互不阻塞、不会重复领取。
- 订单状态修改用乐观锁,避免“用户取消”和“支付回调”同时修改导致状态错乱(见场景文档)。
## 代码示例
```sql
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):
```sql
-- 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 面试题](interview.md):MVCC 和 VACUUM、隔离级别的默认值、`SKIP LOCKED` 的用途、乐观锁和悲观锁。