grep vs sqlite 谁更适合微信聊天记录?

一个爆火的讨论

云风 @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                  -- 附件路径
);

测试设计

参赛选手

分类 方案 思路
传统文本 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" — 部分正确,但格局太小

  1. ripgrep 比 SQLite LIKE 快 3 倍(13ms vs 42ms)— "grep 派"的论据成立
  2. 但 DuckDB/Polars 比 ripgrep 又快 4 倍(3ms vs 13ms)— 列式才是正道
  3. Parquet 比纯文本还小 5.6 倍(8MB vs 47MB)— 省空间还更快
  4. 复合查询(按人+时间+关键词)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