避开 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
相关推荐
Predestination王瀞潞5 小时前
4.1.1 存储->数据库:MongoDB
数据库·mongodb
JZC_xiaozhong5 小时前
ERP与MES制造数据同步:痛点破解与高效落地实践
大数据·数据库·制造·数据传输·数据孤岛解决方案·数据集成与应用集成·异构数据整合
尽兴-5 小时前
超越缓存:Redis Stack 如何将 Redis 打造成全能实时数据平台
数据库·redis·缓存·redis stack
一个有温度的技术博主5 小时前
Redis系列七:Java客户端Jedis的入门
java·数据库·redis
枕布响丸辣5 小时前
【无标题】
数据库·oracle
Cory.眼5 小时前
MySQL语法错误与修正指南
数据库·sql·oracle
LSL666_5 小时前
Redis值数据类型——sorted set
数据库·redis·缓存·数据类型
supericeice5 小时前
GraphRAG 和 RAG 的区别:企业知识问答什么时候该升级到 GraphRAG
数据库·知识图谱·rag·graphrag
菜菜小狗的学习笔记5 小时前
黑马程序员Redis--基础篇
数据库·redis·缓存
是桃萌萌鸭~5 小时前
Oracle参数db_unique_name详解
数据库·sql·oracle·database