避开 case_sensitive_like 坑:SQLite 范围查询替代 LIKE 的性能优化之路

前言

先说结论!!!

  • sqlite 的参数case_sensitive_like为OFF时,Like的前缀模糊会走全表查询
  • sqlite的索引是区分大小写,具备有序性

一、问题发现

1.1 用户报告

搜索 URI 时,发现搜索时间极长:

复制代码
搜索: /api/local/boce
结果: 仅 1 条记录
耗时: 324 秒(5 分多钟)

1.2 性能监控日志部署

为了定位具体的性能瓶颈,添加了性能监控日志:

修改位置 : get_field_ids 方法

代码变更:

python 复制代码
# 在查询开始前记录时间
_perf_start = time.time()

# ... 执行查询 ...

# 查询完成后记录性能日志
_perf_elapsed = time.time() - _perf_start
_status = "✓" if _perf_elapsed < 0.5 else "⚠️" if _perf_elapsed < 2.0 else "❌"
public(
    "[SQL性能]{} get_field_ids[{}] 耗时: {:.3f}秒 | type={} | value={} | results={}".format(
        _status, Table, _perf_elapsed, query_type, normalized_value[:50],
        len(field_value) if field_value else 0
    ),
    color="success" if _perf_elapsed < 0.5 else "warning" if _perf_elapsed < 2.0 else "error"
)

1.3 性能日志分析

通过日志输出,精确定位了性能瓶颈:

log 复制代码
[SQL性能]❌ get_field_ids[uri_list] 耗时: 324.112秒 | type=uri_prefix | value=/api/local/boce | results=1
[SQL性能]❌ get_url_id 耗时: 324.113秒 | url=/api/local/boce | results=1
[SQL性能]✓ count_query 耗时: 0.056秒 | conditions=3 | total=19172
[SQL性能]✓ join_query 耗时: 0.123秒 | limit=20 | offset=0 | results=19

关键发现 : 100% 的延迟(324.112秒)都在 get_field_ids[uri_list] 查询中,其他所有操作加起来仅 0.179 秒。


二、问题定位

2.1 数据库结构分析

表结构:

