数据库(二):索引与查询规划——数据库如何找到你的数据

深入剖析 B 树与 B+ 树索引、哈希索引、复合索引、覆盖索引,以及如何阅读 EXPLAIN 输出以诊断慢查询。

在笔记本上查 1,000 行只需 2 毫秒的查询,到了生产环境面对 5,000 万行数据时,可能暴涨到 45 秒——除非你建对了索引。索引是数据库工具箱里对性能影响最大的单项优化;吃透它,会彻底改变你设计 Schema 和写 SQL 的方式。


根本问题:如何定位一行数据#

想象一张包含 1,000 万行的表,以堆文件(heap file)形式存储在磁盘上。每一行都位于一系列 8 KB 数据页中的某个位置。当你执行:

哈希索引结构

1
SELECT * FROM users WHERE email = 'alice@example.com';

若无索引,数据库必须执行一次 顺序扫描(sequential scan)(也称全表扫描):逐页读取整张表,逐行检查 email 字段是否匹配。若该表在磁盘上占 2 GB,则数据库需读取整整 2 GB——仅仅为了查找一行数据。

索引是一种独立的数据结构,把列值直接映射到对应行的物理位置。若在 email 列上建立了 B 树索引,同样的查找操作只需访问约 3–4 个数据页,而非 250,000 个。这正是查询响应从毫秒级跃升至分钟级的根本原因。

顺序扫描 vs 索引扫描#

维度顺序扫描索引扫描
工作原理按表的物理顺序逐页读取遍历索引树,再获取匹配的行
最适用场景小型表;或查询返回 >10–15% 的行高选择性查询(返回少量行)
I/O 模式顺序读取(HDD 上较快)随机读取(每行可能位于不同页)
CPU 开销每行开销低(仅过滤)每行开销较高(树遍历 + 堆获取)
触发条件无合适索引;或优化器估算扫描更便宜存在合适索引且查询具备高选择性

B-树索引结构

数据库的查询优化器会自动做出这一决策。有时顺序扫描确实更快——例如当 WHERE 条件匹配了表中 80% 的行时,索引带来的随机 I/O 反而比一次性顺序读取全部数据更慢。

B 树索引:主力索引类型#

B 树(平衡树)几乎是所有关系型数据库的默认索引类型,其工作原理如下。

查询成本模型

结构#

B 树是一种自平衡树,具有以下特征:

  • 每个节点包含多个按升序排列的键(key)
  • 每个内部节点包含指向子节点的指针,这些指针分布在键之间及两端
  • 所有叶子节点处于同一深度(即“平衡”)
  • 分支因子(branching factor)(每个节点的子节点数)通常为数百至数千

假设一张表有 1,000 万行、分支因子为 500:根节点(第 0 层)1 个,第 1 层最多 500 个节点,第 2 层最多 250,000 个节点,叶子层(第 3 层)最多 1.25 亿个条目。

仅需三次树遍历(即三次页读取),即可在 1,000 万行中定位任意一行。这正是 O(log N) 时间复杂度的体现——得益于极高的分支因子,实际查找只需极少层数。

查找过程#

要查找 email = 'alice@example.com'

  1. 从根节点开始,通过二分查找确定应跟随哪个子节点指针;
  2. 加载该子节点,再次进行二分查找;
  3. 重复此过程,直至抵达叶子节点;
  4. 叶子节点中包含指向磁盘上实际行(元组 ID 或行 ID)的指针;
  5. 从堆(主表数据区)中获取该行。
1
2
3
4
5
6
7
Root Node: [charlie@... | mike@... | zara@...]
                |              |           |
          Child < charlie  charlie-mike  mike-zara   > zara
                |
    [alice@... | bob@...]  <-- leaf node
         |
    Pointer to heap page 4721, offset 23

创建 B 树索引#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- 单列索引
CREATE INDEX idx_users_email ON users (email);

-- 唯一索引(同时强制唯一性约束)
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);

-- 查看现有索引(PostgreSQL)
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'users';

-- 查看现有索引(MySQL)
SHOW INDEX FROM users;

B+ 树:为何数据库更偏爱它?#

B-树索引结构如未来城市天际线分支

大多数数据库实现实际上使用的是 B+ 树——B 树的一种变体:

