强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 * quantityWHERE 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 索引设计清单

#原则说明
1WHERE 子句中的列创建索引以加速过滤
2JOIN 的连接列加速表关联
3ORDER BY 列避免文件排序
4复合索引列顺序等值在前,范围在后
5覆盖索引热点查询的列全部放入索引
6部分索引固定条件的查询使用部分索引
7避免过多索引每个额外索引都会降低写入性能
8定期 ANALYZE保持统计信息准确

7.9.2 何时不创建索引

场景原因
小表(< 1000 行)全表扫描可能更快
频繁更新的列索引维护开销大
选择性极低的列如布尔值(只有 true/false)
大量写入的表每次写入都要更新索引

⚠️ 注意事项

  1. 索引列顺序很重要——复合索引只能从左到右使用
  2. 部分索引的条件必须在查询中出现——否则优化器不会使用该索引
  3. 表达式索引的表达式必须与查询完全匹配——lower(email) 索引不能用于 UPPER(email) 查询
  4. ANALYZE 会读取大量数据——大表执行 ANALYZE 时可能较慢
  5. 索引过多会严重影响写入性能——一般建议每张表不超过 5-6 个索引
  6. WITHOUT ROWID 不适合大记录——记录大小应远小于页面大小的一半

💡 技巧

  1. 使用 EXPLAIN QUERY PLAN 验证索引是否被使用
  2. 覆盖索引是性能优化的终极手段——避免回表查询
  3. 部分索引非常适合状态过滤——如只索引"活跃"的订单
  4. 定期执行 ANALYZE 保持优化器决策准确
  5. 组合使用表达式索引和部分索引——精确覆盖查询场景

📌 业务场景

场景一:用户登录系统

-- 邮箱查找(大小写不敏感)
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、查询计划、索引选择