sql 复制代码
CREATE TABLE `test` (
    `uri_id` INTEGER PRIMARY KEY AUTOINCREMENT,
    `uri` TEXT DEFAULT "" NOT NULL,
    `time` INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX `idx_uri` ON `test` (`uri`);

数据规模:

  • 记录数: 2,807,293(约 280 万条)
  • 数据库文件大小: 237 MB
  • 索引大小: 约 156 MB(推算)

2.2 查询语句分析

原始代码:

python 复制代码
elif normalized_value.startswith('/'):
    like_value = "{}%".format(normalized_value)
    field_value = db_obj.table(Table).where('{} like ?'.format(Key), (like_value,)).field(Field).select()

生成的 SQL:

sql 复制代码
SELECT uri_id FROM uri_list WHERE uri LIKE '/api/local/boce%'

2.3 查询计划检查

使用 EXPLAIN QUERY PLAN 分析查询执行计划:

bash 复制代码
$ sqlite3 uri_list.db "EXPLAIN QUERY PLAN SELECT uri_id FROM uri_list WHERE uri LIKE '/api/local/boce%'"
QUERY PLAN
`--SCAN uri_list USING COVERING INDEX idx_uri

关键发现 : 使用 SCAN(全索引扫描),而不是 SEARCH(索引查找)!

2.4 对比测试

同时测试其他查询方式:

查询类型 SQL 查询计划 耗时
精确匹配 WHERE uri = '/api/local/boce' SEARCH <0.01秒
LIKE 查询 WHERE uri LIKE '/api/local/boce%' SCAN 324秒
范围查询 WHERE uri >= '/api/local/boce' AND uri < '/api/local/boce\uffff' SEARCH <0.01秒

三、根本原因分析

3.1 初步假设:前缀索引问题

疑问 : 为什么前缀匹配(LIKE '/api/local/boce%')不使用索引?

假设: 可能需要创建专门的前缀索引

sql 复制代码
CREATE INDEX idx_uri_prefix ON uri_list(uri COLLATE NOCASE);

验证: 通过测试发现,即使添加前缀索引,LIKE 查询仍然使用 SCAN。

结论: 前缀索引方案无效。

3.2 深入分析:SQLite LIKE 优化限制

SQLite LIKE 优化的条件

根据 SQLite 官方文档,LIKE 查询使用索引需要满足:

  1. ✅ 左侧不能有通配符:LIKE 'abc%'(满足)
  2. ✅ 只能使用 ASCII 字符(满足)
  3. ✅ 不能使用 ESCAPE 子句(满足)
  4. 右侧必须是字面量,不能是参数(不满足!)
  5. 大小写敏感设置必须匹配索引排序

3.3 核心发现:case_sensitive_like = OFF

测试验证
python 复制代码
# 测试 case_sensitive_like 设置的影响
for setting in ['OFF', 'ON']:
    cursor.execute(f'PRAGMA case_sensitive_like={setting}')

    cursor.execute("EXPLAIN QUERY PLAN SELECT * FROM t WHERE uri LIKE '/api/%'")
    plan = cursor.fetchone()

    print(f'case_sensitive_like={setting}: {plan}')

测试结果:

复制代码
case_sensitive_like = OFF:
┌─────────────────────────────────────────────────────────────┐
│ LIKE '/api%'  →  SCAN (全表扫描)                             │
│ 匹配结果: ['/api/local/boce', '/API/LOCAL/BOCE', '/api/other']│
│              ↑ 不区分大小写,匹配了所有大小写变体              │
└─────────────────────────────────────────────────────────────┘

case_sensitive_like = ON:
┌─────────────────────────────────────────────────────────────┐
│ LIKE '/api%'  →  SEARCH (索引查找) ✓                         │
│ 匹配结果: ['/api/local/boce', '/api/other']                  │
│              ↑ 区分大小写,只匹配小写                          │
└─────────────────────────────────────────────────────────────┘
根本原因总结
复制代码
┌────────────────────────────────────────────────────────────────────┐
│                    问题根本原因                                    │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  1. SQLite 默认: case_sensitive_like = OFF                        │
│     → LIKE 查询不区分大小写                                        │
│                                                                    │
│  2. 索引排序: BINARY(区分大小写)                                 │
│     → idx_uri 按 A < a < B < b ... 排序                           │
│                                                                    │
│  3. 冲突: 不区分大小写的查询无法使用区分大小写的索引               │
│     → LIKE '/api/%' 需要匹配 '/API/...', '/Api/...', '/api/...'   │
│     → 但 BINARY 索引中这些值分散在不同位置                         │
│     → SQLite 无法直接跳到所有匹配区域                              │
│                                                                    │
│  4. 结果: SCAN(全表扫描)→ 324 秒                                │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

3.4 B-tree 索引结构分析

索引中的实际数据顺序
复制代码
B-tree 索引按 BINARY 排序(区分大小写):
┌─────────────────────────────────────────────────────────┐
│  索引条目 (按字典序)                                      │
├─────────────────────────────────────────────────────────┤
│  /                                                     │
│  /                                                     │
│  /API/LOCAL/BOCE      ← 大写,与小写分开存储             │
│  /API/other                                            │
│  /Api/Local/Boce      ← 混合大小写,又是另一个位置       │
│  /api/index.php                                       │
│  /api/local/boce      ← 小写目标位置                    │
│  /api/local/boce/test                                   │
│  /api/other                                            │
│  ... (280万条记录继续)                                 │
└─────────────────────────────────────────────────────────┘

SCAN (全表扫描):

复制代码
LIKE '/api/local/boce%' (case_sensitive_like=OFF)

需要: 匹配所有大小写变体
     - /api/local/boce
     - /API/LOCAL/BOCE
     - /Api/Local/Boce
     ...

问题: 这些值分散在索引的不同位置
     → 无法直接跳转
     → 必须扫描整个索引
     → 280万条 × 0.73 MB/s ≈ 324 秒

SEARCH (索引查找):

复制代码
uri >= '/api/local/boce' AND uri < '/api/local/boce\uffff'

利用: B-tree 的有序性
     1. 二分查找定位起点: O(log n) ≈ 22 次
     2. 顺序读取匹配记录: O(k),k=结果数
     → 22 + 1 ≈ 0.01 秒

四、解决方案

原理:

  • 将前缀匹配 LIKE '/prefix%' 转换为范围查询
  • uri >= '/prefix' AND uri < '/prefix\uffff'
  • \uffff 是 Unicode 最大字符 (U+FFFF)

优势:

  1. ✅ 不依赖 PRAGMA 设置(更可靠)
  2. ✅ 支持参数化查询(防止 SQL 注入)
  3. ✅ 性能始终优秀(无论 case_sensitive_like)
  4. ✅ 代码简洁,无需额外配置

注意事项:

  • URI 路径通常是区分大小写的(符合规范)
  • 极少数包含 \uffff 字符的 URI 不会被匹配(可接受的边缘情况)

修改后:

python 复制代码
elif normalized_value.startswith('/'):
    field_value = db_obj.table(Table).where('{} >= ? AND {} < ?'.format(Key, Key), (normalized_value, normalized_value + '\uffff')).field(Field).select()

五、优化结果

5.1 性能对比

搜索内容 优化前 优化后 提升倍数
/api/local/boce 324.112秒 0.01X秒 ~32,000倍
/ 50秒 0.1秒 ~500倍
/api/index.php 5秒 0.01秒 ~500倍

5.3 查询计划对比

优化前:

sql 复制代码
EXPLAIN QUERY PLAN SELECT uri_id FROM uri_list WHERE uri LIKE '/api/local/boce%';
-- SCAN uri_list USING COVERING INDEX idx_uri

优化后:

sql 复制代码
EXPLAIN QUERY PLAN SELECT uri_id FROM uri_list
WHERE uri >= '/api/local/boce' AND uri < '/api/local/boce\uffff';
-- SEARCH uri_list USING COVERING INDEX idx_uri (uri>? AND uri<?)

六、技术总结

6.1 关键知识点

1. SQLite 查询计划
类型 说明 性能
SCAN 全表/全索引扫描 O(n),慢
SEARCH 索引二分查找 O(log n + k),快
2. B-tree 索引
  • B-tree 是有序的,支持范围查询
  • 二分查找定位:O(log n)
  • 顺序读取匹配记录:O(k)
3. LIKE 查询优化限制
python 复制代码
# SQLite LIKE 优化生效条件
WHERE col LIKE 'abc%'     # ✅ 前缀固定,字面量
WHERE col LIKE ?          # ❌ 使用参数
WHERE col LIKE 'abc%' COLLATE BINARY  # ✅ 指定 BINARY 排序
4. case_sensitive_like
设置 LIKE 行为 索引使用
OFF(默认) 不区分大小写 SCAN(除非索引匹配)
ON 区分大小写 SEARCH
相关推荐
小陈工21 分钟前
Python Web开发入门(十七):Vue.js与Python后端集成——让前后端真正“握手言和“
开发语言·前端·javascript·数据库·vue.js·人工智能·python
科技小花5 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸5 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain5 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希5 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神6 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员6 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java6 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿6 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴6 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存