SQLite 完全指南 / 07 - 索引
07 - 索引:B-Tree、部分索引、表达式索引与覆盖索引
7.1 索引基础
索引是数据库性能优化的核心。SQLite 使用 B-Tree 结构来组织索引。
7.1.1 为什么需要索引
-- 创建测试数据
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
message TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 插入 10 万条测试数据
INSERT INTO logs (level, message, source, created_at)
SELECT
CASE ABS(RANDOM()) % 5
WHEN 0 THEN 'DEBUG' WHEN 1 THEN 'INFO' WHEN 2 THEN 'WARN'
WHEN 3 THEN 'ERROR' ELSE 'FATAL'
END,
'Log message ' || id,
CASE ABS(RANDOM()) % 3
WHEN 0 THEN 'app' WHEN 1 THEN 'auth' ELSE 'api'
END,
datetime('now', '-' || ABS(RANDOM()) % 365 || ' days')
FROM (
WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt WHERE x < 100000)
SELECT x AS id FROM cnt
);
-- 无索引查询
.timer on
SELECT * FROM logs WHERE level = 'ERROR' AND source = 'auth';
-- 可能需要几十毫秒(全表扫描)
-- 创建索引后
CREATE INDEX idx_logs_level_source ON logs(level, source);
SELECT * FROM logs WHERE level = 'ERROR' AND source = 'auth';
-- 几毫秒(使用索引)
7.1.2 创建和删除索引
-- 基本索引
CREATE INDEX idx_logs_level ON logs(level);
-- IF NOT EXISTS(幂等操作)
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
-- 唯一索引
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 多列索引(复合索引)
CREATE INDEX idx_logs_level_source ON logs(level, source);
-- 删除索引
DROP INDEX IF EXISTS idx_logs_level;
-- 重命名索引(SQLite 3.25.0+)
ALTER INDEX idx_logs_level RENAME TO idx_logs_level_v2;
7.2 B-Tree 索引结构
7.2.1 B+Tree 的工作原理
索引 idx_logs_level_source (level, source)
┌───────────────────────┐
│ Root Node │
│ [ERROR,a] [INFO,a] │
└───┬───────┬───────┬──┘
│ │ │
┌──────────┘ │ └──────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Leaf Node │ │ Leaf Node │ │ Leaf Node │
│ ERROR,api │ │ INFO,api │ │ WARN,api │
│ ERROR,app │ │ INFO,app │ │ WARN,app │
│ ERROR,auth │ │ INFO,auth │ │ WARN,auth │
│ → rowid │ │ → rowid │ │ → rowid │
└─────────────┘ └─────────────┘ └─────────────┘
7.2.2 索引的代价
| 代价 | 说明 |
|---|---|
| 写入性能下降 | 每次 INSERT/UPDATE/DELETE 都要维护索引 |
| 存储空间 | 索引本身占用磁盘空间 |
| 内存消耗 | 索引页面需要缓存 |
-- 查看索引大小
ANALYZE; -- 收集统计信息
SELECT * FROM sqlite_stat1 WHERE tbl = 'logs';
-- 查看数据库大小
PRAGMA page_count;
PRAGMA page_size;
SELECT page_count * page_size / 1024.0 / 1024.0 AS size_mb FROM pragma_page_count(), pragma_page_size();
7.3 复合索引
7.3.1 列顺序的重要性
复合索引的列顺序至关重要——SQLite 只能从左到右使用索引列。
CREATE INDEX idx_logs_level_source_date ON logs(level, source, created_at);
-- ✅ 可以使用索引
SELECT * FROM logs WHERE level = 'ERROR';
SELECT * FROM logs WHERE level = 'ERROR' AND source = 'auth';
SELECT * FROM logs WHERE level = 'ERROR' AND source = 'auth' AND created_at > '2026-01-01';
-- ❌ 不能使用索引(跳过了 level 和 source)
SELECT * FROM logs WHERE created_at > '2026-01-01';
-- ⚠️ 只能使用索引的第一列
SELECT * FROM logs WHERE source = 'auth';
7.3.2 最左前缀原则
索引 (a, b, c) 可以用于:
✅ a
✅ a AND b
✅ a AND b AND c
✅ a AND c(只能用到 a)
❌ b
❌ c
❌ b AND c
-- 验证:查看查询计划
EXPLAIN QUERY PLAN
SELECT * FROM logs WHERE level = 'ERROR' AND source = 'auth';
-- SCAN TABLE logs USING INDEX idx_logs_level_source (level=? AND source=?)
EXPLAIN QUERY PLAN
SELECT * FROM logs WHERE source = 'auth';
-- SCAN TABLE logs(全表扫描)
7.3.3 复合索引设计策略
-- 策略 1:等值查询列在前,范围查询列在后
-- 查询:WHERE status = 'active' AND created_at > '2026-01-01'
CREATE INDEX idx_good ON orders(status, created_at);
-- ❌ 不好:范围查询列在前
CREATE INDEX idx_bad ON orders(created_at, status);
-- 策略 2:选择性高的列在前
-- level 只有 5 个值,source 只有 3 个值
-- 但查询通常先过滤 level,所以 level 在前
CREATE INDEX idx_logs_level_source ON logs(level, source);
7.4 部分索引(Partial Index)
部分索引只为满足条件的行创建索引,节省空间和维护开销。
-- 只为活跃用户创建索引
CREATE INDEX idx_active_users_email ON users(email) WHERE is_active = 1;
-- 只为错误日志创建索引(假设错误日志占比 5%)
CREATE INDEX idx_error_logs ON logs(source, created_at) WHERE level = 'ERROR';
-- 使用部分索引
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = 'test@example.com' AND is_active = 1;
-- SCAN TABLE users USING INDEX idx_active_users_email
-- ❌ 不能使用部分索引(条件不匹配)
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = 'test@example.com' AND is_active = 0;
-- SCAN TABLE users(全表扫描)
7.4.1 部分索引的优势
| 场景 | 全索引 | 部分索引 |
|---|---|---|
| 100 万行,仅 1% 活跃 | 索引 100 万条 | 索引 1 万条 |
| 写入性能 | 每次写入都更新 | 仅活跃行更新 |
| 存储空间 | 大 | 小 |
| 适用场景 | 查询范围不确定 | 查询条件固定且选择性高 |
7.5 表达式索引(Expression Index)
SQLite 3.9.0+ 支持在表达式上创建索引:
-- 不区分大小写的邮箱查找
CREATE INDEX idx_users_email_lower ON users(lower(email));
-- 使用表达式索引
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE lower(email) = 'test@example.com';
-- SCAN TABLE users USING INDEX idx_users_email_lower
-- 日期部分索引
CREATE INDEX idx_logs_month ON logs(strftime('%Y-%m', created_at));
-- JSON 字段索引
CREATE INDEX idx_users_city ON users(json_extract(profile, '$.city'));
7.5.1 常见表达式索引模式
| 模式 | 索引定义 | 查询使用 |
|---|---|---|
| 大小写不敏感 | lower(email) | WHERE lower(email) = ? |
| 日期截取 | strftime('%Y-%m', dt) | WHERE strftime('%Y-%m', dt) = '2026-05' |
| JSON 提取 | json_extract(data, '$.key') | WHERE json_extract(data, '$.key') = ? |
| 计算列 | price * quantity | WHERE price * quantity > 1000 |
| 字符串长度 | length(name) | WHERE length(name) > 10 |
7.6 覆盖索引(Covering Index)
当索引包含查询所需的所有列时,SQLite 无需回表查询——这称为覆盖索引。
-- 普通索引(需要回表)
CREATE INDEX idx_orders_user ON orders(user_id);
-- 查询:需要回表获取 total_amount
SELECT user_id, total_amount FROM orders WHERE user_id = 1;
-- SEARCH TABLE orders USING INDEX idx_orders_user (user_id=?)
-- 然后需要访问主表获取 total_amount
-- 覆盖索引(无需回表)
CREATE INDEX idx_orders_user_cover ON orders(user_id, total_amount);
-- 查询:所有数据都在索引中
EXPLAIN QUERY PLAN
SELECT user_id, total_amount FROM orders WHERE user_id = 1;
-- SEARCH TABLE orders USING COVERING INDEX idx_orders_user_cover (user_id=?)
7.6.1 覆盖索引设计
-- 查询:SELECT a, b FROM t WHERE c = ?
-- 覆盖索引:(c, a, b)
CREATE INDEX idx_cover ON t(c, a, b);
-- 查询:SELECT COUNT(*) FROM t WHERE status = 'active'
-- 覆盖索引:(status)
CREATE INDEX idx_status ON t(status);
-- 查询:SELECT id, name FROM t ORDER BY name
-- 覆盖索引:(name, id)
CREATE INDEX idx_name_cover ON t(name, id);
7.7 WITHOUT ROWID 表
WITHOUT ROWID 表使用聚簇索引(clustered index),适合复合主键的小型表。
-- 普通表(有隐藏的 rowid)
CREATE TABLE tags (
article_id INTEGER NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (article_id, tag)
);
-- WITHOUT ROWID 表(数据直接按主键存储)
CREATE TABLE tags_norowid (
article_id INTEGER NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (article_id, tag)
) WITHOUT ROWID;
| 特性 | 普通表 | WITHOUT ROWID 表 |
|---|---|---|
| 存储方式 | 主键索引 + rowid 指针 | 聚簇索引(直接存储) |
| 适用场景 | 自增主键 | 复合主键、小记录 |
| 查询性能 | 主键查询需要两次查找 | 主键查询直接定位 |
| 不支持 | — | AUTOINCREMENT |
7.8 索引维护
7.8.1 ANALYZE
-- 收集统计信息,帮助查询优化器选择最优索引
ANALYZE;
-- 只分析特定表
ANALYZE logs;
-- 查看统计信息
SELECT * FROM sqlite_stat1;
-- tbl | idx | stat
-- logs | idx_logs_level_source | 100000 20000 33333
-- 含义:10 万行,level 有 20000 个不同值对,平均每个 level 值有 33333 行
-- 清除统计信息
DELETE FROM sqlite_stat1;
7.8.2 查看索引使用情况
-- 列出表的所有索引
.indices logs
-- 查看索引定义
SELECT * FROM sqlite_master WHERE type = 'index' AND tbl_name = 'logs';
-- EXPLAIN QUERY PLAN 查看是否使用了索引
EXPLAIN QUERY PLAN SELECT * FROM logs WHERE level = 'ERROR';
7.9 索引设计最佳实践
7.9.1 索引设计清单
| # | 原则 | 说明 |
|---|---|---|
| 1 | WHERE 子句中的列 | 创建索引以加速过滤 |
| 2 | JOIN 的连接列 | 加速表关联 |
| 3 | ORDER BY 列 | 避免文件排序 |
| 4 | 复合索引列顺序 | 等值在前,范围在后 |
| 5 | 覆盖索引 | 热点查询的列全部放入索引 |
| 6 | 部分索引 | 固定条件的查询使用部分索引 |
| 7 | 避免过多索引 | 每个额外索引都会降低写入性能 |
| 8 | 定期 ANALYZE | 保持统计信息准确 |
7.9.2 何时不创建索引
| 场景 | 原因 |
|---|---|
| 小表(< 1000 行) | 全表扫描可能更快 |
| 频繁更新的列 | 索引维护开销大 |
| 选择性极低的列 | 如布尔值(只有 true/false) |
| 大量写入的表 | 每次写入都要更新索引 |
⚠️ 注意事项
- 索引列顺序很重要——复合索引只能从左到右使用
- 部分索引的条件必须在查询中出现——否则优化器不会使用该索引
- 表达式索引的表达式必须与查询完全匹配——
lower(email)索引不能用于UPPER(email)查询 - ANALYZE 会读取大量数据——大表执行 ANALYZE 时可能较慢
- 索引过多会严重影响写入性能——一般建议每张表不超过 5-6 个索引
- WITHOUT ROWID 不适合大记录——记录大小应远小于页面大小的一半
💡 技巧
- 使用 EXPLAIN QUERY PLAN 验证索引是否被使用
- 覆盖索引是性能优化的终极手段——避免回表查询
- 部分索引非常适合状态过滤——如只索引"活跃"的订单
- 定期执行 ANALYZE 保持优化器决策准确
- 组合使用表达式索引和部分索引——精确覆盖查询场景
📌 业务场景
场景一:用户登录系统
-- 邮箱查找(大小写不敏感)
CREATE INDEX idx_users_email_lower ON users(lower(email));
-- 登录查询
SELECT * FROM users
WHERE lower(email) = lower('Test@Example.com')
AND password_hash = 'hashed_pw';
场景二:日志查询系统
-- 只为错误日志建立详细索引(错误日志占比 < 5%)
CREATE INDEX idx_error_detail ON logs(source, created_at, message)
WHERE level IN ('ERROR', 'FATAL');
-- 覆盖查询
SELECT source, created_at, message
FROM logs
WHERE level IN ('ERROR', 'FATAL')
AND source = 'auth'
AND created_at > '2026-04-01';
场景三:电商订单查询
-- 用户订单列表(覆盖索引)
CREATE INDEX idx_orders_user_cover ON orders(user_id, status, created_at, total_amount);
-- 查询用户近期订单
SELECT created_at, total_amount, status
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
🔗 扩展阅读
📖 下一章:08 - 查询优化 —— EXPLAIN、查询计划、索引选择