阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
1 parent 0bae9916a9
commit 5eb67c5d08
65 files changed
+6457 -77

No files matched your search

@@ -0,0 +1,111 @@
---
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` 的用途、乐观锁和悲观锁。