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

SQLite 完全指南 / 08 - 查询优化

08 - 查询优化:EXPLAIN、查询计划与索引选择

8.1 EXPLAIN 与 EXPLAIN QUERY PLAN

SQLite 提供两种 EXPLAIN 来分析查询:

命令说明输出
EXPLAIN显示完整字节码详细的虚拟机指令
EXPLAIN QUERY PLAN显示查询计划高层次的执行策略
-- 完整字节码(调试用)
EXPLAIN SELECT * FROM users WHERE id = 1;

-- 查询计划(优化用,推荐)
EXPLAIN QUERY PLAN SELECT * FROM users WHERE id = 1;

8.2 查询计划解读

8.2.1 查询计划关键字

关键字说明好/坏
SEARCH TABLE ... USING INDEX使用索引扫描✅ 好
SEARCH TABLE ... USING COVERING INDEX覆盖索引扫描✅✅ 最好
SEARCH TABLE ... USING INTEGER PRIMARY KEY主键查找✅✅ 最好
SCAN TABLE全表扫描⚠️ 可能需要优化
USE TEMP B-TREE FOR ORDER BY使用临时 B-Tree 排序⚠️ 可能需要优化
USE TEMP B-TREE FOR GROUP BY使用临时 B-Tree 分组⚠️ 可能需要优化
COMPOSITE INDEX使用复合索引✅ 好
USING ROWID LOOKUP通过 rowid 查找✅ 好

8.2.2 实际分析示例

CREATE TABLE orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    status TEXT NOT NULL,
    amount REAL NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status, created_at);

-- 示例 1:使用索引
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 42;
-- SEARCH TABLE orders USING INDEX idx_orders_user (user_id=?)

-- 示例 2:全表扫描
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE amount > 100;
-- SCAN TABLE orders(没有 amount 索引)

-- 示例 3:复合索引
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE status = 'completed' AND created_at > '2026-01-01';
-- SEARCH TABLE orders USING INDEX idx_orders_status (status=? AND created_at>?)

-- 示例 4:覆盖索引
CREATE INDEX idx_orders_cover ON orders(user_id, status, amount);
EXPLAIN QUERY PLAN
SELECT user_id, status, amount FROM orders WHERE user_id = 42;
-- SEARCH TABLE orders USING COVERING INDEX idx_orders_cover (user_id=?)

8.3 常见查询优化技巧

8.3.1 避免 SELECT *

-- ❌ 不好:返回所有列
SELECT * FROM orders WHERE user_id = 42;

-- ✅ 好:只返回需要的列(可能使用覆盖索引)
SELECT id, status, amount FROM orders WHERE user_id = 42;

8.3.2 避免在索引列上使用函数

-- ❌ 不好:索引列上使用函数(索引失效)
SELECT * FROM users WHERE lower(email) = 'test@example.com';
-- 但如果创建了表达式索引,就可以使用
CREATE INDEX idx_users_email_lower ON users(lower(email));

-- ❌ 不好:对索引列进行计算
SELECT * FROM orders WHERE amount * 1.1 > 1000;

-- ✅ 好:将计算移到右边
SELECT * FROM orders WHERE amount > 1000 / 1.1;

-- ❌ 不好
SELECT * FROM orders WHERE strftime('%Y', created_at) = '2026';

-- ✅ 好:使用范围查询
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

8.3.3 OR 优化

-- ⚠️ OR 可能导致索引失效
SELECT * FROM users WHERE email = 'a@b.com' OR name = '张三';

-- ✅ 改用 UNION ALL
SELECT * FROM users WHERE email = 'a@b.com'
UNION ALL
SELECT * FROM users WHERE name = '张三' AND email != 'a@b.com';

8.3.4 IN 子查询优化

-- ⚠️ 可能导致相关子查询
SELECT * FROM users WHERE id IN (
    SELECT user_id FROM orders WHERE status = 'completed'
);

-- ✅ 改用 EXISTS(通常更快)
SELECT * FROM users u WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'completed'
);

-- ✅ 或用 JOIN
SELECT DISTINCT u.* FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';

8.3.5 LIMIT 优化

-- ✅ 没有 ORDER BY 时 LIMIT 非常快
SELECT * FROM users LIMIT 10;

