基于一台 Windows 微信客户端本地数据的完整观测
| 客户端版本 | 微信 Windows 4.1.15.13 |
|---|---|
| 存储引擎 | WCDB(SQLite 3 分支)+ SQLCipher 4 |
| 观测规模 | 20 个加密库 / 972.8 MB / 1697 张表 / 约 102 万行 |
| 目标读者 | 中高级后端与客户端开发者 |
| 预计阅读 | 22 分钟 |
摘要:本文基于对一台 Windows 机器上微信 4.1.15.13客户端本地数据目录的完整观测与全量迁移实测,拆解其本地持久层的设计:为什么一个聊天软件要拆 20 个加密数据库、为什么要按会话建 994 张表、FTS5 如何做中文分词与拼音索引、4MB 固定大小的 WAL 意味着什么,以及这套设计给普通业务系统带来的启发。文中所有数字均来自实机测量(路径已脱敏,会话名已匿名化)。
微信 4.x 的本地持久层不是一个大数据库,而是20 个按业务域拆分的 SQLCipher 4 加密库,运行于腾讯开源的 WCDB(SQLite 分支)之上。核心结论:
- 按会话分表,而非按行:
message_0.db内含Msg_<md5(会话ID)>表994 张,删除会话等价于DROP TABLE,毫秒级完成。 - 消息量极端长尾:994 张表共 14.5 万行,中位数仅16 行,最大7104 行,前 1% 的表占掉29.3%的消息量。
- 检索与主表彻底解耦:
message_fts.db用 8 张 FTS5 虚表(4 文本 + 4 图片)承接搜索,MMFtsTokenizer自定义分词器通过disable_pinyin/disable_origin开关实现"一份内容、两份索引"。 - 异地分隔的资源索引:图片/视频/文件的去重信息放在
hardlink.db,摘要索引放在message_resource.db,主消息表只存结构化字段。 - 可靠性有边界:WAL 恒定封顶 4MB(自动 checkpoint),优雅退出才能拿到一致快照;崩溃会留下少量坏页,我们实测到
biz_message_0存在真实的行级损坏。
一、实验环境与数据来源
| 项 | 值 |
|---|---|
| 客户端版本 | 微信 Windows 4.1.15.13 |
| 存储引擎 | WCDB(SQLite 3 分支)+ SQLCipher 4 |
| 数据根目录 | <用户文档>\xwechat_files\<账号哈希>\db_storage |
db_storage总体量 | 972.8 MB(含所有附属文件) |
| 完全解读的库 | 14 个(message_0 / biz_message_0 / message_fts / contact / contact_fts / sns / session / favorite / favorite_fts / message_resource / hardlink / head_image / general / emoticon) |
| 未能解读的库 | 6 个(media_0 / bizchat / chatbot_message / solitaire / third_app_icon / weclaw) |
| 分析工具 | Python 3.13(sqlite3 读取、页面级二进制解析)+ PyMySQL |
本文所有结论均建立在本机自有数据的分析之上,重点讨论其工程设计方案。涉及密钥的部分只做架构层面的说明,不提供任何可用于获取他人数据的复现步骤。
二、全局架构:20 个库是怎么切分的
先看清物理布局,再回答一个关键问题:为什么不用一个大库。
2.1 目录按业务域组织
与很多应用把所有表塞进一个main.db不同,微信在db_storage下先按业务域建子目录,再放同名数据库:
db_storage/ ├── message/ # 主消息域 │ ├── message_0.db 138.91 MB │ ├── biz_message_0.db 552.23 MB ← 公众号/服务号消息 │ ├── message_fts.db 35.66 MB ← 消息全文索引 │ ├── media_0.db 66.99 MB ← 媒体索引 │ └── message_resource.db 9.93 MB ← 资源摘要索引 ├── contact/ contact.db 18.62 MB + contact_fts.db 6.01 MB ├── session/ session.db 2.06 MB ├── favorite/ favorite.db 7.74 MB + favorite_fts.db 1.07 MB ├── sns/ sns.db 32.50 MB ├── hardlink/ hardlink.db 2.91 MB ├── head_image/ head_image.db 45.25 MB ├── general/ general.db 5.09 MB ├── emoticon/ emoticon.db 0.41 MB ├── chatbot/ chatbot_message.db ├── bizchat/ solitaire/ third_app_icon/ MMKV/每个库旁边都规整地跟着-wal(预写日志)与-shm(共享内存)文件。命名后缀_fts的三个库是纯索引库,db_storage/MMKV/则是腾讯 MMKV 键值文件的地盘——一个典型的"结构化用 SQLite、小配置用 MMKV"的分层。
2.2 各库的职责边界
| 库 | 体积 | 表数 | 行数 | 职责 |
|---|---|---|---|---|
| message_0 | 138.91 MB | 1003 | 141,161 | 单聊与群聊消息正文 |
| biz_message_0 | 552.23 MB | 503 | 79,985 | 公众号/服务号消息,含大量富媒体 |
| message_fts | 35.66 MB | 60 | 343,771 | 消息全文索引(8 张 FTS5 虚表) |
| contact | 18.62 MB | 16 | 47,837 | 联系人、群成员、陌生人、标签 |
| contact_fts | 6.01 MB | 37 | 153,733 | 联系人检索(含拼音索引) |
| head_image | 45.25 MB | 1 | 1,936 | 头像二进制缓存 |
| message_resource | 9.93 MB | 6 | 92,830 | 消息内资源的摘要索引 |
| hardlink | 2.91 MB | 8 | 20,295 | 图片/视频/文件的去重与路径索引 |
| sns | 32.50 MB | 12 | 28,001 | 朋友圈时间线、草稿、发布任务 |
| session | 2.06 MB | 7 | 12,045 | 会话列表(最近联系人/群) |
| favorite | 7.74 MB | 8 | 8,669 | 收藏 |
| general | 5.09 MB | 22 | 2,004 | 转账/红包/撤回等跨领域杂项 |
| emoticon | 0.41 MB | 7 | 1,125 | 表情包元数据 |
(表数/行数为迁入 MySQL 后的实测值,与目标端一致;6 个未解读库不计入。)
2.3 拆这么多库图什么
从工程角度看,这个粒度至少换来五件事:
- 写锁隔离。SQLite 是库级写锁。朋友圈后台刷新、表情包同步、头像更新如果和"正在打字发消息"争同一把锁,卡顿会被用户直接感知。拆库之后它们各写各的。
- 故障域收敛。我们实测到
biz_message_0存在行级页损坏——如果它和主聊天库是同一个文件,一次坏页就意味着整个资料不可用。 - 按需加载。这是最有意思的一点:微信并不会在启动时打开所有库。
- 生命周期差异。头像、视频封面是可以随时丢弃重建的缓存;聊天记录不是。放在不同文件里,才能用不同的清理策略(删文件 vs 表内 DELETE)。
- 迁移与扩容粒度。
.db之间可以独立做迁移、压缩或搬移。
代价也很真实:按需加载意味着"没被打开的库"对用户/分析者完全不可见。本次实验中media_0(67 MB)、chatbot_message、solitaire等 6 个库始终没被本次会话触达,因此连一处可读取的素材都拿不到。这是一把双刃剑。
三、存储层:WCDB + SQLCipher 4 的页格式
20 个库共用同一套加密页格式,本节把它拆到字节级。
3.1 加密页布局
微信 4.x 走了SQLCipher 4默认配置:AES-256-CBC 加密 + HMAC-SHA512 完整性校验 + PBKDF2-HMAC-SHA512 密钥派生(256000 次迭代)。每个 4096 字节的页面扣除80 字节尾部保留区(16 字节 IV + 64 字节 HMAC),实际可用负载4016 字节:
第 1 页: [0:16] salt 明文 [16:4016] 密文 [4016:4032] AES IV [4032:4096] HMAC-SHA512 其它页: [0:4016] 密文 [4016:4032] AES IV [4032:4096] HMAC-SHA512用 Python 解析一份库文件的页头,可以确认 page_size 一致保持 4096:
import struct head = open('message_0.db', 'rb').read(24) page_size = struct.unpack('>H', head[16:18])[0] # 4096 print(head[:16]) # b'SQLite format 3\x00' print(head[16:24].hex()) # '1000010100402020'4096 - 4016 = 80,正好等于 IV 与 HMAC 的长度之和。这也是为什么 SQLCipher 加密库的 SQLite 头部reserve字段必须是 80:少了它,页尾的身份校验一定失败。
3.2 一库一盐:每个库一把独立密钥
关键设计在于salt 存在第 1 页头部、每个库各不相同。同一份 passphrase 经过不同 salt 派生,得到的是互不相同的 32 字节 raw key。带来的直接后果是:
- 20 个库 = 20 个独立的加密域,破一个不等于破全部;
- 密钥只有在该库真正被打开时才会在进程内存中出现,进程退出即消失;
- 磁盘上不存在可直接使用的明文密钥(
key_info.db里key_md5列为空,真正的 key material 以加密 BLOB 形式存放)。
从安全工程看这是合格的做法:它防的是"整盘拷贝后离线解析",防不住"本机已登录状态下的任意进程读取"。这是一个被很多客户端软件共享的威胁模型边界,值得在做"敏感数据本地存储"时想清楚。
3.3 体积分布:磁盘都花在哪了
biz_message_0.db552 MB 远超message_0.db的 139 MB——公众号文章消息天然携带大量富文本与媒体。而message_fts.db只有 35.66 MB、却撑起了 message_0 里 14.5 万条消息的全文检索(见第八节),说明索引结构设计得相当克制——注意 message_fts 的 MySQL 端行数有 34 万,那是因为 FTS5 的 content / docsize / idx 等影子表各自占行,并非每条消息对应一行。
四、会话级分表:Msg_<md5>的设计取舍
这是整套方案里最反直觉、也最值得借鉴的一环:一个会话一张表。
4.1 命名规则
在message_0.db里,会话名到表名的映射关系是确定的:
表名 = 'Msg_' || lower(hex(md5(会话 username)))例如会话 ID12345678901@chatroom(已脱敏),其消息表为Msg_74b1e878969e017cf5dc78d154c898b8。这个 md5 前缀策略至少有三个好处:
- 定长:表名永远 36 字符,不会因为昵称长短影响
sqlite_master的 b-tree 平衡; - 无需转义:username 里可能含
@、_、中文,直接当表名要做大量引号处理,md5 后一律是[0-9a-f]; - 不可逆:表文件即使被单独导出,也无法从中读出"这是谁的聊天"(当然,配合
Name2Id表可以反查)。
message_0里并非只有消息表,还有一套元数据层(见第六节)。按SELECT name FROM sqlite_master的分类统计:
| 类别 | 数量 |
|---|---|
Msg_<md5>会话表 | 994 |
元数据/系统表(Name2Id/TimeStamp/DeleteInfo/SendInfo/MessageGroupTimeInfo/DeleteResInfo/HistorySysMsgInfo/HistoryAddMsgInfo/wcdb_builtin_compression_record) | 9 |
sqlite_sequence | 1 |
| 合计 | 1004 |
biz_message_0.db同构:Msg_<md5>表 498 张 + 6 张元数据表。
4.2 实测分布:极端长尾
对message_0.db的 994 张会话表逐张COUNT(*):
| 指标 | 值 |
|---|---|
| 会话表总数 | 994 |
| 消息总行数 | 144,939 |
| 平均每表 | 145.8 行 |
| 中位数 | 16 行 |
| 最大表 | 7,104 行 |
| 行数 > 1000 的表 | 33 张(3.3%) |
| 前 1%(9 张表)消息占比 | 29.3% |
| 空表 | 0 |
中位数 16 行、平均 145 行——这意味着绝大多数会话只是"有过几句话"的浅层接触,真正的内容集中在极少数会话里(TOP 10 会话的体量(已匿名化)从 7104 到 2465 不等)。这个分布对我们的启示是:如果你的数据也有这种长尾,按实体建表(而不是一张大表加索引)能获得极低的常数开销。
4.3 收益与代价
| 维度 | 按会话分表(微信做法) | 大表 + 索引(常规做法) |
|---|---|---|
| 删除单个会话 | DROP TABLE,毫秒级,空间立即释放 | 大范围DELETE,触发 VACUUM 才回收空间 |
| 单会话扫描 | 直达 B-tree,无索引回跳 | 需走WHERE chat_id = ?索引 |
| 跨会话搜索 | 必须依赖外部 FTS 库 | 一条 SQL 即可,但会把主表拖垮 |
| 冷启动 | sqlite_master装载 1000+ 条目 | 单表,装载快 |
| DDL 运维 | 1000 张表的迁移/加列成本高 | 一次 ALTER 搞定 |
| 混合读写热点 | 天然隔离 | 页竞争更明显 |
微信选择了前者,并用一个独立的 FTS 库补上它的最大短板(跨会话搜索)。这是一个完整的 trade-off 闭环:先承认方案缺陷,再用另一个子系统设计补偿,而不是硬在同一张表里揉又能改又能查。
五、消息表 Schema:16 列里藏了什么
剥掉分表的外壳看字段,16 列里藏着一条完整的多端同步设计线索。
以Msg_74b1e878969e017cf5dc78d154c898b8为例,这是从解密后的库中读出的原始 DDL:
CREATE TABLE Msg_74b1e878969e017cf5dc78d154c898b8( local_id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER, local_type INTEGER, sort_seq INTEGER, real_sender_id INTEGER, create_time INTEGER, status INTEGER, upload_status INTEGER, download_status INTEGER, server_seq INTEGER, origin_source INTEGER, source TEXT, message_content TEXT, compress_content TEXT, packed_info_data BLOB, WCDB_CT_message_content INTEGER DEFAULT NULL, WCDB_CT_source INTEGER DEFAULT NULL )配套四个索引:
CREATE INDEX ..._SENDERID ON ...(real_sender_id) CREATE INDEX ..._SERVERID ON ...(server_id) CREATE INDEX ..._SORTSEQ ON ...(sort_seq) CREATE INDEX ..._TYPE_SEQ ON ...(local_type, sort_seq)5.1 三个 ID、三个时间维度
这是整张表最值得学习的地方——它明确区分了三种"身份":
| 字段 | 含义 | 为什么必须存在 |
|---|---|---|
local_id | 本地自增主键 | 离线也能发消息,本地必须先有主键。是 UI 滚动、撤回定位的锚点。 |
server_id | 服务端消息 ID | 用于幂等:同一条消息被重复推送时靠它去重。 |
sort_seq | 服务端单调序列号 | 才是真正的"排序键"。本地时间不可信,跨设备一致性靠它。 |
很多 IM 自研方案会直接用create_time排序,然后在多端登录、消息乱序到达时手忙脚乱。微信把"排序"这件事外包给服务端单调序列,是一个干净的做法。
5.2 三份 payload:source/message_content/compress_content
source:原始推送内容,通常是带有 XML 结构的完整数据包,包含所有可能被下游功能用到的字段;message_content:渲染所需的可读文本;compress_content:压缩后的内容(大文本消息的落地形式)。
同一份数据按"用途"存三份,换取的是:渲染时不用解析 XML、解析时不用解压、容量与速度各占一边。这在设计上叫"读写路径分离",在存储成本可接受时非常划算。
5.3WCDB_CT_列:WCDB 的列压缩
每个可被压缩的 TEXT/BLOB 列都配一个WCDB_CT_<列名>的整型标记列(这里是WCDB_CT_message_content、WCDB_CT_source),用于记录该行此列当前使用的压缩算法类型。整个库还有一张注册表:
CREATE TABLE wcdb_builtin_compression_record( tableName TEXT PRIMARY KEY, columns TEXT NOT NULL, rowid INTEGER ) WITHOUT ROWID即:压缩是列级、行级可判定的——同一列里,小数据存原文、大数据压缩后存储,读取时按WCDB_CT_标记决定要不要解压。对比"整列统一压缩"或"整个字段一起 gzip",这个粒度能避免在短文本上白白付出 CPU。
六、元数据层:message_0里的"数据字典"
message_0.db的 9 张非消息表构成了一个轻量元数据层,其中最关键的是字符串整数化:
CREATE TABLE Name2Id(user_name TEXT PRIMARY KEY, is_session INTEGER) CREATE TABLE TimeStamp(timestamp INTEGER) CREATE TABLE DeleteInfo(chat_name_id INTEGER, delete_table_name TEXT, CONSTRAINT UNIQUE_CHAT_DELETE UNIQUE(chat_name_id, delete_table_name)) CREATE TABLE SendInfo(chat_name_id INTEGER, msg_local_id INTEGER) CREATE TABLE MessageGroupTimeInfo(chatname_id INTEGER, group_id TEXT, create_time INTEGER, initial_sort_seq INTEGER, birth_time INTEGER)| 表 | 作用 |
|---|---|
Name2Id | 把 username → 整数 id。让 994 张消息表内部只出现整数,大幅缩小行体积 |
TimeStamp | 单值行,记录本地同步水位 |
DeleteInfo | 记录"某会话的某张分表已被删除",用于多端同步时的已删除判定 |
SendInfo | 待发送/已发送队列与 ACK 关联 |
MessageGroupTimeInfo | 会话内的时间分组(聊天卡片上"今天/昨天"那种分组的锚点) |
把字典抽出来、让数据表里只存整数,是 SQLite 上很有效的一招:INT 是变长编码,5 字节能表示到 2^42;而一个"xxx@chatroom"形式的账号串要 15+ 字节;乘以每条消息一行、几万行,差距显著。
七、会话列表为什么是宽表:反范式的正确性
session.db只有 1542 行,但SessionTable有 20 列:
CREATE TABLE SessionTable( username TEXT UNIQUE, type INTEGER, unread_count INTEGER, unread_first_msg_srv_id INTEGER, is_hidden INTEGER, summary TEXT, draft TEXT, status INTEGER, last_timestamp INTEGER, sort_timestamp INTEGER, last_msg_locald_id INTEGER, last_msg_type INTEGER, last_msg_sub_type INTEGER, last_msg_sender TEXT, last_sender_display_name TEXT, last_msg_ext_type INTEGER, ... )它把"会话的最后一条消息摘要、草稿、未读数、时间戳"全部冗余进同一行。这在 OLTP 建模课上是反范式,但在IM 列表页这个场景下是对的:
- 会话列表是每次进 App 必读的一屏数据,最多几十行;
- 如果规范化的话,每个会话都要回各自的
Msg_<md5>表做一次"取最后一条",1000 张表的MAX(sort_seq)是灾难; - 换成"写消息时顺手更新会话表",就把 N 次读摊平成 1 次写。
session.db还配套了SessionDeleteTable、SessionUnreadListTable_1、SessionDraft、Name2Id、SessionNoContactInfoTable等,把"删除""未读列表""草稿"这类会反复修改的字段再拆出去,避免行内热点竞争。按"读写频率差异"二次拆分,是这套设计里贯穿始终的思路。
八、中文全文检索:FTS5 + 自定义分词器 + 分片
中文没有空格,标准分词器会直接失效——微信的解法是自定义分词器加分片索引。
8.1message_fts.db:8 张虚表
| 虚表 | 用途 |
|---|---|
message_fts_v4_0~v4_3 | 消息文本索引,4 个分片 |
ImgFts0V0~ImgFts3V0 | 图片相关索引,4 个分片 |
消息索引虚表的定义:
CREATE VIRTUAL TABLE message_fts_v4_0 USING fts5( tokenize = 'MMFtsTokenizer disable_pinyin', acontent, message_local_id UNINDEXED, sort_seq UNINDEXED, local_type UNINDEXED, session_id UNINDEXED, sender_id UNINDEXED, create_time UNINDEXED )要点:
- 只有
acontent参与建索引,其余 6 列全部UNINDEXED——它们只是随索引一起存放的"载荷",供命中后回填,不再占用倒排表空间。 - 分词器是自定义扩展
MMFtsTokenizer,不是 FTS5 自带的unicode61。中文没有空格,标准分词器只会把整句当成一个 token,MMFtsTokenizer负责实现词典/子串切分,并且通过参数开关把 tokenizer 参数化。 - 分成 4 个 shard(外加 4 个图片 shard)。分片的好处是:索引构建/增量合并可以分片并行,单个 shard 损坏不致命,也便于"按会话 hash 路由到 shard"。
每张文本虚表都配一张 aux 表:
CREATE TABLE message_fts_v4_aux_0( message_local_id INTEGER, sort_seq INTEGER, session_id INTEGER, CONSTRAINT sessionId_localId_sortseq PRIMARY KEY(session_id, message_local_id, sort_seq) )它的主键(session_id, message_local_id, sort_seq)用来保证"一条消息在索引里只出现一次",也支持按会话快速删除索引条目。另有message_fts_v4_range、message_fts_v4_session_delete_info、table_info三张台账表。
8.2 一份原始内容,两份索引:外部内容表
contact_fts.db里有 6 张虚表,其中一对尤其漂亮:
CREATE VIRTUAL TABLE wa_contact_fts_v1 USING fts5( tokenize = 'MMFtsTokenizer disable_pinyin enable_special_char', search_key, time UNINDEXED) CREATE VIRTUAL TABLE wa_contact_fts_pinyin_v1 USING fts5( tokenize = 'MMFtsTokenizer disable_origin enable_special_char', content='wa_contact_fts_v1', -- 外部内容表:复用同一份content search_key, time UNINDEXED)同一份联系人文本,wa_contact_fts_v1用原始分词建索引(搜汉字),wa_contact_fts_pinyin_v1用拼音分词建索引(搜 "zhangsan" 就能命中"张三")。后者通过content='wa_contact_fts_v1'声明外部内容表——content 只存一份,倒排索引存两份。
三个开关的组合非常克制:
| 开关 | 含义 |
|---|---|
disable_pinyin | 不生成拼音 token(汉字索引用它) |
disable_origin | 不生成原始 token(拼音索引用它) |
enable_special_char | 允许"@"、"-"这类符号进入索引,便于精确匹配账号、邮箱等 |
contact_fts.db总共 37 张表、15.3 万行,撑起了联系人、群成员、表情搜索词典三类检索。把"检索"当成一个独立产品域来建模,而不是给主表加 LIKE,是这套架构最值得借鉴的地方。
九、资源去重与摘要:hardlink与message_resource
图片、视频、文件不进消息表,而是另开两处索引承接。
9.1hardlink.db:MD5 去重
image_hardlink_info_v4 / file_hardlink_info_v4 / video_hardlink_info_v4 索引: xxx_v4_DIR1 / xxx_v4_MD5_HASH / xxx_v4_MODIFY_TIME dir2id -- 目录名 → 整数 id file_checkpoint_v4 / video_checkpoint_v4 / talker_checkpoint_v4三类资源各自一张表,均建有MD5_HASH索引——同一个文件被反复转发、反复出现在不同会话时,物理文件只存一份,逻辑引用靠 MD5 关联。
*_checkpoint_v4表是增量扫描的水位线(talker_checkpoint_v4甚至按MONTH_ID建索引),用于"下次扫描只处理新增部分"。这套"哈希去重 + 扫描水位"的组合,在本地相册/网盘类应用里几乎是标准答案。
9.2message_resource.db:把富消息摘要外移
ChatName2Id / SenderName2Id -- 又一次字符串整数化 MessageResourceInfo -- CC/CS/SENDER/TIME/TYPE 五个索引 MessageResourceDetail -- ATIME/CTIME/MID/SIZE/TYPE 五个索引 FtsRange / FtsDeleteInfo -- 与 FTS 库的联动台账6 张表、9.3 万行。它的存在让"统计某人发了多少张图""按大小清理某个会话的文件"这类查询,完全不需要触碰 139 MB 的message_0.db。再一次:按访问模式拆表。
十、可靠性:WAL 为什么恒定封顶 4MB
10.1 观测事实
扫描所有-wal文件可以看到一个整齐的现象:
| 库 | 主库体积 | -wal体积 |
|---|---|---|
| message_0 | 138.91 MB | 4.00 MB |
| biz_message_0 | 552.23 MB | 4.00 MB |
| contact | 18.62 MB | 4.00 MB |
| contact_fts | 6.01 MB | 4.00 MB |
| message_fts | 35.66 MB | 4.00 MB |
| session | 2.06 MB | 4.00 MB |
全部卡在 4.00 MB。这显然不是巧合,而是设置了 WAL 自动 checkpoint 的大小阈值(SQLite 的journal_size_limit/ WCDB 的自动 checkpoint 配置):一旦 WAL 增长到 4MB 就立刻把已提交页回写主库并截断,把"未落盘数据量"控制在一个可预测的上限内。
这对终端用户体验很关键:崩溃最多丢最近几 MB 的写入,而不是让你回到上个月。
10.2 一致性:优雅退出 vs 强杀
我们在取快照时对比过两种方式:
| 方式 | 结果 |
|---|---|
| 直接结束进程取快照 | WAL 残留最多 4MB 未 checkpoint 数据,部分表读出来database disk image is malformed |
| 发送关闭请求让客户端正常退出 | 进程清空、WAL 回写完毕,可直接得到一致快照 |
更要紧的一点:SQLCipher 会改写加密 WAL 的帧头。我们逐帧解析时发现,同一个 WAL 文件内各帧的 salt 字段并不一致,按标准 SQLite 的"盐必须匹配头部"来判定,会把 998/1018 个有效帧误判为垃圾;但反过来,这些帧的提交点页数小于主库当前页数,说明它们其实是上一周期残留——贸然应用反而会把数据库回退成旧版本并造成损坏。
结论:对着一个正在运行的、加密的 SQLite 做离线分析时,"这个帧要不要应用"必须同时用"解密后是否合理"和"提交点是否比主库新"双重判定。这也是我们把分析流程改成"先优雅退出、再快照、再逐页解密"的原因。
10.3 仍有真实损坏
即便如此,biz_message_0里仍有少部分Msg_表存在行级损坏(COUNT(*)能跑、SELECT *会在某些行上失败)。这说明:SQLite + WAL 能保证单个事务原子,不能保证在进程被粗暴终止时任何页都不坏。我们在迁移时改成按 rowid 分批读取、坏批逐行重试,最终只丢弃了真正损坏的少数行。
十一、这套设计对普通业务系统的启发
把上面的观察抽象成可以直接抄的七条:
- 按"变化频率 + 访问模式"切库,不按业务模块拍脑袋。微信切的是"可以被批次性丢弃的"和"绝对不能丢的"、"每秒写一次的"和"每天写一次的"。
- 长尾数据用分表,别用大表加索引。中位 16 行的数据分布下,分表让"删一个实体"从全表扫描变成
DROP TABLE。代价交给外部索引库去补。 - 把"排序权"交给服务端单调序列。本地时间戳在多端同步场景下永远不可信。
- 字符串一律整数化。
Name2Id这种字典表是 SQLite 上性价比最高的优化之一。 - 检索是独立子系统。用 FTS5 + 自定义 tokenizer + 外部内容表,做到"一份 content、多份倒排"。不要在主表上写
LIKE '%x%'。 - 给每条持久化路径一个可预测的上限。4MB 的 WAL 意味着"最坏情况丢多少"是已知量。
- 列压缩做到行列级可判定。
WCDB_CT_<col>这种标记列只有 1 字节左右的成本,换来的是压缩策略的灵活度。
十二、附录 B:完整数据清单
| 库(源 SQLite) | 表数 | 行数 | MySQL 端体积 |
|---|---|---|---|
| message_fts | 60 | 343,771 | 60.38 MB |
| contact_fts | 37 | 153,733 | 20.03 MB |
| message_0 | 1003 | 141,161 | 236.41 MB |
| message_resource | 6 | 92,830 | 16.72 MB |
| biz_message_0 | 503 | 79,985 | 781.42 MB |
| contact | 16 | 47,837 | 20.67 MB |
| sns | 12 | 28,001 | 15.22 MB |
| hardlink | 8 | 20,295 | 5.50 MB |
| session | 7 | 12,045 | 3.08 MB |
| favorite | 8 | 8,669 | 6.19 MB |
| favorite_fts | 7 | 3,048 | 0.72 MB |
| general | 22 | 2,004 | 5.00 MB |
| head_image | 1 | 1,936 | 9.67 MB |
| emoticon | 7 | 1,125 | 0.45 MB |
_meta_tables(自建元数据) | 1 | 1,697 | 0.48 MB |
| 合计 | 1698 | ≈ 1,023,920 | ≈ 1.18 GB |
边界声明
- 本文分析对象为作者本人设备上自己账号产生的本地数据,属于个人数据自主范畴,文中会话名、联系人均已匿名化。
- 本文的目的是理解优秀的客户端本地存储工程实践,不涉及、也不提供任何读取他人数据的手段。涉及密钥的部分仅做架构层面的讨论。
- 微信各版本的库结构会变化(本文实测于 4.1.15.13),迁移到其他版本前请以实际
sqlite_master为准。
元信息
- 目标读者:中高级后端/客户端开发者,尤其是正在设计本地数据存储、IM、离线优先应用的同学
- 前置知识:SQLite 基础(WAL、B-tree、FTS5)、对称加密基本概念、SQL 调优常识
- 技术栈版本:微信 Windows 4.1.15.13 / SQLCipher 4 / SQLite 3 / MySQL 8.4.7 / Python 3.13
- 预计阅读时间:22 分钟
- 关键词:
SQLiteSQLCipherWCDBFTS5数据库设计本地存储微信分表策略
本文所有数据均来自本机微信客户端自有数据的离线分析,会话名、群名、联系人等已做匿名化处理。文中涉及的加密分析仅用于理解本地存储架构,不提供任何读取他人数据的手段。