在数据库查询优化中,相关子查询(correlated subquery)一直是一个性能敏感点。这类查询会为外部查询的每一行重新计算,当数据量较大时,可能造成严重的性能瓶颈。本文介绍一种针对 SQLite 的实用优化技巧:利用 OR 运算符的短路求值特性,通过添加不相关标量子查询作为前置检查,避免为大多数用户执行昂贵的相关子查询检查。
相关子查询的性能陷阱
相关子查询是指引用外部查询值的子查询。根据维基百科的定义,这种查询 "可能为外部查询的每一行重新计算,对性能有重大影响"。在 SQLite 中,当我们在 WHERE 子句中使用相关子查询时,数据库引擎需要为外部查询返回的每一行执行一次子查询。
考虑一个实际场景:一个内容推荐系统需要根据用户设置过滤掉某些域名的内容。原始查询可能如下:
SELECT *
FROM items i
WHERE i.lang IN (SELECT lang FROM user_languages WHERE user_id = ?1)
AND i.published BETWEEN ?2 AND ?3
AND NOT EXISTS (
SELECT 1
FROM user_excluded_domains ued
WHERE user_id = ?1
AND ued.domain = i.domain
)
这里的关键问题是:大多数用户可能根本没有设置任何域名排除规则,但数据库仍然需要为每一行内容检查user_excluded_domains表。使用EXPLAIN QUERY PLAN查看执行计划,会发现这是一个 "CORRELATED SCALAR SUBQUERY",意味着它为每一行都要执行。
短路优化原理
SQLite 的 OR 运算符支持短路求值(short-circuit evaluation)。这意味着在表达式A OR B中,如果 A 为真,SQLite 就不会评估 B。我们可以利用这一特性来优化上述查询。
优化思路是:先检查用户是否有任何排除的域名,如果没有,就直接跳过后续的域名检查。具体实现如下:
SELECT *
FROM items i
WHERE i.lang IN (SELECT lang FROM user_languages WHERE user_id = ?1)
AND i.published BETWEEN ?2 AND ?3
AND (
NOT EXISTS (
SELECT 1
FROM user_excluded_domains
WHERE user_id = ?1
)
OR NOT EXISTS (
SELECT 1
FROM user_excluded_domains ued
WHERE user_id = ?1
AND ued.domain = i.domain
)
)
这个优化的核心在于第一个NOT EXISTS子查询不引用items表的任何列,因此它是一个 "不相关标量子查询"(uncorrelated scalar subquery)。SQLite 可以只计算一次这个子查询,然后为所有行重用结果。
执行计划分析
使用优化后的查询,EXPLAIN QUERY PLAN会显示不同的执行计划:
QUERY PLAN
|--SEARCH i USING INDEX idx_items_lang_published (lang=? AND published>? AND published<?)
|--LIST SUBQUERY 1
| `--SEARCH user_languages USING COVERING INDEX sqlite_autoindex_user_languages_1 (user_id=?)
|--SCALAR SUBQUERY 2
| `--SEARCH user_excluded_domains USING COVERING INDEX sqlite_autoindex_user_excluded_domains_1 (user_id=?)
`--CORRELATED SCALAR SUBQUERY 3
`--SEARCH ued USING COVERING INDEX sqlite_autoindex_user_excluded_domains_1 (user_id=? AND domain=?)
注意这里有两个关键变化:
- 第二个子查询标记为
SCALAR SUBQUERY,表示它是一个不相关的标量子查询 - 第三个子查询标记为
CORRELATED SCALAR SUBQUERY,表示它是相关的
当第一个NOT EXISTS返回true(用户没有排除任何域名)时,OR 运算符短路,第三个子查询根本不会执行。
性能基准测试
为了量化优化效果,我们可以进行基准测试。测试环境使用包含 235,975 条记录的数据集,针对两种用户场景:
用户没有排除域名(大多数用户)
| 方法 | 平均时间 (ms) | 开销增加 |
|---|---|---|
| 基线(无过滤) | 72.7 | — |
| 相关子查询(未优化) | 85.2 | +17.1% |
| 短路优化 | 72.7 | +0% |
对于大多数用户(没有设置域名排除规则),优化后的查询与基线性能完全相同,而未优化的相关子查询增加了 17.1% 的开销。
用户有排除域名(少数用户)
| 方法 | 平均时间 (ms) | 开销增加 |
|---|---|---|
| 基线(无过滤) | 76.2 | — |
| 相关子查询(未优化) | 90.5 | +18.7% |
| 短路优化 | 88.5 | +16.1% |
对于有排除域名的用户,短路优化仍然有效,虽然不能完全避免相关子查询的执行,但也没有增加额外开销。
应用场景与限制
这种优化技巧适用于以下场景:
- 特征标志检查:当某个功能只对部分用户启用时,可以先检查用户是否启用了该功能
- 权限验证:在检查具体权限前,先检查用户是否有任何相关权限
- 条件过滤:当过滤条件对大多数记录都不适用时
但需要注意以下限制:
- 不适用于大多数情况:如果大多数用户都需要执行相关子查询检查,这种优化不会带来显著收益
- 查询复杂度增加:优化后的查询结构更复杂,可能影响可读性
- 索引依赖:前置检查需要有效的索引支持,否则可能适得其反
与其他方法的比较
除了NOT EXISTS,还有其他方法可以实现类似功能:
NULL-safe NOT IN
AND (
i.domain IS NULL
OR i.domain NOT IN (
SELECT domain
FROM user_excluded_domains
WHERE user_id = ?1
)
)
LEFT JOIN
SELECT *
FROM items i
LEFT JOIN user_excluded_domains ued on ued.user_id = ?1 AND ued.domain = i.domain
WHERE i.lang IN (SELECT lang FROM user_languages WHERE user_id = ?1)
AND i.published BETWEEN ?2 AND ?3
AND ued.domain IS NULL
基准测试显示,对于没有排除域名的用户:
NOT EXISTS+ 短路优化:0% 开销NULL-safe NOT IN+ 短路优化:2.8% 开销LEFT JOIN+ 短路优化:16.0% 开销
NOT EXISTS配合短路优化是最有效的方案。
实际应用示例
这种优化技巧在 Scour(一个内容推荐系统)中得到了实际应用。除了域名排除功能,还应用于付费内容过滤:
AND (
(
SELECT COALESCE(hide_paywalled_content, 0) = 0
FROM users
WHERE user_id = ?1
)
OR COALESCE(i.is_paywalled, 0) = 0
OR i.domain IN (
SELECT domain
FROM user_paywall_allowed_domains
WHERE user_id = ?1
)
)
这里首先检查用户是否启用了隐藏付费内容的功能,如果没有启用,就直接跳过后续的付费内容检查。
工程实践建议
- 使用 EXPLAIN QUERY PLAN:在优化前后都使用
EXPLAIN QUERY PLAN验证执行计划变化 - 建立合适的索引:确保前置检查和相关子查询都能有效利用索引
- 考虑数据分布:分析用户行为数据,确定优化是否适用于大多数情况
- 渐进式部署:可以先在小部分用户中测试优化效果,再逐步推广
- 监控性能指标:建立查询性能监控,及时发现性能回归
总结
SQLite 相关子查询的短路优化是一种简单而有效的性能优化技巧。通过添加一个不相关标量子查询作为前置检查,利用 OR 运算符的短路求值特性,可以避免为大多数用户执行昂贵的相关子查询检查。在实际应用中,这种优化可以将查询性能开销从 17.1% 降低到 0%,对于大规模应用来说,这是显著的性能提升。
关键要点:
- 相关子查询会为每一行重新计算,性能开销大
- OR 运算符支持短路求值,可以利用这一特性优化查询
- 添加不相关标量子查询作为前置检查,避免不必要的计算
- 使用
EXPLAIN QUERY PLAN验证优化效果 - 这种优化特别适用于特征标志、权限检查等场景
通过这种优化,我们可以在不增加应用复杂度的前提下,显著提升数据库查询性能,特别是在用户行为分布不均匀的场景中。
资料来源
- Evan Schwartz 的博客文章《Short-Circuiting Correlated Subqueries in SQLite》,详细介绍了这种优化技巧的实现和基准测试结果
- SQLite 官方文档中的
EXPLAIN QUERY PLAN部分,提供了查询计划分析的基础知识