-- ⚠️ 有 ORDER BY 时需要排序
SELECT * FROM users ORDER BY name LIMIT 10;
-- 如果有 ORDER BY 列的索引就很快

-- ❌ 大 OFFSET 值性能差
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 100000;
-- SQLite 需要扫描前 100010 行然后丢弃前 100000 行

-- ✅ 使用游标分页(更高效)
SELECT * FROM users WHERE id > 100000 ORDER BY id LIMIT 10;

8.3.6 LIKE 优化

-- ❌ 前缀通配符无法使用索引
SELECT * FROM users WHERE name LIKE '%张%';

-- ✅ 后缀通配符可以使用索引
SELECT * FROM users WHERE name LIKE '张%';

-- ✅ 使用 FTS5 全文搜索替代前缀通配符
-- 参见第 11 章

8.4 JOIN 优化

8.4.1 JOIN 顺序

-- SQLite 优化器会自动选择 JOIN 顺序
-- 但确保连接列有索引

-- ✅ 确保连接列有索引
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_items_order ON order_items(order_id);

-- ✅ 小表驱动大表(SQLite 通常自动优化)
SELECT u.name, o.id, p.name
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
JOIN users u ON o.user_id = u.id
WHERE u.id = 1;

8.4.2 使用 EXPLAIN 验证 JOIN

EXPLAIN QUERY PLAN
SELECT u.name, o.id
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 1;

-- 预期输出:
-- SEARCH TABLE users AS u USING INTEGER PRIMARY KEY (rowid=?)
-- SEARCH TABLE orders AS o USING INDEX idx_orders_user (user_id=?)

8.5 子查询优化

8.5.1 相关子查询 vs JOIN

-- 相关子查询(对外部表的每一行执行一次子查询)
SELECT name, (SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count
FROM users;

-- ✅ 使用 LEFT JOIN + GROUP BY 通常更快
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;

8.5.2 EXISTS vs IN

-- 当子查询结果集较大时,EXISTS 通常更快
-- 当子查询结果集较小时,IN 通常更快

-- EXISTS:找到第一个匹配就停止
SELECT * FROM users WHERE EXISTS (
    SELECT 1 FROM orders WHERE user_id = users.id AND status = 'completed'
);

-- IN:需要计算完整的子查询结果集
SELECT * FROM users WHERE id IN (
    SELECT user_id FROM orders WHERE status = 'completed'
);

8.6 临时表和排序优化

8.6.1 避免临时 B-Tree

-- ⚠️ 可能需要临时 B-Tree
SELECT * FROM users ORDER BY name, age;

-- ✅ 创建复合索引避免排序
CREATE INDEX idx_users_name_age ON users(name, age);

EXPLAIN QUERY PLAN SELECT * FROM users ORDER BY name, age;
-- 应该显示使用索引而不是临时 B-Tree

8.6.2 GROUP BY 优化

-- ⚠️ GROUP BY 可能需要临时表
SELECT department, COUNT(*) FROM employees GROUP BY department;

-- ✅ 创建索引优化 GROUP BY
CREATE INDEX idx_emp_dept ON employees(department);

8.7 索引选择器(Index Selection)

SQLite 的查询优化器通过以下步骤选择索引:

  1. 收集可用索引:列出表的所有索引
  2. 估算成本:对每个索引估算使用成本
  3. 选择最优:选择成本最低的方案

8.7.1 影响索引选择的因素

因素说明
WHERE 条件等值条件比范围条件更优
索引选择性不同值越多,索引越有效
统计信息ANALYZE 收集的信息
ORDER BY如果索引可以避免排序
LIMIT小 LIMIT 值倾向于使用索引
覆盖索引如果索引包含所有需要的列

8.7.2 手动提示索引

SQLite 不支持 USE INDEX 语法,但可以通过以下方式影响索引选择:

-- 方式 1:重新排列 WHERE 条件顺序
-- 方式 2:使用部分索引限制选择
-- 方式 3:使用 CROSS JOIN 强制表的连接顺序
-- 方式 4:调整复合索引的列顺序

-- 查询优化器提示(SQLite 不直接支持,但可以通过重构 SQL 实现)
-- 如果优化器选择了错误的索引,考虑:
-- 1. 运行 ANALYZE 更新统计信息
-- 2. 重新设计索引
-- 3. 重构查询

8.8 性能分析工具

8.8.1 sqlite3_analyzer

# 分析数据库文件结构
sqlite3_analyzer mydb.db

# 输出包含:
# - 每张表的页面数
# - 每个索引的页面数
# - 空间利用率
# - B-Tree 深度

8.8.2 sqldiff

# 比较两个数据库的差异
sqldiff db1.db db2.db

# 输出 SQL 语句将 db1 转换为 db2

8.8.3 CLI 计时

-- 开启计时
.timer on

-- 执行查询
SELECT COUNT(*) FROM logs WHERE level = 'ERROR';
-- Run Time: real 0.012 user 0.011000 sys 0.001000

-- 比较有无索引的性能差异
.timer on
SELECT * FROM logs WHERE level = 'ERROR' LIMIT 100;
-- 无索引:~50ms
-- 有索引:~0.5ms

8.9 优化案例集

案例 1:慢查询分析

-- 问题查询
SELECT * FROM orders
WHERE user_id = 42 AND status = 'completed'
ORDER BY created_at DESC
LIMIT 10;

-- 步骤 1:查看查询计划
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE user_id = 42 AND status = 'completed'
ORDER BY created_at DESC LIMIT 10;
-- SCAN TABLE orders(全表扫描)

-- 步骤 2:创建合适的索引
CREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at);

