第 08 章:索引与性能优化
第 08 章:索引与性能优化
8.1 索引基础
8.1.1 为什么图数据库也需要索引?
虽然图数据库的遍历操作是沿着边指针直接导航(O(1)),但起始节点的定位仍然需要索引:
查询: MATCH (p:Person {name: 'Alice'})-[:KNOWS]->(f) RETURN f
执行过程:
1. 定位起点 "Alice" ← 这一步需要索引!O(log N)
2. 沿 KNOWS 边遍历 ← 直接指针导航 O(k)
3. 获取朋友顶点 ← O(k)
没有索引:
1. 全表扫描找 "Alice" ← O(N) 性能灾难!
8.1.2 AgensGraph 索引类型
| 索引类型 | 适用场景 | 底层实现 |
|---|
| B-Tree | 等值查询、范围查询 | PostgreSQL B-Tree |
| GiST | 全文搜索、地理空间 | PostgreSQL GiST |
| GIN | 数组、JSONB 属性 | PostgreSQL GIN |
| Hash | 等值查询 | PostgreSQL Hash |
| Label Index | 标签扫描 | AgensGraph 专用 |
| Graph Index | 图遍历优化 | AgensGraph 专用 |
8.2 创建索引
8.2.1 标签索引
标签索引是最基本的图索引,加速按标签查找顶点:
-- 为 Person 标签创建索引(通常自动创建)
CREATE INDEX ON :Person(name);
-- 查看已有索引
-- 使用 SQL 系统视图
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'social_network';
8.2.2 属性索引
-- 单属性索引
CREATE INDEX ON :Person(name);
CREATE INDEX ON :Person(email);
CREATE INDEX ON :Product(price);
-- 复合属性索引
CREATE INDEX ON :Person(city, age);
-- 在边上创建索引
CREATE INDEX ON :KNOWS(since);
CREATE INDEX ON :WORKS_AT(position);
8.2.3 唯一索引(通过约束创建)
-- 唯一约束会自动创建唯一索引
CREATE CONSTRAINT person_email_unique
ON (p:Person)
ASSERT p.email IS UNIQUE;
-- 验证约束
SELECT conname, contype
FROM pg_constraint
WHERE conrelid = 'person'::regclass;
8.2.4 GIN 索引(JSONB/数组属性)
-- 在 SQL 层面为 JSONB 属性创建 GIN 索引
-- 适用于属性查询包含嵌套字段的场景
CREATE INDEX idx_person_props_gin
ON person USING GIN (properties);
-- 查询嵌套属性
SELECT * FROM person
WHERE properties @> '{"address": {"city": "北京"}}';
8.2.5 全文搜索索引
-- 使用 GIN 索引支持全文搜索
CREATE INDEX idx_post_content_fts
ON post USING GIN (to_tsvector('chinese', properties->>'content'));
8.3 查询计划分析
8.3.1 EXPLAIN 命令
-- 基本执行计划
EXPLAIN MATCH (p:Person {name: 'Alice'})-[:KNOWS]->(f:Person)
RETURN f.name;
-- 详细执行计划(包含代价和行数估算)
EXPLAIN (VERBOSE, COSTS ON)
MATCH (p:Person {name: 'Alice'})-[:KNOWS]->(f:Person)
RETURN f.name;
-- 实际执行计划(包含真实运行时间)
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
MATCH (p:Person {name: 'Alice'})-[:KNOWS]->(f:Person)
RETURN f.name;
8.3.2 执行计划解读
QUERY PLAN
────────────────────────────────────────────────────
HashAggregate (cost=15.05..15.06 rows=1 width=32)
→ GraphScan (cost=0.00..15.03 rows=2 width=32)
→ Nested Loop (cost=0.56..12.00 rows=2 width=64)
→ Index Scan using person_name_idx
on person p (cost=0.28..8.29 rows=1)
Index Cond: (name = 'Alice')
→ Index Scan using edge_start_idx
on knows (cost=0.28..3.72 rows=2)
Index Cond: (start_id = p.id)
Filter: (label = 'KNOWS')
关键指标:
| 指标 | 含义 | 优化方向 |
|---|
cost | 估算代价 | 越低越好 |
rows | 估算行数 | 接近实际行数为好 |
actual time | 实际执行时间 | 关注高耗时节点 |
loops | 循环次数 | 嵌套循环次数多说明优化空间大 |
Buffers | 缓冲区命中 | hit 多为好,read 多说明 I/O 重 |
8.3.3 常见计划操作对比
| 操作 | 说明 | 性能 | 何时出现 |
|---|
| Index Scan | 索引扫描 | ⭐⭐⭐⭐⭐ | 有索引 + 精确条件 |
| Index Only Scan | 索引覆盖扫描 | ⭐⭐⭐⭐⭐ | 查询字段都在索引中 |
| Bitmap Index Scan | 位图索引扫描 | ⭐⭐⭐⭐ | 多条件组合 |
| Seq Scan | 全表扫描 | ⭐ | 无索引 / 小表 |
| Nested Loop | 嵌套循环连接 | ⭐⭐⭐ | 小表连接 |
| Hash Join | 哈希连接 | ⭐⭐⭐⭐ | 等值连接、中等表 |
| Merge Join | 合并连接 | ⭐⭐⭐⭐ | 排序数据连接 |
8.4 索引优化策略
8.4.1 选择性分析
索引的效果取决于属性的选择性(Selectivity):
-- 查看属性值分布
SELECT properties->>'city' AS city, count(*) AS cnt
FROM person
GROUP BY city
ORDER BY cnt DESC;
-- 选择性 = 不同值数量 / 总行数
-- 选择性接近 1 → 索引效果好
-- 选择性接近 0 → 索引效果差
| 选择性 | 索引效果 | 示例 |
|---|
| > 0.8 | ⭐⭐⭐⭐⭐ | email, phone, id |
| 0.3 - 0.8 | ⭐⭐⭐⭐ | name, city |
| 0.1 - 0.3 | ⭐⭐ | age, department |
| < 0.1 | ⭐ | gender, status (布尔) |
8.4.2 复合索引设计
-- 考虑查询模式来设计复合索引
-- 查询: WHERE city = '北京' AND age > 25
-- ✅ 正确顺序:高选择性列在前
CREATE INDEX ON :Person(city, age);
-- ❌ 错误顺序:低选择性列在前
CREATE INDEX ON :Person(age, city);
复合索引顺序规则:
- 等值条件列放在前面
- 范围条件列放在后面
- 选择性高的列优先
8.3.3 覆盖索引
-- 创建包含查询所需字段的索引
CREATE INDEX idx_person_covering
ON person (properties->>'name')
INCLUDE (properties->>'age', properties->>'city');
-- 查询可以直接从索引获取数据,无需回表
SELECT properties->>'name', properties->>'age'
FROM person
WHERE properties->>'name' = 'Alice';
8.5 查询优化技巧
8.5.1 Cypher 查询优化
-- ❌ 低效:先遍历再过滤
MATCH (p:Person)-[:KNOWS]->(f:Person)
WHERE p.name = 'Alice'
RETURN f.name;
-- ✅ 高效:先过滤再遍历
MATCH (p:Person {name: 'Alice'})-[:KNOWS]->(f:Person)
RETURN f.name;
8.5.2 使用 Profile 分析
-- profile 会实际执行查询并返回统计信息
PROFILE
MATCH (p:Person)-[:KNOWS*1..3]->(fof:Person)
WHERE p.name = 'Alice'
RETURN DISTINCT fof.name;
8.5.3 优化 Checklist
| 检查项 | 说明 | 工具 |
|---|
| ✅ 起始节点是否高效定位 | 使用索引属性定位 | EXPLAIN |
| ✅ 是否避免了全表扫描 | 检查 Seq Scan | EXPLAIN |
| ✅ 过滤条件是否尽早执行 | WHERE 在 MATCH 中 | EXPLAIN |
| ✅ 变长路径是否有深度限制 | *1..N 而非 * | 代码审查 |
| ✅ 返回字段是否精确 | RETURN 特定字段而非 * | 代码审查 |
| ✅ 是否使用了 LIMIT | 避免返回过多结果 | 代码审查 |
| ✅ 索引是否覆盖常用查询 | pg_indexes | SQL 查询 |
| ✅ 统计信息是否最新 | ANALYZE 命令 | ANALYZE table; |
8.6 统计信息管理
8.6.1 更新统计信息
-- 更新特定表的统计信息
ANALYZE person;
ANALYZE knows;
-- 更新所有表的统计信息
ANALYZE;
-- 查看统计信息
SELECT
schemaname,
tablename,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
8.6.2 统计信息对优化器的影响
-- 查看某列的统计信息
SELECT
attname AS column_name,
n_distinct AS distinct_values,
most_common_vals AS common_values,
most_common_freqs AS common_freqs
FROM pg_stats
WHERE tablename = 'person' AND attname = 'properties';
8.7 业务场景:大规模社交网络优化
场景数据
| 实体 | 数量 | 说明 |
|---|
| Person 顶点 | 1000 万 | 用户 |
| KNOWS 边 | 5000 万 | 好友关系 |
| FOLLOWS 边 | 1 亿 | 关注关系 |
| Post 顶点 | 2 亿 | 动态 |
优化前
-- 查询:找 Alice 的二度好友(未优化)
MATCH (alice:Person)-[:KNOWS]->()-[:KNOWS]->(fof:Person)
WHERE alice.name = 'Alice'
RETURN DISTINCT fof.name;
-- 执行时间: 45 秒
优化步骤
-- 1. 创建索引
CREATE INDEX ON :Person(name);
CREATE INDEX ON :KNOWS(start_id);
-- 2. 优化查询
MATCH (alice:Person {name: 'Alice'})-[:KNOWS*2]->(fof:Person)
RETURN DISTINCT fof.name
LIMIT 100;
-- 执行时间: 0.3 秒
优化效果对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|
| 执行时间 | 45s | 0.3s | 150x |
| 缓冲区读取 | 500K | 2K | 250x |
| CPU 时间 | 42s | 0.28s | 150x |
8.8 维护操作
8.8.1 VACUUM 与 ANALYZE
-- 清理死元组(不影响在线服务)
VACUUM (VERBOSE) person;
-- 清理并回收空间(需要排他锁)
VACUUM FULL person;
-- 更新统计信息
ANALYZE person;
-- 自动 VACUUM 设置
ALTER SYSTEM SET autovacuum = on;
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.1;
ALTER SYSTEM SET autovacuum_analyze_scale_factor = 0.05;
SELECT pg_reload_conf();
8.8.2 索引维护
-- 查看索引使用情况
SELECT
indexrelname AS index_name,
idx_scan AS times_used,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'social_network'
ORDER BY idx_scan DESC;
-- 重建索引(消除碎片)
REINDEX INDEX CONCURRENTLY person_name_idx;
-- 查找未使用的索引
SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname NOT IN ('pg_catalog', 'information_schema');
8.9 本章小结
| 要点 | 说明 |
|---|
| 索引作用 | 加速起始节点定位,遍历本身是 O(k) |
| 核心索引 | B-Tree(属性查询)、GIN(JSONB/数组) |
| EXPLAIN | 理解执行计划是优化的关键 |
| 选择性 | 高选择性列适合建索引 |
| 覆盖索引 | 减少回表查询 |
| 统计信息 | 定期 ANALYZE 保持优化器准确 |
8.10 练习
- 使用
EXPLAIN ANALYZE 对比有无索引时的查询性能差异。 - 为一个包含 10 万顶点的测试图创建合理的索引策略。
- 优化一个包含
WHERE 条件的变长路径查询。 - 编写脚本定期检查索引使用率并报告未使用的索引。
8.11 扩展阅读