112 lines
5.8 KiB
Markdown
112 lines
5.8 KiB
Markdown
---
|
||
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` 的用途、乐观锁和悲观锁。
|