grep vs sqlite 谁更适合微信聊天记录?
Posted | stdout
一个爆火的讨论
云风 @cloudwu 2026-06-29
微信的开发人员根本就不懂该怎么储存数据。这种聊天软件,文本和媒体文件分开存,文本根本就不应该保存在什么数据库(sqlite)里, 一个对话一个文本文件追加就可以了。需要搜索的时候 grep 一下性能完全符合需求。一个对话能有多少文本?一秒一个字 24 小时不间断,一年也就 30M 个字。
网上的争论都是猜测,我呢,决定让 opus 跑一局。
首先让AI去搜微信聊录表结构
微信(Android)聊天记录存储在加密 SQLite 数据库
EnMicroMsg.db中,使用 SQLCipher(AES-256-CBC,PBKDF2 256000 轮派生密钥)。核心message表:
CREATE TABLE message (
msgId INTEGER PRIMARY KEY, -- 本地自增 ID
msgSvrId INTEGER, -- 服务器消息 ID
type INTEGER, -- 1=文字, 3=图片, 34=语音, 43=视频
isSend INTEGER, -- 0=接收, 1=发送
createTime INTEGER, -- Unix 时间戳
talker TEXT, -- wxid 或群 chatroom ID
content TEXT, -- 消息正文
imgPath TEXT -- 附件路径
);
测试设计
- 数据量:50 万条模拟消息(模拟中度用户 ~2 年)
- 搜索关键词:
微信支付、服务器、数据库、会议、周末 - 环境:macOS Apple Silicon, Python 3.14, ripgrep 15.1, DuckDB 1.5.4, Polars 1.42
- 每项测试 3 轮取最小值
参赛选手
| 分类 | 方案 | 思路 |
|---|---|---|
| 传统文本 | grep (BSD) | 最朴素的逐字节匹配 |
| SIMD文本 | ripgrep | AVX2/NEON 并行 + 多线程 |
| 零拷贝 | mmap 直接搜索 | OS page cache + Python bytes.find |
| 压缩文本 | zstd 流式解压搜索 | 省空间,边解压边搜 |
| 索引 | 倒排索引 (2-gram) | 搜索引擎思路,内存索引 |
| 索引 | Bloom Filter 分块 | 概率型预过滤 |
| RDBMS | SQLite LIKE | 微信的实际方案(去掉加密) |
| RDBMS | SQLite mmap 模式 | mmap I/O 加速 |
| RDBMS FTS | SQLite FTS5 (trigram) | 全文搜索引擎 |
| 列式DB | DuckDB contains() | OLAP 列式扫描 |
| 列式DB | DuckDB FTS | DuckDB 的全文搜索扩展 |
| 列式文件 | Parquet(zstd) + DuckDB | 列式文件直接查询 |
| DataFrame | Polars lazy scan | Rust实现的极速 DataFrame |
| DataFrame | Polars in-memory | 全量载入内存 |
| 并行文本 | ripgrep 多文件并行 | 分块文件 + rg 多线程 |
测试结果
关键词搜索延迟(ms, 3轮最小值)
| # | 方案 | 微信支付 | 服务器 | 数据库 | 会议 | 周末 | 平均 |
|---|---|---|---|---|---|---|---|
| 1 | SQLite FTS5 (trigram) | 0.65 | 0.46 | 0.37 | ❌² | ❌² | 0.31¹ |
| 2 | Polars lazy scan | 3.51 | 2.56 | 2.43 | 2.49 | 2.47 | 2.69 |
| 3 | 倒排索引 (2-gram) | 2.71 | 3.26 | 2.78 | 3.24 | 2.37 | 2.87 |
| 4 | Polars in-memory | 2.67 | 3.61 | 2.98 | 3.21 | 3.21 | 3.13 |
| 5 | DuckDB contains() | 3.72 | 3.23 | 3.18 | 3.91 | 4.41 | 3.69 |
| 6 | Parquet + DuckDB | 4.63 | 4.37 | 4.55 | 4.88 | 4.84 | 4.65 |
| 7 | ripgrep 多文件并行 | 10.65 | 10.26 | 9.48 | 10.32 | 9.05 | 9.95 |
| 8 | DuckDB FTS (BM25) | 12.53 | 11.77 | 12.85 | 11.82 | 12.78 | 12.35³ |
| 9 | ripgrep (SIMD, 单文件) | 13.24 | 13.77 | 13.40 | 14.14 | 13.10 | 13.53 |
| 10 | mmap 直接搜索 | 17.00 | 22.62 | 22.86 | 31.36 | 30.52 | 24.87 |
| 11 | Bloom Filter + 扫描 | 25.34 | 25.25 | 25.06 | 26.68 | 27.01 | 25.87 |
| 12 | SQLite mmap LIKE | 37.45 | 37.45 | 37.92 | 37.83 | 37.81 | 37.69 |
| 13 | SQLite LIKE | 43.13 | 41.40 | 41.50 | 41.51 | 41.46 | 41.80 |
| 14 | grep (BSD) | 139.10 | 136.86 | 141.42 | 122.54 | 124.16 | 132.82 |
| 15 | zstd 流式解压搜索 | 161.32 | 164.22 | 164.56 | 170.23 | 170.73 | 166.21 |
¹ FTS5 只对 ≥3字符 的关键词有效,取3个有效关键词平均
² trigram tokenizer 无法匹配 2 字符的中文词
³ DuckDB FTS 默认 tokenizer 不支持中文,返回 0 结果(延迟仍可参考)
视觉化排名
1. █ 0.31ms SQLite FTS5
2. █ 2.69ms Polars lazy
3. █ 2.87ms 倒排索引
4. █ 3.13ms Polars in-mem
5. █ 3.69ms DuckDB contains
6. ██ 4.65ms Parquet+DuckDB
7. ████ 9.95ms rg多文件并行
8. ██████ 12.35ms DuckDB FTS
9. ██████ 13.53ms ripgrep (SIMD
10. ████████████ 24.87ms mmap 直接搜索
11. ████████████ 25.87ms Bloom+扫描
12. ██████████████████ 37.69ms SQLite mmap LIKE
13. ████████████████████ 41.80ms SQLite LIKE
14. ████████████████████████████████████████████████████████████ 132.82ms grep (BSD
15. ████████████████████████████████████████████████████████████ 166.21ms zstd流式解压
复合条件查询(指定用户 + 时间范围 + 关键词"会议")
| 方案 | 延迟 (ms) | 倍率(vs grep) |
|---|---|---|
| SQLite indexed | 2.80 | 76x |
| DuckDB | 4.57 | 47x |
| Polars in-memory | 7.02 | 30x |
| ripgrep pipe | 20.47 | 10x |
| grep pipe | 213.90 | 1x |
存储大小
| 格式 | 大小 | vs TSV | 说明 |
|---|---|---|---|
| Parquet (zstd) | 8.3 MB | 0.18x | 列式 + 字典编码 + 压缩 |
| zstd 压缩 TSV | 12.7 MB | 0.27x | 纯压缩 |
| DuckDB + FTS | 26.0 MB | 0.55x | 含全文索引 |
| TSV 纯文本 | 47.0 MB | 1.00x | 基线 |
| SQLite | 69.2 MB | 1.47x | B-tree 开销 |
| SQLite + FTS5 | 116.8 MB | 2.49x | trigram 索引翻倍 |
各方案深度分析
Tier 1: 亚毫秒级(< 1ms)
SQLite FTS5 (trigram)
- 原理:对 content 字段的每个 3 字符子串建倒排索引
- 优点:查询极快(0.3-0.6ms),无需额外依赖
- 缺点:索引体积翻倍(+68MB);trigram 无法匹配 ≤2 字符的关键词
- 适用:搜索词通常 ≥3 字符的场景
Tier 2: 低毫秒级(2-5ms)
Polars (lazy/in-memory)
- 原理:Rust 实现的 DataFrame 引擎,列式内存布局 + SIMD 字符串匹配
- 优点:Parquet 文件仅 8.3MB(最小!),查询 2-3ms,复合查询也快(7ms)
- 缺点:需要加载到内存;Python 库依赖
- 杀手锏:8MB 的 Parquet 文件 + 3ms 搜索延迟,这是存储效率和速度的最佳平衡点
倒排索引 (2-gram, 内存)
- 原理:搜索引擎最经典的思路,对所有 2-gram 建 posting list
- 优点:构建仅 0.78s,查询 2.9ms,支持任意长度关键词
- 缺点:纯内存(需要序列化/加载),索引构建需要全量遍历
- 适用:append-only 数据可以增量更新索引
DuckDB contains()
- 原理:列式存储,content 列连续存放,CPU cache 友好 + SIMD 扫描
- 优点:无需专门索引即可 3.7ms;复合查询也仅 4.6ms;文件仅 26MB
- 缺点:需要 DuckDB 运行时
- 杀手锏:不建任何索引,纯靠列式布局就比 SQLite LIKE 快 11 倍
Parquet 文件 + DuckDB 零拷贝查询
- 原理:Parquet 本身就是列式格式,DuckDB 可以直接查询不需导入
- 优点:文件仅 8.3MB,不需要数据库进程,查询 4.6ms
- 缺点:每次查询需要启动 DuckDB 连接
- 杀手锏:一个 8MB 的文件就是完整的"数据库",任何语言都能读
Tier 3: 10ms 级
ripgrep 多文件并行
- 原理:把消息分块成多个文件,ripgrep 的 work-stealing 线程池并行搜索
- 优点:比单文件 ripgrep 快 ~35%(10ms vs 13.5ms)
- 缺点:文件管理复杂
- 适用:数据天然按时间分文件存储的场景
ripgrep (SIMD, 单文件)
- 原理:AVX2/NEON 每周期处理 16-32 字节,多线程(对单文件仍用单线程)
- 优点:零配置,即装即用
- 缺点:对单文件只能单线程
Tier 4: 失败/不推荐的"邪路"
Bloom Filter 分块预过滤
- 问题:中文常用 2-gram 只有 ~690 种,每个块都包含所有 n-gram,过滤率为 0
- 结论:对高频 n-gram 的数据集完全无效,白费构建时间
zstd 流式解压搜索
- 问题:Python 解压+搜索 166ms,比不压缩的 grep 还慢
- 结论:CPU 密集的解压抵消了 I/O 节省。如果数据在 SSD 上,不如直接读原文
- 可能有用的场景:数据在网络存储/HDD 上,I/O 是瓶颈时
DuckDB FTS (BM25)
- 问题:默认 tokenizer(类似 ICU word boundary)不支持中文
- 结论:需要自定义 tokenizer 或等 DuckDB 支持 trigram/CJK
mmap 直接搜索
- 表现:24.87ms,比 ripgrep 慢 2 倍
- 原因:Python 的 mmap.find() 是朴素搜索,没有 SIMD 优化
- 如果用 C/Rust 实现的 mmap + SIMD,预期接近 ripgrep 水平
推荐方案排名
如果从零设计微信聊天存储
| 优先级 | 方案 | 搜索延迟 | 存储 | 理由 |
|---|---|---|---|---|
| 🥇 | Parquet + Polars/DuckDB | 3-5ms | 8 MB | 存储最小、搜索极快、格式通用、append 友好 |
| 🥈 | DuckDB 数据库 | 3.7ms | 26 MB | 单文件数据库、列式、SQL 查询、FTS 索引可选 |
| 🥉 | SQLite + FTS5 | 0.3ms | 117 MB | 最快搜索(有索引)、但索引太大 |
| 4 | ripgrep + 分块 TSV | 10ms | 47 MB | 最简单、无依赖、人类可读 |
如果要"一行命令搜聊天记录"
# 方案 A#: ripgrep 搜纯文本 (~10ms)
rg "微信支付" messages.tsv
## 方案 B: DuckDB 直接查 Parquet (~5ms)
duckdb -c "SELECT * FROM read_parquet('messages.parquet') WHERE contains(content, '微信支付')"
## 方案 C: DuckDB 复合查询 (~5ms)
duckdb -c "SELECT * FROM read_parquet('messages.parquet')
WHERE talker='wxid_xxx' AND createTime > 1672531200
AND contains(content, '会议')"
最终结论
"用 grep 代替 SQLite" — 部分正确,但格局太小
- ripgrep 比 SQLite LIKE 快 3 倍(13ms vs 42ms)— "grep 派"的论据成立
- 但 DuckDB/Polars 比 ripgrep 又快 4 倍(3ms vs 13ms)— 列式才是正道
- Parquet 比纯文本还小 5.6 倍(8MB vs 47MB)— 省空间还更快
- 复合查询(按人+时间+关键词)SQLite 仍然最强(2.8ms)
真正的启示
聊天记录是 append-only 数据 → 最适合列式存储 (Parquet/DuckDB)
- 不需要 RDBMS 的事务/锁/B-tree 开销
- 列式布局让
content列连续存放,CPU cache 极度友好- zstd 字典压缩对重复模式多的聊天文本压缩率惊人(82%)
- 追加写入只需要 append 新的 row group
微信搜索慢的根因:不是 SQLite vs grep 的问题,而是 SQLCipher 加密的 PBKDF2 256000 轮 + 行式存储对全文扫描不友好。如果用 Parquet + 硬件 AES,可以同时做到加密和极速搜索。
脑洞:终极方案
messages.parquet (8MB, zstd压缩, 硬件AES加密)
→ DuckDB/Polars 直接查询 (3-5ms)
→ 可选: 内存倒排索引 for < 1ms 搜索
→ 追加: 新消息 append 到 staging 文件, 定期 merge
→ 导出: parquet 是开放格式, 任何语言/工具可读
复现
pip install duckdb polars zstandard cryptography
python3 benchmark.py 500000 # 基础测试 (SQLite/grep)
python3 benchmark_v2.py 500000 # 扩展测试 (DuckDB/Polars/mmap/...)
源码放在 https://github.com/est/snippets/tree/master/grep_vs_sqlite 。
模拟聊天记录有500,000行 一个 50MB 的 .tsv 太大了。自己让AI根据文章和最上面表结构生成一份吧。
后记
被Polars的性能震惊了。说实话如果换我肯定不会选sqlite。聊天记录又不会改。ACID的 OLTP 明显浪费
Comments