1. 先把 Multi 文件夹的作用弄清楚
1.1 三类数据库的分工:MSG、MediaMSG、FTSMSG
很多人第一次打开聊天软件的个人数据目录,看到Multi文件夹下面一大堆MSG0.db、MediaMSG3.db、FTSMSG7.db,第一反应是"为什么同一个东西要拆成这么多份"。我当初也一样,以为这是历史遗留的垃圾文件,直到自己动手写脚本导出聊天记录,才发现这套设计其实相当克制——它把消息主体、媒体索引、全文检索这三类读写特征完全不同的数据,硬生生拆到了三个独立的库文件里。
MSG*.db:存的是消息本体。一条消息是谁发的、发给谁、什么类型、什么时候、正文是什么,全在这里。这是整个体系的主干,也是我们解析的重心。MediaMSG*.db:存的是媒体类消息的附加信息。语音、图片、视频、文件这些消息在MSG库里只会留下一个"类型标记 + 指向",真正的媒体元数据(比如文件大小、时长、存储路径的散列值)放在MediaMSG库里。FTSMSG*.db:全文索引库。它的表结构是全文检索引擎的虚拟表,专门用来做关键词搜索。你在客户端里搜一句话能秒出结果,靠的就是它,而不是去扫MSG主表。
这三个库的关系,可以类比成图书馆:MSG是书架上的实体书,MediaMSG是每本书附带的光盘存放位置登记册,FTSMSG则是按关键词排好的索引卡片。你要查"某个作者写了什么",走索引卡片最快;你要看书的完整内容,必须回到实体书;你要找配套光盘,得查登记册。三个库各司其职,谁也替代不了谁。
理解这个分工非常关键,因为后面的所有操作都建立在这个认知上:不要试图只解析 MSG 库就把事情做全。我见过不少人写完MSG的查询就以为大功告成,结果发现语音消息只有一行"类型 34"的占位,正文空白,然后开始怀疑人生。其实内容没丢,只是你还没去MediaMSG和附件目录里取。
1.2 目录结构与分片规则
以常见的 Windows 桌面客户端版本为例,Multi文件夹典型结构大致是这样(不同版本号会有差异,但主体框架稳定):
Multi/ ├── MSG0.db ├── MSG1.db ├── ... ├── MediaMSG0.db ├── MediaMSG1.db ├── ... ├── FTSMSG0.db ├── FTSMSG1.db ├── ... ├── Media.db └── (同级还有 Contact、Favorite、Sns 等目录)这里有两个细节值得单独说。
第一,为什么是 0 到 9 这样的编号。这套编号本质上是分片(sharding),而不是按会话或按时间切分。当当前活跃的库文件增长到一定体量(经验值在两百多 MB 这个量级),客户端就会把新消息写进下一个编号的库,如此滚动。所以你经常会看到MSG4.db只有几 MB 而MSG3.db有两百多 MB,这不是文件损坏,而是滚动写入的正常结果。
第二,.db-wal和.db-shm文件不能忽略。这是预写日志(WAL)模式的副产物。如果你只复制.db文件而不带上同名的-wal,可能会丢掉最近一段时间尚未合并进主文件的数据,表现为"导出来的记录少了几天的量"。我在第一次做备份的时候就踩过这个坑,导出结果比客户端界面上显示的少了大概两天的消息,查了半天才想起来 WAL 文件没带走。
注意:复制数据库文件之前,先让客户端完全退出。进程还在运行时,文件处于被占用状态,复制出来的可能是一个内部状态不一致的快照。宁可多等几秒,也别图省事。
1.3 拆库设计背后的取舍
如果让我来评价这套设计,我觉得核心动机是读写分离与故障隔离。
消息写入是高频、小体积、强顺序的,适合用紧凑的事务批量提交;全文索引的写入是低频但每次都很重,需要重构倒排表,如果和主表放在同一个文件里,每次发消息都要等索引更新完,体验会很差;媒体元数据的写入量小但单条记录大,而且经常需要跨库关联查询。三种负载模式混在一个库里,锁竞争会非常明显。
拆开之后还有一个隐性收益:单库损坏的影响面被限制住了。索引库出问题,删掉重建即可,主数据毫发无伤;某个分片被误删,损失的是那一段时间窗口的消息,而不是全部。
代价也很明显——跨库关联查询变得麻烦。你不能一条 SQL 从消息表直接 join 到语音时长,必须在应用层做两段查询再拼装。这就是为什么自己写解析脚本时,代码里会有一堆"先查 MSG 拿到 localId,再拿去 MediaMSG 匹配"的逻辑。这不是代码写得丑,是数据结构决定的,只能认。
2. 动手之前先把数据模型想明白
2.1 三张核心表怎么串起来
在MSG*.db里,真正需要吃透的是三张表:MSG、Name2ID、Session。它们构成了一个很朴素但极其有效的模型。
Name2ID是一张映射表,把会话标识(一串字符 ID)映射成一个自增整数。MSG表里的TalkerId字段存的就是这个整数,而不是原始字符串。这么做的原因很直接——整数占 4 字节,字符串动辄二三十字节,一张表里有几百万条消息,光这一项就能省下可观的存储空间,同时整数比较也比字符串比较快得多。
Session表存的是会话列表信息:会话标识、最后一条消息时间、最后一条消息摘要。它相当于客户端的"最近聊天"列表的数据源。
三者的关联路径是这样的:
-- 第一步:从映射表拿到整数 ID SELECT rowid FROM Name2ID WHERE UsrName = '某个会话标识'; -- 第二步:用整数 ID 去 MSG 表捞消息 SELECT localId, Type, SubType, IsSender, CreateTime, StrContent FROM MSG WHERE TalkerId = ? ORDER BY CreateTime ASC; -- 第三步:需要媒体信息时再去 MediaMSG 库 -- (注意这是另一个数据库连接) SELECT * FROM Media WHERE TalkerId = ? AND localId = ?;这套流程看起来简单,但实际写代码时有几个点很容易出错。比如Name2ID表在不同版本里的列名可能叫UsrName,也可能是UserName,字段数量也不完全一致。稳妥的做法是先跑一次PRAGMA table_info(Name2ID)把列名打出来看一眼,别硬编码。
2.2 MSG 表字段逐个过一遍
下面这张表是我自己整理并反复验证过的字段说明,覆盖了绝大部分实际用得到的列。不同版本会有增删,但核心字段是稳定的。
| 字段名 | 类型 | 含义与使用要点 |
|---|---|---|
localId | INTEGER | 库内自增主键,只在单库内唯一,跨库比较没有意义 |
TalkerId | INTEGER | 会话整数 ID,需通过Name2ID反查 |
MsgSvrID | INTEGER | 服务端消息唯一标识,跨设备、跨库都唯一,是最可靠的去重依据 |
Type | INTEGER | 消息大类,文本、图片、语音、系统消息等都靠它区分 |
SubType | INTEGER | 子类型,Type为应用消息时才有实际意义 |
IsSender | INTEGER | 0 表示对方发出,1 表示自己发出 |
CreateTime | INTEGER | 秒级 Unix 时间戳,注意不是毫秒 |
Sequence | INTEGER | 会话内顺序号,用于同秒消息排序 |
StrTalker | TEXT | 会话标识的冗余副本,省去一次映射查询 |
StrContent | TEXT | 文本类消息的正文 |
CompressContent | BLOB | 压缩后的正文,部分版本对大文本会走这个字段 |
BytesExtra | BLOB | 序列化后的附加信息,媒体路径、引用关系常藏在这里 |
BytesTrans | BLOB | 转发来源等附加信息,多数场景可以不解析 |
这里要特别强调localId和MsgSvrID的区别。localId是库内局部主键,MsgSvrID才是全局身份。我曾经天真地以为localId唯一,用它做去重键,结果在合并多个分片库的数据时,出现了大量"同一条消息被误判为两条"或者"两条不同消息被合并成一条"的问题。后来全部改用MsgSvrID做去重,配合StrTalker和CreateTime做二次校验,问题才消失。
2.3 时间、序号、去重三者的配合关系
时间戳这块看似没什么好讲的,但实操中问题不少。
CreateTime是秒级时间戳,这个没问题。但同一个会话里,同一秒内发出多条消息是常态,尤其是快速连发的时候。这时候光靠CreateTime排序,顺序会乱。必须叠加Sequence字段,而这个字段在同一会话内是递增的。正确的排序语句应该是:
ORDER BY CreateTime ASC, Sequence ASC, localId ASC最后那个localId是兜底,因为极少数情况下Sequence可能出现重复或缺失。
还有一个容易忽略的点:不同分片库之间的时间不是严格单调的。因为分片是按体积滚动的,理论上MSG2里的消息都比MSG3早,但如果你做过数据迁移、恢复备份一类的操作,这个假设就不一定成立。稳妥的合并策略是:把所有分片的记录捞出来,统一按CreateTime加Sequence排序,而不是简单地把MSG0拼到MSG1前面。
提示:合并多分片数据时,先按
MsgSvrID建立哈希集合去重,再做全局排序,比边查边插要快一个数量级,也更不容易出错。
3. MSG 库的解析实操
3.1 建立连接与第一轮探查
拿到一个.db文件后,第一件事不是急着写业务查询,而是先做结构探查。我通常按这个顺序走一遍:
-- 1. 看有哪些表 SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY name; -- 2. 看 MSG 表结构 PRAGMA table_info(MSG); -- 3. 看索引情况,判断哪些查询会快 SELECT name, tbl_name FROM sqlite_master WHERE type = 'index'; -- 4. 粗看数据量 SELECT COUNT(*) FROM MSG;第四步的COUNT(*)在大库上可能跑十几秒,如果只是想估算规模,可以用SELECT MAX(localId) FROM MSG代替,快得多。
这里有个实用性很强的建议:把探查结果落成一个结构快照文件。因为不同客户端版本的表结构会有细微差异,你的解析脚本很可能要面对多个版本的数据。每次遇到新版本,先跑一遍探查,把PRAGMA table_info的结果存下来,脚本里做字段存在性判断,而不是假设某个字段一定存在。这个习惯能帮你省掉大量"为什么在别人电脑上跑不通"的麻烦。
3.2 消息类型判定与内容分发
Type字段是解析的分水岭。我做过的项目里,绝大多数代码量都花在这个字段的分支处理上。下面是实践中覆盖到的主要类型:
| Type | 含义 | 正文获取方式 |
|---|---|---|
| 1 | 文本 | 直接读StrContent |
| 3 | 图片 | 读StrContent里的 XML 描述,实际文件在附件目录 |
| 34 | 语音 | 存于MediaMSG,需解码音频格式 |
| 42 | 名片 | StrContent为 XML |
| 43 | 视频 | XML 描述加缩略图路径 |
| 47 | 表情 | XML,含 CDN 标识 |
| 48 | 位置 | XML,含经纬度与地址文本 |
| 49 | 应用消息 | 由SubType二次分发,最常见也最复杂 |
| 10000 | 系统提示 | 直接读StrContent,多为"撤回""入群"类提示 |
| 10002 | 系统操作 | 与 10000 类似,处理时机不同 |
Type = 49这一类需要单独展开,因为它是"万能容器"。SubType常见取值包括 5(链接或文件)、6(文件)、19(合并转发的聊天记录)、33 与 36(小程序卡片)、57(引用回复)、2000(转账)、2001(红包)等。
处理策略上我推荐分层解耦:先用Type分出大类,再用SubType细分,每一类写一个独立的解析函数,统一返回一个标准化的结构体(包含发送者、时间、文本摘要、附件路径列表)。这样即使以后新增了子类型,也只需要加一个函数,不用动主流程。
# 伪代码示意:分层分发的骨架 def parse_message(row): t = row["Type"] if t == 1: return {"kind": "text", "text": row["StrContent"]} if t == 49: return parse_app_message(row["SubType"], row["StrContent"]) if t == 34: return {"kind": "voice", "ref": row["localId"]} return {"kind": "other", "raw": row["StrContent"]}注意:不要试图用正则去"一把梭"解析
StrContent里的 XML。消息体里的内容经常包含用户自己输入的尖括号、换行、转义字符,正则很容易在边界上翻车。老老实实用 XML 解析器,遇到解析异常就降级成原始文本保存,这条兜底策略救过我很多次。
3.3 引用、合并转发与系统消息的识别
引用回复(SubType = 57)的结构是"外层一条消息 + 内嵌一段被引用的原文"。外层正文里通常包含<title>标签存放引用摘要,<refermsg>节点存放被引用消息的详细字段。解析时要注意:被引用的内容可能是任意类型,包括图片和语音,所以不能只取文本。
合并转发(SubType = 19)更麻烦一些。它在外层只存一个标题和一段记录列表的描述,真正的完整聊天记录往往以附加数据的形式存在,需要去附件目录里找对应的记录文件。如果只看数据库,你只能得到"某人转发了 N 条聊天记录"这个信息,内容和它下面的展开项拿不到。
系统消息(Type = 10000)看起来最简单,其实文本格式很不统一。同一类事件在不同版本里措辞完全不一样,"你撤回了一条消息"和"XX 撤回了一条消息"在结构上是同一回事,但字符串匹配规则完全不同。我的做法是只做归类不做精确提取:把系统消息按关键词粗略分成"撤回、入群、退群、改名、其他"几类,具体原文原样保留。这样既够用,又不用跟着版本无限打补丁。
3.4 压缩字段与附加二进制字段的处理
CompressContent这个字段值得单独说。部分版本里,较长的文本内容会被压缩后存进这个 BLOB 字段,而StrContent反而是空的或者只存一个占位。如果你发现某些消息StrContent是空的但确实有内容,八成就是走了压缩路径。
处理流程是:先判断CompressContent是否非空,非空则尝试解压。压缩算法以 zstd 为主,也有版本用其他通用无损算法。解压前先读前几个字节判断魔数,避免用错算法导致解压失败。
# 思路示意:先探测魔数再决定算法 def maybe_decompress(blob): if not blob: return None if blob[:4] == b"\x28\xb5\x2f\xfd": # zstd 帧魔数 return zstd_decompress(blob) return None # 未知格式,交回上层处理BytesExtra是另一个重点。它的内容是序列化后的键值对,媒体文件的相对路径、引用关系、部分消息的扩展属性都藏在里面。解析它需要先确认序列化格式(不同版本用过不同的编码方式),再逐字段提取。我的经验是只取需要的键,不要全量反序列化——全量反序列化在几百万条消息上会非常慢,而且经常因为个别字段格式异常整条记录解析失败。
提示:解析二进制字段时,永远用 try/except 包住,失败就记录一条日志并跳过。一条脏数据不应该让整个导出任务崩掉。
4. MediaMSG 库与媒体资源的对应关系
4.1 MediaMSG 的表结构与关联方式
MediaMSG*.db的结构比MSG简单得多,核心就是一张Media表。它的关键字段包括Key、TalkerId、localId、MsgSvrID、CreateTime、Sequence,以及若干保留字段。
关联方式有两种,各有适用场景:
- 用
localId关联:最快,但只在同一个分片内有效。也就是说MSG3.db里的localId必须去MediaMSG3.db里查,跨分片查不到。 - 用
MsgSvrID关联:慢一点,但全局唯一,适合跨分片合并后的查询。
我一般先用localId在同分片内做批量关联(一次查一批,而不是逐条查),处理不了的再退回MsgSvrID。批量查询这个优化很重要,逐条查询在十万级数据上能跑几个小时,改成每批五百条之后缩短到几分钟。
4.2 语音消息的定位与格式转换
语音消息是很多人卡住的地方。MSG表里它只有一行Type = 34的记录,StrContent里通常是一段包含时长和格式标识的 XML,真正的音频文件在附件目录下,文件名是一串散列值。
定位路径的逻辑是:从BytesExtra或者StrContent里取到相对路径片段,拼接附件根目录,再按两级散列子目录去找。实际目录层级类似附件根/会话散列/年-月/文件名,具体层级随版本变化,稳妥做法是先用文件名做一次全目录索引,再反查,而不是硬编码路径拼接规则。
拿到音频文件后还要面对格式问题。桌面端的语音普遍采用一种为低码率语音设计的专用编码,不是常见的 WAV 或 MP3,直接用播放器打不开。转换方式是用对应的解码库先解成裸 PCM,再封装成 WAV。
# 思路示意:先解码再封装 # 输入是专用编码语音,输出标准 WAV decode_voice input.audio - | pcm_to_wav -o output.wav --rate 24000 --channels 1采样率一般是 24kHz 单声道,这个参数错了会导致声音变调,听起来像快进或慢放。我第一次转换时用了默认的 16kHz,结果所有语音听起来都像被拉长了,排查了好一阵。
4.3 图片、视频、文件的存储与命名规律
图片和视频在数据库里留下的是 XML 描述加一个指向,实际文件在FileStorage一类的目录下,按年月分文件夹,文件名是内容散列加扩展名。
这里有个绕不开的环节:部分图片文件落盘时做了轻量混淆处理,文件头被改写过,直接改扩展名打不开。处理方式是根据文件头特征做还原,常见做法是识别出实际格式后按对应规则还原字节。还原逻辑不复杂,但要针对不同格式分别处理,写的时候注意保留原始文件副本,别在原文件上直接改。
文件类消息(SubType = 6)在 XML 里通常带有文件名、大小、扩展名,实际文件同样按散列命名存放。映射关系是:数据库里的散列值 → 目录索引 → 实际文件。建议第一次处理时花几分钟建一张"文件名到完整路径"的哈希表常驻内存,后续查起来就是 O(1)。
注意:处理媒体文件时务必先做完整备份。还原、转码这类操作一旦写错就是不可逆的,而原始文件往往只有一份。
5. FTSMSG 库的检索能力
5.1 全文索引表的结构特征
FTSMSG*.db里的核心对象是一张全文检索虚拟表。它的特点是:你查询它的时候,它返回的是匹配结果和相关性排序,而不是让你逐行扫描。
表里通常包含消息文本、所属会话标识、对应的消息引用这几个关键列。具体列名在不同版本里会变,但结构思路一致:用倒排索引换来毫秒级的模糊匹配。
一个很实用的判断:如果你在客户端里搜索某个词,结果是秒出的,那说明走的是索引库;如果搜索历史很久远的消息时明显变慢,那可能是索引没有覆盖到旧分片,退回了主表扫描。
5.2 用索引做快速检索的正确姿势
直接对虚拟表做MATCH查询,比你写LIKE '%关键词%'快几个数量级。原因是LIKE带前置通配符时无法使用任何索引,只能全表扫描;而全文索引是先查词典再定位文档。
-- 推荐:走全文索引 SELECT * FROM 索引表 WHERE 索引表 MATCH '关键词' LIMIT 50; -- 不推荐:全表扫描,几十万条以上就没法看了 SELECT * FROM MSG WHERE StrContent LIKE '%关键词%';需要注意的是,全文索引的匹配规则和LIKE不完全等价。它默认按词切分,中文场景下分词质量直接影响召回率。有些词的组合搜不到,不是数据缺失,而是分词把它切开了。遇到这种情况,可以尝试缩短关键词,或者改用前缀匹配语法。
5.3 索引与主表的一致性维护
索引库和主表不是强一致的。消息写进主表之后,索引更新是异步的,中间存在一个时间窗口。正常情况下这个窗口很短,但如果客户端异常退出,可能出现"消息在,但搜不到"的情况。
我自己遇到过一次:几百条消息在界面上正常显示,但怎么搜都搜不出来,后来对比索引库和主表的记录数才定位到问题——索引落后了。这种情况的处理方式是不要自己手改索引表(虚拟表的手工写入很容易把索引搞坏),而是通过客户端自身的机制触发重建,或者干脆在解析脚本里绕开索引,直接对主表做分批扫描。
如果需要大批量检索,我的建议是自己建索引:把主表数据导出成结构化格式后,用成熟的检索库重新建一套,可控性更强,也不受客户端索引状态的影响。
6. 打开加密数据库的原理与正确姿势
6.1 为什么直接打开会报错
用一个普通数据库客户端去打开这些.db文件,大概率会得到一句file is not a database。这不是文件损坏,而是文件本身是整库加密的,页数据在落盘前被逐页加密过,明文结构完全不可见。
这类加密方案的基本做法是:以固定大小的页为单位(通常是 4096 字节),每页独立加密,页尾预留一小段空间存放校验值,首页开头放一段随机盐值。密钥不是直接用你手里的原始字符串,而是经过多轮派生计算得到的。
6.2 密钥派生与页结构的关键参数
要正确解密,需要知道四个参数:页大小、派生迭代轮数、加密算法、校验算法。这几个参数在社区里有成熟的经验值,常见组合是页大小 4096、派生算法为带 HMAC 的 PBKDF2、加密用 AES-256 的分组模式、每页尾部的校验值占 20 字节左右。
派生迭代轮数这一项最容易出错。不同版本用过不同的值,常见的几个量级分别对应不同年代的版本。逐个试的成本不高,因为参数错误时解密出来的首页数据一眼就能看出是乱码,试一次只需几毫秒。
这里我必须说清楚一件事:本文只讨论原理,不提供密钥获取的具体手段。密钥由客户端在本地持有,具体怎么拿到不属于本文范围,也不应该用在任何不属于自己的数据上。下面所有的操作前提都是——你对自己设备上的数据拥有完整处置权,且只用于个人数据整理与学习。
6.3 一份可靠的操作流程
我总结下来比较稳的流程是这样的:
- 完全退出客户端,确认相关进程已经结束。
- 整体复制目录,把
Multi连同-wal、-shm文件一起拷到独立的工作目录。这一步是硬性要求,后面所有操作都在副本上做。 - 先解密,后分析。把加密库还原成标准明文数据库文件,后续所有查询都用普通数据库工具跑,效率高得多。
- 校验完整性。解密后跑一次
PRAGMA integrity_check,返回ok才算成功。如果不 ok,多半是页大小或校验参数不对。 - 建工作副本。分析脚本一律读这份明文副本,绝不直接改。
# 流程示意(参数需按实际版本调整,此处仅为结构演示) step1_copy() { cp -a "$SRC"/. "$WORK"/; } step2_decrypt() { decrypt_db --page 4096 --kdf-iter N --in "$WORK"/MSG0.db --out "$WORK"/MSG0.plain.db; } step3_check() { sqlite3 "$WORK"/MSG0.plain.db "PRAGMA integrity_check;"; }注意:
-wal文件一定要一起处理。有些解密流程会忽略 WAL,导致解密后的库缺少最近的数据。如果解密工具不支持 WAL,可以先在明文副本上做一次检查点操作把它合并进主文件。
6.4 工具选型的取舍
社区里有几种思路:一是用支持该加密方案的数据库分支版本,直接带参数打开;二是写脚本按页解密再拼装成标准文件;三是用通用解密工具处理。
我个人的偏好是第二种,原因很实际:可控。写脚本虽然前期要多花一两个小时,但页大小、迭代轮数、偏移量这些参数全部掌握在自己手里,遇到版本差异时改一行就行。用现成工具的好处是快,坏处是参数对不上时你只能干瞪眼,看不到中间过程。
如果只是偶尔处理一两个库,用现成工具完全够用;如果要做批量、多版本的解析,自己写一套解密模块是值得的投资。我现在的做法是把解密逻辑封装成一个独立模块,输入输出都是文件路径,上层业务代码完全不用关心加密这回事。
7. 常见问题与排查实录
7.1 报错速查表
下面这些是我在实际操作中反复遇到的,按出现频率排序:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
file is not a database | 库仍是加密状态 | 确认解密参数,检查首页盐值是否正确读出 |
解密成功但integrity_check失败 | 页大小或校验参数错 | 逐个试常见页大小,优先 4096 |
| 记录数比客户端显示少 | WAL 未一并处理 | 带上-wal文件,或先做检查点合并 |
| 中文全是乱码 | 编码判断错误 | 内容多为 UTF-8,按此解码后再处理异常项 |
| 时间显示为 1970 年 | 把秒当毫秒用 | CreateTime是秒级,乘以 1000 才是毫秒 |
| 语音打不开 | 未做格式转换 | 先解码为 PCM 再封装为 WAV |
| 图片改扩展名后仍打不开 | 文件头被混淆 | 识别实际格式后按规则还原 |
| 多分片合并后顺序错乱 | 直接按分片号拼接 | 改为全局按时间加序号排序 |
| 同一消息出现两次 | 用localId去重 | 改用MsgSvrID去重 |
| 某类消息正文为空 | 走了压缩字段 | 检查CompressContent并解压 |
7.2 三个我踩过的深坑
坑一:以为localId全局唯一。这个前面提过,但值得再强调一次。它只在单库内唯一,跨库直接比较会得到完全错误的结果。修正方式很简单,但发现问题的过程很痛苦——我对着两份"看起来一样"的数据查了整整一个下午。
坑二:XML 解析没做异常兜底。消息体里的 XML 偶尔会包含用户输入的非法字符,导致解析器直接抛异常。一开始我让异常往外冒,结果一个坏字符就让整个批次的导出中断。后来改成每条消息独立 try/except,失败的记录写入一个单独的失败清单,事后人工核查,稳定性立刻上了一个台阶。
坑三:忽略时区。时间戳本身是 UTC 基准的秒数,转换成本地时间时要用正确的时区偏移。我早期直接用服务器的默认时区输出,导致导出的记录整体偏移了几个小时,跟客户端界面对不上。修正后专门写了一个统一的转换函数,所有时间输出都走它,避免各处散落硬编码。
7.3 一些不太起眼但很有用的经验
第一,先小样本验证再全量跑。拿一个最旧的、只有一个会话的小分片库做验证,跑通了再上大库。全量跑一次动辄半小时,边跑边改的代价太高。
第二,日志要记到字段级。解析失败时不要只记"第 1234 条失败",要记清楚是哪张表、哪个字段、原始字节的前若干位。这些信息在事后定位时价值极高,而多写这几行日志的成本几乎为零。
第三,输出格式选宽松的中间格式。不要一步到位直接生成最终报表,先落成结构化的中间文件(每条消息一行),需要什么格式再从中间文件转换。这样做的好处是改需求时不用重新解析一遍数据库,几分钟就能产出新格式。
第四,给字段留扩展位。你永远会遇到某个版本多出个不认识的字段。解析脚本里对未知字段采取"原样保留"策略,别丢掉,说不定哪天就要用到。
第五,注意磁盘空间。解密是等体积的,媒体文件还原也是等体积的,一个中等规模的账号搞下来几十 GB 很正常。开工前先看一眼剩余空间,中途磁盘满导致的半成品文件非常难清理。
我个人在反复折腾这套东西的过程中,最大的体会是:数据结构的理解成本远高于代码本身。真正花时间的不是写查询语句,而是搞明白这三个库为什么这么拆、哪些字段之间有隐含的对应关系、哪些看起来冗余的列其实承担着关键作用。把这些想通之后,代码反而写得飞快。所以如果你也要动手,别急着打开编辑器,先花半天时间把表结构和几段样本数据仔仔细细看一遍,后面会省下好几倍的时间。