凌晨两点半,某即时通信平台的核心服务在发布过程中被强制重启,启动日志里赫然写着“recovery completed”,所有人都松了口气。结果一个小时后业务方找过来:几个小时前的关键词检索查不到结果,部分消费者的消费位点凭空回退了几万条。原因很快定位到——恢复流程只重建了 FTS 索引,却没有先把 State DB 里的状态事务完整回放;索引先起来了,状态库却还停在旧水位,两层恢复没有对齐。那次事故之后,我把 Hermes State DB 的恢复流程彻底重做了一遍,核心思路就是标题里这五个字:双重防线。
这篇文章想跟你聊的,就是这条双重防线到底怎么设计、每一步怎么实操、哪些坑我替你先踩过了。无论你是正在给消息中间件做状态持久化,还是在自研检索系统时遇到了“索引能起来但数据对不上”的诡异问题,这篇都可以直接参考。我会把 FTS 索引恢复和 State DB 状态恢复拆开讲,再讲清楚两者如何对齐,最后附上我整理的异常排查速查表。
1. 先看全局:State DB 和 FTS 索引在系统里到底各管什么
1.1 两个很容易被混淆的存储角色
在动手写恢复代码之前,你得先在心里画清楚一张数据流向图。Hermes 这套体系里,消息本体存在主存储里,而 State DB 是一个嵌入式持久化存储,专门记录消费者游标、任务状态、消息确认记录、分组内成员关系这类“正在发生变化”的数据。FTS 索引则是另一条支线,它接收消息内容,构建倒排索引,让用户能按关键词快速检索历史消息。
这两者最大的区别在于:State DB 是权威状态源,它丢了,整个系统的消费语义就乱了套;FTS 是派生数据源,它丢了,顶多检索暂时不可用,但可以通过主存储重新构建。很多人恢复时只盯着“哪个服务报错了”,而没有一个全局视角,结果就是状态库回放了一半、索引却先切了流量,两边永远对不上。
1.2 为什么“双重防线”是必要的,而不是过度设计
有些同行问过我:FTS 索引既然能从主存储重建,那直接把索引删了重建不就行了?为什么要搞得这么复杂?
这里有个现实问题:重建索引的时间窗口。生产环境里一个分片可能有几十 GB 的索引数据,全量重建动不动就是几十分钟,期间检索服务完全不可用。所以你需要第一道防线,让 FTS 索引“尽量不重建、优先局部修复”。而 State DB 这边恰恰相反,状态数据一旦丢失,不是重建能解决的,必须靠 WAL 回放把已提交事务恢复到内存和磁盘。两条线的恢复目标不同、手段不同,自然需要两套防线。
我的经验是:恢复流程设计之初就要分清“哪些数据可以丢、哪些数据不能丢”。可重建的数据走“检测-修复-重建”的轻量路径,不可重建的数据走“备份-WAL-回放-校验”的严格路径。标题里的双重防线,本质上就是这两条路径的组合。
2. 第一道防线:FTS 索引的损坏识别与重建实操
2.1 索引损坏的三种典型形态,你得能一眼认出来
FTS 索引不是单个文件,而是由多个分段文件、字典文件、倒排列表文件共同组成的集合。我实际工作中遇到的索引损坏,绝大多数不是“文件消失了”这种大事故,而是下面三种小问题:
- 分段文件尾部不完整:进程被 kill 的时候,刚好有数据写在段文件尾部缓冲里没落盘,导致文件长度比正常短一截。
- 字典文件与倒排列表对不上:索引的字典部分已经提交了新关键词,但对应的倒排列表因为磁盘写入顺序问题被截断,查询时轻则少结果,重则直接报段异常。
- 版本号漂移:索引引擎升级后,旧索引文件没有被自动迁移,服务起来之后读到了不兼容的版本结构。
所以恢复的第一步不是“重建”,而是“确认到底坏了什么”。我在生产环境里用三层检查:启动时引擎自检、定期抽样查询、以及恢复前的特征比对。启动自检会扫描每个分段的元数据尾部标记;抽样查询是拿几条已知 ID 的消息去索引里查,能查到说明主链路是通的;特征比对则是比较索引里记录的文档总数和 State DB 里的实际记录数,数目对不上基本可以断定索引有缺失。
2.2 索引重建完整流程:摘流量、建临时索引、原子切换
一旦确认索引坏了需要重建,别直接删目录。我踩过一次大坑:某同学把索引目录直接 rm 掉,然后触发全量重建,重建期间检索接口还是对外服务的,查询落到一个空索引上,结果全部落空,线上 5 分钟无结果。正确姿势是先把 FTS 的流量摘掉,再走如下流程:
- 标记该分片索引状态为 rebuilding,从检索路由中摘除。
- 从主存储导出需要索引的时间窗口内的消息数据,按分片、按时间分批拉取。
- 使用批量写入接口构建临时索引目录,注意控制批量大小和线程数,避免打爆主存储。
- 临时索引构建完成后,做一次计数对账,确认文档数与源数据一致。
- 原子切换:把检索请求指向新索引目录,同时清理旧索引文件。
这里的关键参数我建议这样设置:批量大小 500 到 1000 条一批,线程数不超过 4 个同时写,每次提交后强制刷新索引段。别为了快一次性开 16 个线程,实测下来 FTS 引擎的写入锁竞争和磁盘 IO 会成为瓶颈,速度反而上不去,还容易触发段合并风暴。
2.3 一份可以参考的恢复命令参数示例
Hmm,让我设计一份伪代码性质的命令参考,方便你落地时对照:
# 索引健康检查 hermes-ftsctl check --data-dir /data/hermes/fts --shard shard-03 # 局部修复:仅重建校验失败的段 hermes-ftsctl repair --data-dir /data/hermes/fts --shard shard-03 --scope local # 全量重建:摘流量后执行 hermes-ftsctl rebuild --data-dir /data/hermes/fts \ --source hermes-main-store --shard shard-03 \ --bulk-size 800 --threads 4 --atomic-switch true注意,check 命令扫出来如果只是单个段坏了,优先用局部修复,把坏段从映射表里剔除然后从主存储补拉对应时间段的数据;全量重建是兜底方案。另外我强烈建议在 rebuild 命令里加上 --atomic-switch true,这样切换是原子的,不会让查询在切换瞬间读到半个新索引。
3. 第二道防线:State DB 的 WAL 回放与状态恢复
3.1 状态库的写入路径与持久化策略,决定你恢复的上限
State DB 的恢复能不能做好,很大程度上取决于平时写入路径设计得规不规范。Hermes 的状态写入是标准的“内存状态 + 预写日志”模式:业务变更先追加一条 WAL 记录,每条记录带全局自增序号和 CRC 校验,然后更新内存状态;WAL 在满足一定条件时做一次快照,把全量状态落盘。我见过不少团队为了性能跳过 WAL 直接定期刷内存,结果崩溃后状态全靠猜,这个我强烈不建议。
关键设计点是 WAL 文件什么时候 fsync。如果每次写入都立刻 fsync,性能确实会掉,但恢复时几乎不会有丢失窗口;如果完全攒一批才落盘,性能好但崩溃时会丢最近几秒数据。我采用的折中方案是:普通业务消息确认走组提交,攒 200 条或每隔 5ms 刷一次盘;涉及消费位点提交这种关键状态,单独走立即 fsync 的快路径。恢复能力的天花板,在写入那一刻就已经定死了,这一点想清楚很重要。
3.2 WAL 回放过程的核心细节:别让半截事务毁掉整个恢复
恢复的流程看起来不复杂,从最近一个快照开始,把后续 WAL 依次回放,直到日志尾部。但这里有个极易出问题的地方:日志尾部可能躺着一条写到一半的记录。崩溃可能发生在记录写了一半的时候,如果回放时不管三七二十一直接执行,状态库就会进入一个事务上不一致的状态。
我的回放逻辑是这样写的:
- 先扫描 WAL 目录,确认快照文件与 WAL 文件的时间序关系。
- 从快照的全局序号 N 起,读取后续所有 WAL 记录。
- 逐条校验 CRC。CRC 不通过,且该条是完整事务的起始记录,则截断到该条之前;如果 CRC 不过的是一条事务的中间记录,则把整个事务回滚,不应用其中任何一条。
- 回放完成后,把内存状态中的脏页强制写入,再落一个新的快照。
- 更新恢复标记,把 State DB 标记为 ready。
这里最容易写错的是第三步。我见过一个实现:CRC 校验失败就静默跳过那条记录,继续往后回放。如果跳过的只是一条幂等的状态更新还好,但要是跳的是一条“事务开始”的半截记录,后面所有程依赖它的事务都会错位。正确做法是遇到尾部 CRC 错误立即停止扫描,把错误点之前的数据视为完整恢复。
3.3 与 FTS 恢复的先后顺序为什么不能颠倒,这是我踩过坑才懂的
这是整篇文章里我最想强调的一点。第一版恢复流程里,我是先做了 FTS 索引重建,再去做 State DB 回放,理由是“检索恢复得快,业务感知小”。结果在数据对账环节发现:索引里已经包含了一部分消息的正文,但 State DB 里对应消费者的游标还没恢复,消费任务重新拉取这些消息时,会把它当成新消息再处理一遍,下游产生了一批重复数据。
后来我把顺序彻底改了:先恢复 State DB,等状态库回到一致水位且对业务确认“状态已就绪”之后,再恢复或者校验 FTS 索引。为什么必须这样?因为状态库是权威源头,索引只是它的派生影子。影子应该跟着实体走,而不是实体跟着影子走。如果先恢复了索引,状态恢复却晚了几分钟,这个时间窗口里检索结果和消费状态就是错位的。
实操顺序总结就是:加载快照 -> 回放 WAL -> 状态就绪 -> 再触发 FTS 的校验和重建 -> 最后做两者水位对齐校验。我在脚本里加了一个依赖控制,State DB 没有标记为 ready,FTS 的重建步骤根本不会被拉起,从机制上防住顺序错误的可能性。
4. 双防线之后的最后一道闸门:一致性对账
4.1 对账为什么不能省:两个独立恢复流程天然有水位差
很多团队做到“State DB 回放成功 + FTS 索引重建完成”就觉得大功告成了,然后恢复后的第一天不出事,第二天检索结果开始漏数据。原因往往是:两条恢复链路是异步并行跑的,它们各自完成的时间点不一致,导致索引里的数据范围和状态库里的水位没有严格对应。
对账要解决的就是这个问题。我在 Hermes 里给每批写入 FTS 的文档都带上一个全局版本号,这个版本号就是从 State DB 的 WAL 序号里取出来的。对账时,FTS 索引里每批文档记录自己的起始版本号和结束版本号,State DB 恢复完成后记录自己的当前版本号,用三个数字就能判断是否一致:索引的结束版本是否包含了状态库当前版本之前的所有数据,以及两者之间是否存在断裂。
4.2 对账工具的设计要点:从计数对账到抽样对账
我实现了一个两层对账工具,第一层是快速计数,第二层是抽样校验。快速计数阶段,只比较 State DB 的记录总数和 FTS 索引里的文档总数、最大版本号、最小版本号,任何一项不匹配就进入详细对账。详细对账阶段,按版本号区间分段抽样,取 1000 条消息,用消息 ID 去两边分别查一遍,找出只在一边存在的记录。
# 快速对账:对比计数和版本号 hermes-reconcile --fast \ --state-db /data/hermes/state --fts /data/hermes/fts # 详细对账:抽样 1000 条,检查双向一致性 hermes-reconcile --full \ --state-db /data/hermes/state --fts /data/hermes/fts \ --sample-size 1000 --threads 8这个工具我建议做成独立于启动流程的定时任务,恢复后 10 分钟跑一次快速对账,半小时后如果有余力再跑一次详细对账。不要只依赖启动时的单次校验,因为有些异步管道的数据要过一会儿才能完全落到索引里。
5. 常见问题与排查技巧实录
5.1 六个高频故障现场,对应排查办法
我在团队里整理过一份恢复相关的故障排查速查表,这里摘几条最典型的,你恢复时可以直接照方抓药:
| 故障现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 启动报 WAL 尾部 CRC 错误 | 崩溃时正好写坏最后一条记录 | 查看 WAL 文件大小与记录偏移 | 截断到上一条完整记录,重放 |
| FTS 查询结果数比实际少 | 分段文件未完整合并或丢段 | 比较索引文档计数与 State DB 记录数 | 局部重建坏段 |
| 状态恢复成功但消费位点回退 | 快照选取过早,或部分 WAL 未回放 | 检查快照序号与 WAL 起始序号 | 重新指定最新快照回放 |
| 重建 FTS 期间主存储 IO 飙高 | 批量线程数过大 | 查看主存储 IO 曲线 | 调低线程数和批量大小 |
| 索引切换后查询报段异常 | 新旧索引目录切换不是原子的 | 查看路由表是否指向旧段 | 使用原子切换,重建后清理旧段 |
| 对账时两边计数一致但抽样有缺失 | 文档内容相同但版本号不对应 | 检查写入管道是否漏填版本号 | 重新生成版本号后重建索引 |
5.2 几条日志关键字,帮你快速定位恢复卡在哪一步
恢复流程跑到一半不动了,这是最考验人的时刻。我喜欢在关键节点打上明确的日志标记,配合业务系统自带的链路追踪,能在 10 分钟内定位问题:
- 恢复启动阶段:看
snapshot loaded和wal replay start之间的时间差,如果加载快照卡住,大概率是快照文件被损坏或磁盘 IO 异常。 - WAL 回放阶段:看
wal replay progress的序号变化,如果长时间停留在同一个序号,说明某条记录处理异常。此时检查 CPU 和锁等待,偶尔是回放线程与业务线程抢锁。 - FTS 构建阶段:看
fts segment merged和fts switch completed两个标记,如果一直在 merge 但迟迟不 switch,多半是段合并策略参数不合理,比如 merge 阈值设置太低导致合并风暴。
另外,我强烈建议恢复流程支持“断点续跑”。不光是 FTS 重建要能断点续跑,State DB 的 WAL 回放也要能把已应用的序号记下来,避免每次失败都从头来。这点在生产环境里价值极大,索引重建跑到 80% 被机器重启打断,如果没有断点续跑,又得从头拉几个小时的源数据。
恢复这件事,我的几点体会
这套流程从一开始的“先索引后状态”,到现在的“状态优先、索引跟随、最终对账”,中间经历了不止一次线上事故。我个人体会最深的是:恢复流程本身也要有状态机,不能被拆成若干个独立脚本各跑各的。恢复不是几个命令的拼凑,而是一条有先后、有依赖、有校验的完整链路。
最后分享一个小技巧,也是我现在每次操作前必做的一步:无论线上情况多紧急,恢复前先对整个数据目录做一次快照备份。你可以用存储层的快照能力,也可以直接复制目录到另一块盘。很多同事觉得这一步浪费时间,但事实证明,恢复过程中最怕的不是恢复失败,而是恢复到一半发现旧数据已经被改得面目全非,连回滚的余地都没有。有了快照,哪怕恢复方案有问题,你也能把现场还原到备份点,再冷静地修第二版。
如果你也在设计类似的状态恢复链路,我建议先从“能不能通过一条命令完整恢复到一致状态”这个目标倒推设计。假如你的恢复流程需要人工去比对两个系统的水位,那还不够好,把它做成自动化的、有状态的、可断点续跑的流程,然后再上线。