--- 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` 的用途、乐观锁和悲观锁。