-- 步骤 3:验证优化效果
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE user_id = 42 AND status = 'completed'
ORDER BY created_at DESC LIMIT 10;
-- SEARCH TABLE orders USING INDEX idx_orders_user_status_date (user_id=? AND status=?)

案例 2:统计查询优化

-- 慢:每次 COUNT 都全表扫描
SELECT COUNT(*) FROM orders WHERE status = 'pending';

-- 快:使用索引
CREATE INDEX idx_orders_status ON orders(status);
SELECT COUNT(*) FROM orders WHERE status = 'pending';

-- 更快:维护计数表
CREATE TABLE order_counts (
    status TEXT PRIMARY KEY,
    cnt INTEGER DEFAULT 0
);

-- 使用触发器维护计数
CREATE TRIGGER order_insert AFTER INSERT ON orders
BEGIN
    INSERT INTO order_counts (status, cnt) VALUES (NEW.status, 1)
    ON CONFLICT(status) DO UPDATE SET cnt = cnt + 1;
END;

案例 3:分页查询优化

-- ❌ 慢:大偏移量
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 100000;

-- ✅ 快:游标分页
SELECT * FROM products WHERE id > 100000 ORDER BY id LIMIT 20;

-- ✅ 延迟关联(如果需要按非主键排序)
SELECT p.* FROM products p
INNER JOIN (
    SELECT id FROM products ORDER BY name LIMIT 20 OFFSET 100000
) t ON p.id = t.id;

⚠️ 注意事项

  1. EXPLAIN QUERY PLAN 是最重要的优化工具——先看查询计划再优化
  2. SCAN TABLE 不一定是问题——小表的全表扫描可能比索引更快
  3. 索引不是越多越好——每个索引都有写入成本
  4. ANALYZE 需要定期执行——数据分布变化后统计信息可能过时
  5. 不要盲目优化——先测量,再优化,再测量
  6. SQLite 不支持查询提示(hint)——需要通过索引设计和 SQL 重构来影响优化器

💡 技巧

  1. timer on 是最简单的性能测量工具
  2. 覆盖索引是最有效的优化手段——完全避免回表
  3. 游标分页比 OFFSET 分页高效得多
  4. EXISTS 通常比 IN 快——特别是子查询结果集大时
  5. 避免在 WHERE 中对索引列使用函数——除非创建了表达式索引

📌 业务场景

场景一:API 分页接口

-- 创建覆盖索引
CREATE INDEX idx_products_cover ON products(category, price, id, name);

-- 高效分页
SELECT id, name, price FROM products
WHERE category = 'electronics' AND price > 100
ORDER BY price ASC
LIMIT 20;

场景二:报表查询

-- 月度订单统计
CREATE INDEX idx_orders_month ON orders(
    strftime('%Y-%m', created_at), status
);

SELECT strftime('%Y-%m', created_at) AS month, status, COUNT(*), SUM(amount)
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY month, status;

🔗 扩展阅读


📖 下一章09 - 事务 —— BEGIN/COMMIT/ROLLBACK、WAL 模式、并发