索引选择性影响

特性B 树B+ 树
数据指针位置内部节点和叶子节点均包含仅叶子节点包含
叶子节点是否链式连接是,通过兄弟指针(sibling pointers)
内部节点大小更大(存储数据指针)更小(仅存储键 + 子节点指针)
分支因子较低更高(每页可容纳更多键)
范围查询效率需回溯父节点直接沿叶子链顺序遍历
点查(point lookup)可在内部节点提前终止总是需到达叶子层

核心优势在于内部节点只存键和子节点指针,不存数据指针,单页能容纳更多键,分支因子自然更高。更高的分支因子意味着更浅的树结构,从而减少磁盘读取次数。

叶子节点间的链式连接对范围查询至关重要:

1
2
3
4
-- 范围查询:查找最近 7 天的订单
SELECT * FROM orders
WHERE created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at;

created_at 上建有 B+ 树索引,数据库只需定位第一个匹配的叶子节点,然后沿兄弟指针顺序读取所有匹配项——无需反复回溯内部节点。

哈希索引#

哈希索引利用哈希函数将键直接映射到行的物理位置:

1
2
-- PostgreSQL:显式创建哈希索引
CREATE INDEX idx_users_email_hash ON users USING hash (email);
维度哈希索引B 树索引
等值查找(=平均 O(1)O(log N)
范围查询(>, <, BETWEEN不支持支持
排序 / ORDER BY不支持支持
前缀匹配(LIKE 'abc%'不支持支持
WAL 日志记录(崩溃安全)PostgreSQL 10+ 支持始终支持
实际使用频率实践中极少使用默认索引类型,几乎总是首选

哈希索引只支持等值查找,而不支持范围查询、排序和前缀匹配。实践中,B 树索引的等值查找性能已足够优秀,而哈希索引功能受限带来的收益,往往无法弥补其适用场景狭窄的缺陷。PostgreSQL 直到版本 10 才使哈希索引具备崩溃安全性。

复合索引:列顺序至关重要#

复合索引(多列索引)将多个列作为一个整体进行索引:

1
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

该索引构建的 B+ 树首先按 user_id 排序,再在每个 user_id 下按 status 排序。类比电话簿:先按姓氏排序,再在同姓下按名字排序。

最左前缀规则(Leftmost Prefix Rule)#

一个 (a, b, c) 复合索引可满足以下查询的过滤条件:

查询过滤条件是否使用索引?原因
a最左前缀
a, b最左前缀
a, b, c完整索引
b跳过了最左列
b, c跳过了最左列
a, c部分使用使用 a,但对 c 需额外扫描
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 此查询高效利用复合索引
SELECT * FROM orders
WHERE user_id = 42 AND status = 'completed';

-- 此查询可利用索引(最左前缀)
SELECT * FROM orders
WHERE user_id = 42;

-- 此查询**无法**利用该复合索引
-- 需单独为 (status) 创建索引
SELECT * FROM orders
WHERE status = 'pending';

列顺序策略:将选择性最高的列(即能过滤掉最多行的列)放在最前面,随后是常被一起使用的列。

覆盖索引与仅索引扫描(Index-Only Scan)#

通常,索引扫描包含两个步骤:

  1. 遍历索引以定位匹配项;
  2. 从堆(主表数据)中获取剩余所需列(即“堆获取”,heap fetch)。

索引扫描与顺序扫描

第 2 步涉及随机 I/O。而 覆盖索引(covering index) 包含查询所需的所有列,从而完全避免堆获取:

1
2
3
4
5
6
7
8
9
-- 创建覆盖索引
CREATE INDEX idx_orders_covering ON orders (user_id, status)
INCLUDE (created_at, order_id);

-- 此查询可完全由索引满足
-- 无需堆获取 → “仅索引扫描”
SELECT order_id, status, created_at
FROM orders
WHERE user_id = 42 AND status = 'completed';

在 PostgreSQL 中,使用 INCLUDE 子句添加非搜索用但需覆盖的列。而在 MySQL(InnoDB)中,覆盖索引天然有效——因为 InnoDB 的二级索引若已包含查询所需全部列,即可直接服务查询。

1
2
3
4
5
6
7
-- MySQL 覆盖索引
CREATE INDEX idx_orders_covering ON orders (user_id, status, created_at, order_id);

-- 检查是否启用仅索引扫描(MySQL)
EXPLAIN SELECT order_id, status, created_at
FROM orders WHERE user_id = 42 AND status = 'completed';
-- 查看 Extra 列是否显示 "Using index"

EXPLAIN:解读查询执行计划#

EXPLAIN 显示优化器选定的执行计划;EXPLAIN ANALYZE 则真正执行查询并报告真实耗时。

PostgreSQL EXPLAIN ANALYZE#

1
2
3
4
5
6
7
8
EXPLAIN ANALYZE
SELECT u.full_name, COUNT(*) AS order_count
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.status = 'completed'
GROUP BY u.full_name
ORDER BY order_count DESC
LIMIT 10;

输出示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 Limit  (cost=1845.23..1845.26 rows=10 width=40) (actual time=12.456..12.461 rows=10 loops=1)
   ->  Sort  (cost=1845.23..1857.45 rows=4889 width=40) (actual time=12.454..12.458 rows=10 loops=1)
         Sort Key: (count(*)) DESC
         Sort Method: top-N heapsort  Memory: 25kB
         ->  HashAggregate  (cost=1723.56..1772.45 rows=4889 width=40) (actual time=11.234..11.890 rows=4889 loops=1)
               Group Key: u.full_name
               Batches: 1  Memory Usage: 913kB
               ->  Hash Join  (cost=234.67..1601.23 rows=24450 width=32) (actual time=2.345..8.901 rows=24450 loops=1)
                     Hash Cond: (o.user_id = u.user_id)
                     ->  Seq Scan on orders o  (cost=0.00..1156.00 rows=24450 width=4) (actual time=0.012..4.567 rows=24450 loops=1)
                           Filter: ((status)::text = 'completed'::text)
                           Rows Removed by Filter: 25550
                     ->  Hash  (cost=159.00..159.00 rows=10000 width=36) (actual time=2.123..2.123 rows=10000 loops=1)
                           Buckets: 16384  Batches: 1  Memory Usage: 641kB
                           ->  Seq Scan on users u  (cost=0.00..159.00 rows=10000 width=36) (actual time=0.008..0.987 rows=10000 loops=1)
 Planning Time: 0.234 ms
 Execution Time: 12.567 ms

关键字段解读:

字段含义
Seq Scan全表扫描 —— 可能需要添加索引
Index Scan使用索引 —— 对高选择性查询有利
Index Only Scan覆盖索引 —— 最优情况
Bitmap Index Scan合并多个索引结果
Hash Join / Nested Loop / Merge Join连接策略
actual time真实执行耗时(首行..末行),单位毫秒
rows实际处理的行数
Rows Removed by Filter已读取但被过滤丢弃的行数 —— 数值高表明缺少索引
loops当前步骤执行次数

MySQL EXPLAIN#

1
2
3
4
5
6
7
8
EXPLAIN
SELECT u.full_name, COUNT(*) AS order_count
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.status = 'completed'
GROUP BY u.full_name
ORDER BY order_count DESC
LIMIT 10;
1
2
3
4
5
6
+----+-------------+-------+------+------------------+---------+---------+------------------+-------+----------------------------------------------+
| id | select_type | table | type | possible_keys    | key     | key_len | ref              | rows  | Extra                                        |
+----+-------------+-------+------+------------------+---------+---------+------------------+-------+----------------------------------------------+
|  1 | SIMPLE      | o     | ref  | idx_order_status | idx_... | 82      | const            | 24450 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | u     | ref  | PRIMARY          | PRIMARY | 4       | mydb.o.user_id   |     1 | NULL                                         |
+----+-------------+-------+------+------------------+---------+---------+------------------+-------+----------------------------------------------+

MySQL EXPLAIN 中最重要的字段是 type

type含义性能
system / const至多匹配一行最优
eq_ref每次连接返回一行(主键/唯一键)极佳
ref通过非唯一索引匹配多行良好
range索引范围扫描良好
index全索引扫描(读取所有索引条目)中等
ALL全表扫描最差 —— 通常需加索引

识别性能问题#

以下是一个糟糕的查询计划(PostgreSQL):

1
2
3
4
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
  AND created_at > '2023-01-01';
1
2
3
4
5
 Seq Scan on orders  (cost=0.00..2456.00 rows=245 width=48) (actual time=0.034..18.567 rows=234 loops=1)
   Filter: (((status)::text = 'pending'::text) AND (created_at > '2023-01-01'))
   Rows Removed by Filter: 49766
 Planning Time: 0.089 ms
 Execution Time: 18.623 ms

警示信号:

  • 在 50,000 行的表上执行 Seq Scan
  • Rows Removed by Filter: 49766 —— 读取了 50,000 行才找到 234 行。

修复方案:

1
CREATE INDEX idx_orders_status_created ON orders (status, created_at);

建索引后:

1
2
3
4
 Index Scan using idx_orders_status_created on orders  (cost=0.29..12.45 rows=245 width=48) (actual time=0.023..0.189 rows=234 loops=1)
   Index Cond: (((status)::text = 'pending'::text) AND (created_at > '2023-01-01'))
 Planning Time: 0.102 ms
 Execution Time: 0.234 ms

从 18.6 毫秒降至 0.23 毫秒——80 倍性能提升,仅靠一个索引。

索引选型策略#

应该建立索引的列#

  1. 主键(Primary keys):所有数据库均自动为其建立索引;
  2. 外键(Foreign keys):务必索引——JOIN 操作频繁依赖;
  3. WHERE 子句中的列:尤其是高频查询中的过滤条件;
  4. ORDER BY 子句中的列:避免昂贵的排序操作;
  5. GROUP BY 子句中的列:加速聚合计算;
  6. JOIN 条件中的列:除外键外,任何连接表达式中的列。

基数(Cardinality)至关重要#

基数 = 列中不同值的数量。

基数是否适合建索引?
email(唯一)10,000,000是 —— 高度选择性
country195视查询模式而定
status(active/inactive)2极少 —— 选择性不足
is_deleted(true/false)2 —— 应改用部分索引(partial index)

低基数列的每个值对应大量行,优化器往往倾向于顺序扫描而非索引扫描。

例外:若某低基数值极为稀有(如 status = 'fraud' 仅匹配 0.01% 的行),则它具备高选择性,索引依然有效;此时部分索引效果更佳。

过度索引:隐藏的成本#

放大镜下的数据库索引揭示优化查询

每个索引都附带成本:写放大(每次 INSERT/UPDATE/DELETE 都要同步更新所有相关索引)、存储开销(索引体积常达表的 10–30%)、内存争用(与表共享缓冲池(buffer pool))、规划延迟(索引越多,优化器评估路径越耗时)以及维护负担(如 VACUUM、REINDEX、统计更新等)。

一张表若有 10 个索引,则每次 INSERT 实际需写入 11 个数据结构(表 + 10 个索引)。对写密集型负载而言,这是灾难性的。

经验法则:多数 OLTP 表应维持 3–5 个索引。若超过 8 个,请全面审计。

在 PostgreSQL 中检查未使用的索引:

1
2
3
4
5
6
7
8
9
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan AS times_used,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

部分索引(Partial Indexes)#

部分索引仅对满足特定条件的行建立索引:

1
2
3
4
5
6
7
-- 仅索引未删除的用户(若 99% 用户未被删除)
CREATE INDEX idx_users_active ON users (email)
WHERE is_deleted = FALSE;

-- 仅索引待处理订单(若大部分订单已完成)
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

优势:

  • 体积远小于全量索引;
  • 维护更快(需更新的条目更少);
  • 缓冲池命中率更高。

但查询中必须包含部分索引的 WHERE 条件,优化器才会选用它:

1
2
3
4
5
6
7
-- 此查询可使用 idx_orders_pending
SELECT * FROM orders
WHERE status = 'pending' AND created_at > '2023-12-01';

-- 此查询**不会**使用 idx_orders_pending
SELECT * FROM orders
WHERE status = 'completed' AND created_at > '2023-12-01';

表达式索引(Expression Indexes)#

你可以对表达式或函数的结果建立索引:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 为大小写不敏感查找建立小写 email 索引
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

-- 查询必须使用相同表达式
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

-- 为时间戳提取年份建立索引
CREATE INDEX idx_orders_year ON orders (EXTRACT(YEAR FROM created_at));

-- 为 JSONB 字段建立索引(PostgreSQL)
CREATE INDEX idx_users_metadata_country
ON users ((metadata->>'country'));

若未使用表达式索引,WHERE 子句中调用函数将导致优化器无法使用该列上的普通索引:

1
2
3
4
5
6
-- 此查询**无法**使用 email 列上的普通索引
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- 数据库看到的是 LOWER(email),而非 email —— 二者语义不同

-- 此查询**可以**使用 email 列上的普通索引
SELECT * FROM users WHERE email = 'alice@example.com';

GIN 与 GiST 索引(PostgreSQL)#

除 B 树外,PostgreSQL 提供多种专用索引类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- GIN 索引用于全文检索
CREATE INDEX idx_products_search ON products
USING gin (to_tsvector('english', name || ' ' || description));

-- 全文检索查询
SELECT name, ts_rank(to_tsvector('english', name || ' ' || description),
                     plainto_tsquery('english', 'wireless keyboard')) AS rank
FROM products
WHERE to_tsvector('english', name || ' ' || description)
      @@ plainto_tsquery('english', 'wireless keyboard')
ORDER BY rank DESC;

-- GIN 索引用于 JSONB 包含查询
CREATE INDEX idx_users_metadata ON users USING gin (metadata jsonb_path_ops);

SELECT * FROM users WHERE metadata @> '{"country": "US"}';

-- GiST 索引用于几何/区间数据
CREATE INDEX idx_events_timerange ON events USING gist (time_range);
索引类型最适用场景支持的操作符
B-tree等值、范围、排序=, <, >, BETWEEN, ORDER BY, LIKE 'prefix%'
Hash仅等值=
GIN数组、JSONB、全文检索@>, &&, @@, ?, ?&
GiST几何、区间、最近邻<<, >>, &&, @>, <->
BRIN大型、天然有序的表<, >, =(精度略降)

BRIN 索引:块范围索引#

BRIN 索引是为自然有序数据(时间戳、自增 ID)设计的极小索引。它不索引每一行,而是存储每个块范围(通常 128 页)的最小/最大值。

BRIN 工作原理#

1
2
3
块范围 1 (第 0-127 页):   min_date = 2024-01-01, max_date = 2024-01-15
块范围 2 (第 128-255 页): min_date = 2024-01-15, max_date = 2024-01-31
块范围 3 (第 256-383 页): min_date = 2024-02-01, max_date = 2024-02-14

查询 WHERE date > '2024-02-01' 时,PostgreSQL 跳过 max_date 早于阈值的整个块范围。

1
2
3
4
5
6
7
-- 创建 BRIN 索引(订单大致按时间顺序插入)
CREATE INDEX idx_orders_created_brin ON orders
USING BRIN (created_at) WITH (pages_per_range = 64);

-- 对比大小
SELECT pg_size_pretty(pg_relation_size('idx_orders_created_brin')) AS brin_size;
-- 结果: 48 kB(相比等效 B-tree 在 1 亿行上需要 2.1 GB)

BRIN 适用 vs 不适用#

条件BRIN 表现B-tree 表现
数据物理上按索引列排序优秀(跳过整个范围)良好
数据随机排列糟糕(每个范围都匹配)良好
表超过 1 亿行索引小约 1000 倍可用但索引巨大
点查询 (WHERE id = X)慢(扫描匹配范围)快(单路径)

规则: 行位置与列值的相关性高时 BRIN 才有效。用以下查询检查:

1
2
3
SELECT correlation FROM pg_stats
WHERE tablename = 'orders' AND attname = 'created_at';
-- 结果: 0.98 → 非常适合 BRIN。低于 0.5 → 不要用

数据库中的布隆过滤器#

布隆过滤器回答"这个元素可能在集合中吗?"——零假阴性,可调假阳性率。数据库用它避免不必要的磁盘读取。

布隆过滤器的应用#

数据库用途
PostgreSQL (bloom 扩展)多列等值过滤
RocksDB / LevelDB跳过不可能包含目标键的 SSTable
Cassandra读取时跳过 SSTable
Parquet 文件跳过行组

PostgreSQL Bloom 索引#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CREATE EXTENSION bloom;

-- Bloom 索引:多列等值查询的高效方案
CREATE INDEX idx_logs_bloom ON logs USING bloom (
    user_id, session_id, action, status_code
) WITH (length=80, col1=2, col2=2, col3=4, col4=2);

-- 这单个索引加速任意列组合:
SELECT * FROM logs WHERE user_id = 42 AND status_code = 500;
SELECT * FROM logs WHERE action = 'login' AND session_id = 'abc';

B-tree 复合索引只对使用索引列前缀的查询有效。Bloom 索引对任何组合都有效,代价是更高的假阳性率。

索引维护与膨胀#

索引随时间退化。更新/删除留下的死元组造成空洞。理解和管理膨胀是生产数据库的必备技能。

检测索引膨胀(PostgreSQL)#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE EXTENSION pgstattuple;

SELECT
    indexrelname AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan AS scans
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 10;

-- 详细膨胀估算
SELECT * FROM pgstatindex('idx_orders_user_id');
-- avg_leaf_density < 50% → 严重膨胀

REINDEX#

1
2
3
4
5
-- 重建膨胀的索引(短暂锁表)
REINDEX INDEX idx_orders_user_id;

-- CONCURRENTLY:不锁表重建(PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

未使用索引检测#

1
2
3
4
5
6
7
8
9
-- 查找自上次统计重置以来未被扫描的索引
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size,
    idx_scan AS scans_since_reset
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND pg_relation_size(indexrelid) > 1048576  -- > 1MB
ORDER BY pg_relation_size(indexrelid) DESC;

每个未使用的索引占用磁盘空间并拖慢每个 INSERT/UPDATE/DELETE。验证生产流量后果断删除。

实用索引设计流程#

遇到慢查询时,请遵循以下流程:

1
2
3
4
5
6
7
8
1. 对慢查询运行 EXPLAIN ANALYZE
2. 寻找带有高 "Rows Removed by Filter" 的 Seq Scan
3. 识别 WHERE/JOIN/ORDER BY 中缺失索引的列
4. 检查这些列的基数
5. 创建最具选择性的复合索引
6. 再次运行 EXPLAIN ANALYZE 验证改进效果
7. 监控 pg_stat_user_indexes 以确认实际使用率
8. 30 天后删除未使用的索引

完整示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 步骤 1:慢查询
EXPLAIN ANALYZE
SELECT p.name, SUM(oi.quantity) AS total_sold
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
JOIN orders o ON oi.order_id = o.order_id
WHERE o.created_at >= '2023-11-01'
  AND o.status = 'completed'
GROUP BY p.name
ORDER BY total_sold DESC
LIMIT 10;
-- Execution Time: 234.567 ms

-- 步骤 2–4:orders 表出现 Seq Scan,过滤掉 80% 的行
-- 需要在 (status, created_at) 上建索引

-- 步骤 5:创建索引
CREATE INDEX idx_orders_status_date ON orders (status, created_at);

-- 步骤 6:验证
EXPLAIN ANALYZE
-- ... 同一查询 ...
-- Execution Time: 3.456 ms  (68 倍提速)

下一步#

索引告诉数据库去哪里查找数据。但当两个事务同时尝试修改同一行数据时,会发生什么?在下一篇文章中,我们将深入探讨 事务与并发控制——ACID 保证、隔离级别、锁机制,以及预防死锁的“黑魔法”。

  1. 01 数据库(一):数据模型与 SQL —— 为何关系型表结构至今仍占主导地位
  2. 02 数据库(二):索引与查询规划——数据库如何找到你的数据 当前
  3. 03 数据库(三):事务与并发控制——ACID、隔离级别与锁机制
  4. 04 数据库(四):存储引擎——数据如何落盘
  5. 05 数据库(五):NoSQL——文档型、键值型、列式与图数据库
  6. 06 数据库(六):复制与分片——突破单机限制的扩展之道
  7. 07 数据库(七):分布式事务——两阶段提交、Saga 模式,以及为何共识如此困难
  8. 08 数据库(八):实战中的数据库——迁移、监控与故障案例

读有所得?

GitHub 关注我 → 新文周更

GitHub