做状态存储和检索的同学,应该都经历过这种尴尬:节点一宕,重启日志明明打着 recovery completed,状态库的数据也回放完了,但 FTS 索引里最新几条记录就是查不到;或者反过来,状态库里某条事务已经回滚了,索引里却还残留着这条脏数据,用户一搜就搜出已经作废的内容。我在维护内部一套事件消息检索系统 Hermes 时,就因为这个坑连续熬了好几个通宵。后来我把恢复逻辑彻底重构了一遍,核心思路就是:把 FTS 索引恢复和 State DB 状态恢复拆成两条独立的防线,互相兜底、互相对账。这套方案在线上跑了大半年,节点故障恢复的成功率从 92% 提到了 99.8%,RTO 也稳定控制在分钟级。这篇文章就把整个设计从头拆开讲一遍,适合正在做存储、搜索或中间件恢复逻辑的同学参考。1. Hermes 的恢复困局:为什么单条恢复链路必出事1.1 一次典型事故:索引和状态分叉先交代背景。Hermes 不是通用数据库,它是一套带全文检索能力的消息事件存储组件。业务方往里面写入带文本内容的消息记录,Hermes 负责两件事:第一,把消息的状态(已接收、处理中、已完成、已失败)落进 State DB,保证状态可查、可回滚、可恢复;第二,为消息的文本字段构建 FTS 索引,让运营和研发能按关键词快速检索历史消息。正常情况下,两个组件跟随同一条事务链路联动:事务提交后先写 State DB,再异步触发索引更新。问题恰恰出在故障恢复这一步。那次事故是这样的:某台节点在凌晨被强制重启,机器起来后,State DB 的 WAL 回放很顺利,很快打出了 recovery completed。但是 FTS 索引没有走任何恢复流程,直接以旧索引文件对外服务。结果就是:索引停留在重启前某一时刻的水位,State DB 已经回放到最新位置,两边差了大约一万条记录。业务方排查"最近一小时关键词 X 的消息",返回的结果缺了一大截,而且还有几条已经被业务侧标记为失败、连 State DB 里都回滚掉的事务消息残留在索引中。用户看到的是"系统返回了已经被作废的数据",这个性质比单纯丢数据更严重——丢数据最多是查不到,残留脏数据是系统在主动误导用户。1.2 根因:把派生数据当成主数据来恢复复盘那件事,结论其实很朴素:FTS 索引本质上就是 State DB 的派生数据,它的一切内容都应该能从状态库里重算出来。但我当时犯的错是,把索引当成一个独立的数据源来"恢复"——直接用索引自己的旧文件启动,而没有校验它和 State DB 的水位是否对齐。这里引出一个非常重要的认知:派生数据永远不能作为恢复的最终依据,主数据才是。State DB 是 Hermes 的唯一事实来源,FTS 索引只是它的一个可重建视图。恢复时,索引可以滞后,但绝不能超前;可以缺失,但绝不能残留已回滚的数据。滞后最多是"搜不到",用户可以感知为短暂的查询空窗;超前和残留则是"搜出脏数据",业务方完全无法接受。1.3 双重防线到底是什么想清楚根因之后,我重新设计了恢复架构,两条防线各管一摊:第一道防线(追求快):FTS 索引级恢复。基于索引快照加索引增量日志做回放,目标是把索引恢复到最新水位,耗时控制在分钟级。第二道防线(追求稳):State DB 状态级恢复。基于状态快照加 WAL 回放,目标是把状态数据完整、一致地恢复出来。状态恢复完成后,如果索引无法通过增量回放对齐,就用状态库全量重建索引兜底。两条防线不是串行而是协同:正常情况下,两道防线各自恢复各自的,最后做一次水位对账;一旦第一道防线失败,或者对账不过,就触发降级,用第二道防线重新计算索引。后面每一章的细节,都是围绕这个协同关系展开的。2. 第一道防线:FTS 索引的快照制与增量回放2.1 为什么不能每次都全量重建很多人设计索引恢复时,第一反应是"反正是派生数据,直接全量重建不就行了"。这个思路正确,但在 Hermes 的场景下不可行。生产集群里的索引文件有几百 GB,全量重建需要把所有原始消息重新读一遍、分词一遍、建倒排一遍。我实测过,几个亿条的消息量级,全量重建至少四十分钟起步,量级再大一点就要推到小时级。这期间索引完全不可服务,对业务来说就是一场灾难。所以第一道防线的目标非常明确:尽量只处理故障期间新增的那一小部分增量,而不是把历史数据全部重算一遍。全量重建只能作为兜底路径,不能作为常规恢复路径。2.2 索引快照怎么打才安全索引快照是实现增量恢复的基础。我采用的方案是:定期对索引生成快照,而不是实时去快照每个文档。快照内容包含两部分:第一部分是索引段的文件清单,包括倒排段、字典文件、docvalues 等。快照的本质是把当前已提交的段文件固化成一个可整体读取的目录。第二部分是水位标记 watermark,它记录"这份快照的索引数据已经包含到 State DB 的哪一条写操作序号 seq"。这两个部分里,水位标记是快照的灵魂。如果快照只复制文件但没记录水位,恢复时根本不知道这份快照对应到哪一步,后面的增量回放就无从谈起,只能回到全量重建的老路上去。我在这里吃过亏,后面踩坑部分会详细说。生成快照时有一个很容易忽略的细节:快照不是把整个索引目录完整拷贝一遍。Hermes 封装的开源倒排库本身有 commit 机制,每次 commit 会生成新的段文件。生成快照时,我只需要针对"已经 commit 的段"做引用计数和文件复制,正在写入、尚未 commit 的段不允许进入快照。否则快照里混入半成品数据,恢复出来的索引反而是坏的。快照的生成策略我用了两个触发条件,满足任意一个就生成:一是时间策略,每 10 分钟生成一次;二是数量策略,WAL 累计新增超过 20 万条写操作时生成一次。生成后的快照会额外写一份 manifest 文件,内容包括段文件列表、watermark、段级别的 checksum 以及生成时间。manifest 是恢复流程的入口,所有对账和校验都从它开始。2.3 增量回放的实现细节增量回放的输入是索引侧的增量日志。这里我做了一个简化:不单独维护一套索引 oplog,而是直接消费 State DB 的写操作日志,也就是后面会讲到的 WAL 事件流,只取出其中对索引有影响的事件类型:INSERT、UPDATE、DELETE。回放流程是这样的:从快照 manifest 中读取 watermark,假设是 seq=100000。打开状态 WAL,从 seq=100001 开始向后扫描。每读到一条影响索引的事件,就执行对应的索引操作:新增文档、覆盖文档、删除文档。全部消费完成后,把索引 manifest 的 watermark 推进到当前最新 seq,写入新 manifest。触发一次索引 commit,把回放期间的增量段落盘。回放过程的幂等性设计是重中之重。FTS 索引的文档 ID 直接用状态库主键,这样同一文档被重复回放时是覆盖而非叠加,天然幂等。DELETE 事件执行的是按文档 ID 的删除,同样可以安全重复执行。我要求回放线程严格按 seq 顺序消费,绝不并发处理同一文档的多个版本,避免出现旧版本覆盖新版本的竞态。性能上,回放期间我有意关闭了实时搜索的 refresh 操作,让增量文档先在内存缓冲区里攒着,攒到一定数量,比如 10 万条或 256MB,再批量写入索引段。实测增量回放的速度大约是每秒 8 万到 12 万条事件,一分钟可以回放几百万条,完全覆盖常规故障窗口内的数据量。2.4 回放期间的对外行为约定增量回放期间,索引并不是完全不可用,而是降级为"只读历史"模式:已包含在快照水位里的旧数据可以正常搜索;水位之后的新数据暂时搜不到,查询接口会带一个recover_in_progress=true的标记,业务方看到这个标记可以选择等待或走其他查询通道。这样做的好处是把恢复期间的可用性损失降到最低。绝大多数查询都是查历史数据,只有实时性要求极高的那部分查询会受影响,而这部分业务通常有自己的兜底方案。这个约定坚持了很久,实际效果比我最初设想的"直接拒绝所有查询"要平滑很多。3. 第二道防线:State DB 的 WAL 回放与状态校验3.1 State DB 的底层结构State DB 是 Hermes 的主数据存储,采用"内存哈希表 + WAL + 周期快照"的结构。内存哈希表保存所有消息状态的当前值,每个 value 带一个版本号,版本号实际就是全局递增的 seq。所有写操作必须先追加 WAL 并 fsync 成功,才算事务提交,然后才更新内存哈希表。WAL 的记录格式我设计了四段结构:[txn_begin | txn_id | op_count | batch_checksum] [op_insert | key | value | seq | record_checksum] [op_update | key | value | seq | record_checksum] [op_delete | key | tombstone | seq | record_checksum] [txn_end | txn_id | batch_checksum]每条操作记录都包含 seq、key、value 和记录级 checksum,每个事务批次则包含批次级 checksum。这个双层 checksum 是后面故障检测的基础,也是安全截断策略能够成立的前提。快照则是对内存哈希表某个时刻的完整落盘,快照文件自带一个尾部水位字段,记录"这份快照已经包含到 seq=多少"。这个水位和 WAL 的起始 seq 必须无缝衔接,中间既不能有空档,也不允许重叠,否则对账逻辑会直接判定恢复失败。3.2 重启后的回放流程节点重启后,State DB 的恢复流程分四步:加载最近一次状态快照,读取尾部水位snapshot_seq。定位 WAL 文件中第一条seq > snapshot_seq的记录。从该位置顺序读取 WAL,逐条应用到内存哈希表,跳过所有已经包含在快照中的 seq。应用完 WAL 最后一条完整记录后,将内存状态标记为可读,开始接受读写请求。整个回放过程必须单线程顺序执行,这是为了保持事务顺序语义。我见过有人为了加快回放把 WAL 改成并发应用,结果事务 A 和事务 B 的先后依赖被打破,状态出现幻读。State DB 的回放和索引回放不一样,它不允许并发,宁可慢一点也要保证顺序正确。3.3 三层校验机制针对 WAL 可能出现的各种损坏场景,我实现了三层校验:校验层级检查对象失败处理记录级单条操作记录的 checksum定位损坏点,标记该记录之后为不可信批次级单个事务批次完整性丢弃该批事务,从事务块边界继续回放衔接级快照水位与 WAL 起始 seq 的连续性比对失败则回退到上一个完整快照重新回放这里最棘手的是 WAL 尾部的 torn write,也就是撕裂写。断电瞬间,磁盘上最后一条 WAL 记录可能只写了一半,checksum 校验必然失败。一开始我遇到这种情况直接报错并且拒绝启动,结果运维只能手动删 WAL,风险很大。后来改为安全截断处理:校验失败的尾部记录直接截断丢弃。因为事务只有写入提交记录并返回成功,才可能被用户感知,只写了一半说明该事务没有被确认提交,丢弃它符合事务语义。3.4 回放失败时的降级策略WAL 回放还有更坏的情况:不是尾部损坏,而是中间某一段文件损坏。这时候安全截断已经不适用了,我设计了三级降级:本地 WAL 中间损坏:尝试从备用副本拉取缺失的 WAL 段文件补齐后回放。没有副本或副本也损坏:回退到上一个完整快照,接受声明过的数据丢失窗口,同时把 FTS 索引水位做相应回退。快照也损坏:这是最坏情况,只能重置该分片,从上游数据源重新消费重建。降级选择的核心原则是:宁可回到更早的已知一致水位,也不要强行拼凑出一个看似可用但内部不一致的状态。这个原则同时约束第二道防线与第一道防线的联动,状态降级到哪个水位,索引就必须跟着回退到哪个水位,这一点在高可用设计里怎么强调都不过分。4. 两条防线的协同:恢复编排与索引对账4.1 四阶段恢复状态机两条恢复机制单独都能工作,但真正保证不出脏数据的是它们之间的编排。我把整个恢复过程定义为一个四阶段状态机:Prepare:冻结写入。主流程先停止接收新的写入请求,记录当前全局最新 seq 作为恢复目标水位。RecoverState:执行第二道防线,State DB 完成快照加载与 WAL 回放,确认最终水位 state_seq。RecoverIndex:执行第一道防线,FTS 索引完成快照加载与增量回放。回放目标水位同样是 state_seq。如果索引回放失败,触发全量重建兜底。VerifyAndSwitch:执行对账校验,全部通过后开放读写。这个状态机的关键点在于,所有阶段都围绕同一个全局水位 state_seq 对齐,而不是各自为政。恢复过程中任何一个阶段失败,状态机都会回退到 Prepare 或直接进入降级分支,不会带着不一致的中间状态对外服务。状态机的状态转换日志我完整保留,每次故障都能复盘出到底卡在哪一步、耗时多少。4.2 对账机制:三路校验对账是 VerifyAndSwitch 阶段的执行体,我设计了三个维度的校验:第一是计数对账。State DB 中所有有效文档的总数应该等于索引文档总数。这里我用了一个很笨但很有效的办法:状态库维护一个"有效文档计数"的原子变量,索引侧统计段内 doc 数,两边比对。数量对不上就说明一定有问题。第二是水位对账。索引 manifest 的 watermark 必须等于 state_seq 才算通过。低于说明差数据,高于说明索引超前,两种情况都算失败。注意索引超前也失败,是因为超前意味着索引里可能出现了状态库中尚不存在的数据,这正是最怕的脏数据形态。第三是抽样对账。按 key 的哈希取模,随机抽取约 1% 的文档,比对状态库中的字段与索引中的字段是否一致,尤其是文本内容。抽样对账主要用来发现"计数一致但内容不一致"的隐蔽问题,这类问题通常不会在数量上暴露出来。对账不通过时,我不会让服务直接上线,而是进入"局部重建"分支:定位到不一致的索引段或分区,只重建那一部分,而不是全量推倒。局部重建的粒度越细,恢复代价越小,这是长时间打磨之后沉淀下来的高效路径。4.3 写入链路和最终一致性的取舍双防线设计绕不开一个问题:实时写入时,状态库和 FTS 索引到底要不要强一致?实际实现中我选择了最终一致,没有做同步双写。原因很现实:如果在事务提交路径上同步重建索引,写入延迟会从亚毫秒级飙升到几十毫秒级,业务无法接受。所以我采用"状态库同步落 WAL、索引异步更新"的模式。但这里有一条铁律:索引只允许滞后,不允许超前。具体落地是:索引事件必须携带对应的 seq,索引在任一时间点的可见水位都不会高于状态库的已提交水位;任何情况下,索引不允许包含状态库中尚未提交的数据。这条铁律保证了即使崩溃,最坏情况只是"差一些记录搜不到",而绝不会出现"搜到已经回滚的数据"。用生活化的类比就是:状态库是账本,FTS 索引是记账之后整理出来的目录卡片。账本必须实时准确,目录卡片可以晚点更新,但绝不能出现在账本里根本不存在的一笔账。4.4 切换对外服务的条件恢复完成之后,我制定了两个阶段的切换策略:先开放只读查询:对账通过后,索引可以先开放历史数据的只读查询,但不允许写入,也不允许实时查询。再开放写入:将新写入的请求重新接入,同时恢复异步索引更新链路。确认第一批索引事件正常消费之后,才算真正完成切换。这个切换策略看起来保守,但非常有效。它的作用是让线上流量一点点压上来,而不是在恢复完成瞬间把所有请求一次性打进来,把隐藏问题全部暴露出来。经过几次故障演练验证,这个策略确实帮我们挡掉过至少一次"恢复成功但索引链路没就绪"的隐形事故。5. 恢复性能调优与关键参数参考5.1 三个性能瓶颈恢复速度主要卡在三个环节,我在调优时逐一做了针对性处理:磁盘顺序读带宽。WAL 回放和索引增量回放都是顺序读,在 SSD 上顺序读和随机读差异不大,但机械盘上差别巨大。建议 WAL 放在独立 SSD,并且与数据文件分开,避免恢复时和正常写入抢 IO。索引构建 CPU。增量回放时的分词和倒排构建是 CPU 密集操作,务必用多核并行,但同时要保证同一文档的版本处理是有序的。我按文档 ID 哈希分桶并行,每个桶内部保持顺序,桶之间互不干扰。fsync 频率。快照和 checkpoint 写入不能太频繁,否则恢复时的磁盘负担会和正常业务写入打架。我控制在每 30 秒一次 checkpoint,既能保证崩溃恢复窗口可控,又不会明显拖慢业务写路径。5.2 关键参数参考参数默认值调整建议状态快照间隔10 分钟数据量大时缩到 5 分钟,减小 WAL 回放长度索引快照间隔10 分钟与状态快照错开,避免同时落盘抢 IOWAL 回放批次1 万条/批批次内顺序应用,批次边界做一次校验索引回放批量10 万条/批批量构建段,减少段数量,降低后续 merge 压力索引 refresh 间隔回放期间禁用恢复过程不对外实时搜索,攒批更划算checkpoint 周期30 秒与快照错峰,防止 IO 抖动这些参数是我在标准测试机上反复压出来的参考值,不同业务的数据模型差别很大,建议在自己的环境里跑一轮恢复基准测试再定值。5.3 一组实测数据参考我拿一套模拟生产环境的缩量版本做了基准测试,规模是单节点约 3 亿条消息,索引文件约 400GB,WAL 约 80GB。结果如下:全量重建索引耗时:约 46 分钟;索引快照加增量回放耗时(故障窗口 30 分钟,增量约 20 万条):约 40 秒;状态快照加 WAL 回放耗时(快照 10 分钟前,WAL 约 300 万条):约 55 秒;整体 RTO(从准备到开放写入):约 2 分钟,其中对账耗时占比不到 10%。40 秒和 46 分钟的差距,就是第一道防线存在的意义。当然,增量回放耗时和增量大小成正比,如果故障窗口特别长,增量达到千万级,回放时间也会相应变长。这时候可以先触发一次索引快照缩短恢复时间,这也是我在线上演练中经常用的一招。另外,恢复过程的进度可观测性一定要做足。我在 Hermes 的状态恢复模块里加了一个内部进度端点,返回已回放 seq、总回放目标 seq、当前耗时和预计剩余时间。故障演练时全靠它判断恢复是否在预期时间内,而不是等到超时才去翻日志。6. 踩坑记录:三次差点翻车的故障演练6.1 坑一:快照与 WAL 之间的空档期第一版实现里,生成状态快照的过程很简单:直接遍历内存哈希表,边遍历边写入快照文件。表面看没问题,但快照生成期间 WAL 还在持续写入。等我快照写完,记录的尾部水位是"遍历开始时"的 seq,而实际上快照文件里已经包含了一部分遍历期间新写入的数据。结果就是水位偏低,恢复时会重复回放已经包含在快照里的数据。大多数操作是幂等的,但 DELETE 事件重复回放会导致文档被错误删除,对索引的破坏是肉眼可见的。解法是给快照打上"开始水位"和"结束水位"两个标记。快照文件包含的,是从开始水位到结束水位之间所有已提交数据;WAL 回放只处理结束水位之后的事件,中间部分靠快照自身保证完整。这个双向水位设计后来也被用到了索引快照上,彻底解决了空档期问题。6.2 坑二:索引回放撞上段合并增量回放期间,后台的索引段合并进程还在运行。某个段文件正在被回放线程读取,合并进程却把它标记为删除,并在落盘后清理了文件句柄。结果回放线程读到一半报文件不存在,恢复直接失败。第一次遇到时我很困惑,文件明明在,怎么读着读着就没了。修法有两个层面:一是回放期间临时冻结后台段合并,这是最简单直接的方案;二是把段删除改成"标记删除加延迟物理删除",确保有读取句柄的段不会被强制清理。现在线上运行的是后者,因为它不会阻塞正常业务的段合并,回放和合并可以共存。6.3 坑三:WAL 尾部撕裂写引发启动阻塞断电模拟测试时发现,WAL 文件最后一条记录只写了一半,checksum 校验失败,恢复流程直接卡住拒绝启动。第一次遇到这个问题,我下意识认为"数据都损坏了,一定要人工介入"。后来把事务语义理清楚才明白:WAL 尾部撕裂只影响"尚未确认提交"的事务,业务从未收到提交确认,丢掉它们不会产生不一致。所以现在的处理方式是:识别到尾部半条记录就安全截断,并记录一条degraded_recovery事件;同时把索引水位回退到截断点,保证两侧仍然对齐。这个逻辑上线之后,断电故障的恢复成功率从 92% 提升到了 99.8%。6.4 故障注入测试怎么做想要双防线真正可靠,必须靠故障注入持续验证。我维护了一套脚本清单,每次改动恢复逻辑都要完整跑一遍:# 模拟进程崩溃 kill -9 $(pgrep hermes-node) # 模拟 WAL 尾部撕裂写 truncate -s -512 $WAL_DIR/hm-wal-000003.log # 模拟索引段文件损坏 dd if=/dev/urandom of=$INDEX_DIR/seg_0042.bin bs=4096 count=1 seek=5 conv=notrunc除了这三种,还有磁盘限流模拟 IO 故障、人为删除快照文件、制造副本不同步等极端场景。每次注入后都要验证三件事:服务能否在目标 RTO 内完成恢复;恢复后查询结果是否与注入前一致,允许声明过的丢失窗口;对账日志中是否记录了完整的降级链路。这套故障注入清单现在已经成为发布流程的一部分,任何改动只要能通过这条清单,我对双防线在真实故障下的表现就基本有信心。整套方案做下来,我自己最大的体会是:恢复逻辑不是一次写出来的,而是靠故障演练不断"养"出来的。第一版上线的头一个月,每一次演练几乎都会暴露新的边界问题,上面三个坑只是其中印象最深的。另外还有一个建议送给正在做类似系统的同学:把对账逻辑做成线上常驻的巡检任务,而不是只在恢复时跑一次。双防线最大的价值不只体现在恢复那一刻,更体现在日常运行中能持续发现索引和状态之间的细微偏差。等真出事的时候,你对整个系统的信心是完全不一样的。
做状态存储和检索的同学,应该都经历过这种尴尬:节点一宕,重启日志明明打着 recovery completed,状态库的数据也回放完了,但 FTS 索引里最新几条记录就是查不到;或者反过来,状态库里某条事务已经回滚了,索引里却还残留着这条脏数据,用户一搜就搜出已经作废的内容。我在维护内部一套事件消息检索系统 Hermes 时,就因为这个坑连续熬了好几个通宵。后来我把恢复逻辑彻底重构了一遍,核心思路就是:把 FTS 索引恢复和 State DB 状态恢复拆成两条独立的防线,互相兜底、互相对账。这套方案在线上跑了大半年,节点故障恢复的成功率从 92% 提到了 99.8%,RTO 也稳定控制在分钟级。这篇文章就把整个设计从头拆开讲一遍,适合正在做存储、搜索或中间件恢复逻辑的同学参考。1. Hermes 的恢复困局:为什么单条恢复链路必出事1.1 一次典型事故:索引和状态分叉先交代背景。Hermes 不是通用数据库,它是一套带全文检索能力的消息事件存储组件。业务方往里面写入带文本内容的消息记录,Hermes 负责两件事:第一,把消息的状态(已接收、处理中、已完成、已失败)落进 State DB,保证状态可查、可回滚、可恢复;第二,为消息的文本字段构建 FTS 索引,让运营和研发能按关键词快速检索历史消息。正常情况下,两个组件跟随同一条事务链路联动:事务提交后先写 State DB,再异步触发索引更新。问题恰恰出在故障恢复这一步。那次事故是这样的:某台节点在凌晨被强制重启,机器起来后,State DB 的 WAL 回放很顺利,很快打出了 recovery completed。但是 FTS 索引没有走任何恢复流程,直接以旧索引文件对外服务。结果就是:索引停留在重启前某一时刻的水位,State DB 已经回放到最新位置,两边差了大约一万条记录。业务方排查"最近一小时关键词 X 的消息",返回的结果缺了一大截,而且还有几条已经被业务侧标记为失败、连 State DB 里都回滚掉的事务消息残留在索引中。用户看到的是"系统返回了已经被作废的数据",这个性质比单纯丢数据更严重——丢数据最多是查不到,残留脏数据是系统在主动误导用户。1.2 根因:把派生数据当成主数据来恢复复盘那件事,结论其实很朴素:FTS 索引本质上就是 State DB 的派生数据,它的一切内容都应该能从状态库里重算出来。但我当时犯的错是,把索引当成一个独立的数据源来"恢复"——直接用索引自己的旧文件启动,而没有校验它和 State DB 的水位是否对齐。这里引出一个非常重要的认知:派生数据永远不能作为恢复的最终依据,主数据才是。State DB 是 Hermes 的唯一事实来源,FTS 索引只是它的一个可重建视图。恢复时,索引可以滞后,但绝不能超前;可以缺失,但绝不能残留已回滚的数据。滞后最多是"搜不到",用户可以感知为短暂的查询空窗;超前和残留则是"搜出脏数据",业务方完全无法接受。1.3 双重防线到底是什么想清楚根因之后,我重新设计了恢复架构,两条防线各管一摊:第一道防线(追求快):FTS 索引级恢复。基于索引快照加索引增量日志做回放,目标是把索引恢复到最新水位,耗时控制在分钟级。第二道防线(追求稳):State DB 状态级恢复。基于状态快照加 WAL 回放,目标是把状态数据完整、一致地恢复出来。状态恢复完成后,如果索引无法通过增量回放对齐,就用状态库全量重建索引兜底。两条防线不是串行而是协同:正常情况下,两道防线各自恢复各自的,最后做一次水位对账;一旦第一道防线失败,或者对账不过,就触发降级,用第二道防线重新计算索引。后面每一章的细节,都是围绕这个协同关系展开的。2. 第一道防线:FTS 索引的快照制与增量回放2.1 为什么不能每次都全量重建很多人设计索引恢复时,第一反应是"反正是派生数据,直接全量重建不就行了"。这个思路正确,但在 Hermes 的场景下不可行。生产集群里的索引文件有几百 GB,全量重建需要把所有原始消息重新读一遍、分词一遍、建倒排一遍。我实测过,几个亿条的消息量级,全量重建至少四十分钟起步,量级再大一点就要推到小时级。这期间索引完全不可服务,对业务来说就是一场灾难。所以第一道防线的目标非常明确:尽量只处理故障期间新增的那一小部分增量,而不是把历史数据全部重算一遍。全量重建只能作为兜底路径,不能作为常规恢复路径。2.2 索引快照怎么打才安全索引快照是实现增量恢复的基础。我采用的方案是:定期对索引生成快照,而不是实时去快照每个文档。快照内容包含两部分:第一部分是索引段的文件清单,包括倒排段、字典文件、docvalues 等。快照的本质是把当前已提交的段文件固化成一个可整体读取的目录。第二部分是水位标记 watermark,它记录"这份快照的索引数据已经包含到 State DB 的哪一条写操作序号 seq"。这两个部分里,水位标记是快照的灵魂。如果快照只复制文件但没记录水位,恢复时根本不知道这份快照对应到哪一步,后面的增量回放就无从谈起,只能回到全量重建的老路上去。我在这里吃过亏,后面踩坑部分会详细说。生成快照时有一个很容易忽略的细节:快照不是把整个索引目录完整拷贝一遍。Hermes 封装的开源倒排库本身有 commit 机制,每次 commit 会生成新的段文件。生成快照时,我只需要针对"已经 commit 的段"做引用计数和文件复制,正在写入、尚未 commit 的段不允许进入快照。否则快照里混入半成品数据,恢复出来的索引反而是坏的。快照的生成策略我用了两个触发条件,满足任意一个就生成:一是时间策略,每 10 分钟生成一次;二是数量策略,WAL 累计新增超过 20 万条写操作时生成一次。生成后的快照会额外写一份 manifest 文件,内容包括段文件列表、watermark、段级别的 checksum 以及生成时间。manifest 是恢复流程的入口,所有对账和校验都从它开始。2.3 增量回放的实现细节增量回放的输入是索引侧的增量日志。这里我做了一个简化:不单独维护一套索引 oplog,而是直接消费 State DB 的写操作日志,也就是后面会讲到的 WAL 事件流,只取出其中对索引有影响的事件类型:INSERT、UPDATE、DELETE。回放流程是这样的:从快照 manifest 中读取 watermark,假设是 seq=100000。打开状态 WAL,从 seq=100001 开始向后扫描。每读到一条影响索引的事件,就执行对应的索引操作:新增文档、覆盖文档、删除文档。全部消费完成后,把索引 manifest 的 watermark 推进到当前最新 seq,写入新 manifest。触发一次索引 commit,把回放期间的增量段落盘。回放过程的幂等性设计是重中之重。FTS 索引的文档 ID 直接用状态库主键,这样同一文档被重复回放时是覆盖而非叠加,天然幂等。DELETE 事件执行的是按文档 ID 的删除,同样可以安全重复执行。我要求回放线程严格按 seq 顺序消费,绝不并发处理同一文档的多个版本,避免出现旧版本覆盖新版本的竞态。性能上,回放期间我有意关闭了实时搜索的 refresh 操作,让增量文档先在内存缓冲区里攒着,攒到一定数量,比如 10 万条或 256MB,再批量写入索引段。实测增量回放的速度大约是每秒 8 万到 12 万条事件,一分钟可以回放几百万条,完全覆盖常规故障窗口内的数据量。2.4 回放期间的对外行为约定增量回放期间,索引并不是完全不可用,而是降级为"只读历史"模式:已包含在快照水位里的旧数据可以正常搜索;水位之后的新数据暂时搜不到,查询接口会带一个recover_in_progress=true的标记,业务方看到这个标记可以选择等待或走其他查询通道。这样做的好处是把恢复期间的可用性损失降到最低。绝大多数查询都是查历史数据,只有实时性要求极高的那部分查询会受影响,而这部分业务通常有自己的兜底方案。这个约定坚持了很久,实际效果比我最初设想的"直接拒绝所有查询"要平滑很多。3. 第二道防线:State DB 的 WAL 回放与状态校验3.1 State DB 的底层结构State DB 是 Hermes 的主数据存储,采用"内存哈希表 + WAL + 周期快照"的结构。内存哈希表保存所有消息状态的当前值,每个 value 带一个版本号,版本号实际就是全局递增的 seq。所有写操作必须先追加 WAL 并 fsync 成功,才算事务提交,然后才更新内存哈希表。WAL 的记录格式我设计了四段结构:[txn_begin | txn_id | op_count | batch_checksum] [op_insert | key | value | seq | record_checksum] [op_update | key | value | seq | record_checksum] [op_delete | key | tombstone | seq | record_checksum] [txn_end | txn_id | batch_checksum]每条操作记录都包含 seq、key、value 和记录级 checksum,每个事务批次则包含批次级 checksum。这个双层 checksum 是后面故障检测的基础,也是安全截断策略能够成立的前提。快照则是对内存哈希表某个时刻的完整落盘,快照文件自带一个尾部水位字段,记录"这份快照已经包含到 seq=多少"。这个水位和 WAL 的起始 seq 必须无缝衔接,中间既不能有空档,也不允许重叠,否则对账逻辑会直接判定恢复失败。3.2 重启后的回放流程节点重启后,State DB 的恢复流程分四步:加载最近一次状态快照,读取尾部水位snapshot_seq。定位 WAL 文件中第一条seq > snapshot_seq的记录。从该位置顺序读取 WAL,逐条应用到内存哈希表,跳过所有已经包含在快照中的 seq。应用完 WAL 最后一条完整记录后,将内存状态标记为可读,开始接受读写请求。整个回放过程必须单线程顺序执行,这是为了保持事务顺序语义。我见过有人为了加快回放把 WAL 改成并发应用,结果事务 A 和事务 B 的先后依赖被打破,状态出现幻读。State DB 的回放和索引回放不一样,它不允许并发,宁可慢一点也要保证顺序正确。3.3 三层校验机制针对 WAL 可能出现的各种损坏场景,我实现了三层校验:校验层级检查对象失败处理记录级单条操作记录的 checksum定位损坏点,标记该记录之后为不可信批次级单个事务批次完整性丢弃该批事务,从事务块边界继续回放衔接级快照水位与 WAL 起始 seq 的连续性比对失败则回退到上一个完整快照重新回放这里最棘手的是 WAL 尾部的 torn write,也就是撕裂写。断电瞬间,磁盘上最后一条 WAL 记录可能只写了一半,checksum 校验必然失败。一开始我遇到这种情况直接报错并且拒绝启动,结果运维只能手动删 WAL,风险很大。后来改为安全截断处理:校验失败的尾部记录直接截断丢弃。因为事务只有写入提交记录并返回成功,才可能被用户感知,只写了一半说明该事务没有被确认提交,丢弃它符合事务语义。3.4 回放失败时的降级策略WAL 回放还有更坏的情况:不是尾部损坏,而是中间某一段文件损坏。这时候安全截断已经不适用了,我设计了三级降级:本地 WAL 中间损坏:尝试从备用副本拉取缺失的 WAL 段文件补齐后回放。没有副本或副本也损坏:回退到上一个完整快照,接受声明过的数据丢失窗口,同时把 FTS 索引水位做相应回退。快照也损坏:这是最坏情况,只能重置该分片,从上游数据源重新消费重建。降级选择的核心原则是:宁可回到更早的已知一致水位,也不要强行拼凑出一个看似可用但内部不一致的状态。这个原则同时约束第二道防线与第一道防线的联动,状态降级到哪个水位,索引就必须跟着回退到哪个水位,这一点在高可用设计里怎么强调都不过分。4. 两条防线的协同:恢复编排与索引对账4.1 四阶段恢复状态机两条恢复机制单独都能工作,但真正保证不出脏数据的是它们之间的编排。我把整个恢复过程定义为一个四阶段状态机:Prepare:冻结写入。主流程先停止接收新的写入请求,记录当前全局最新 seq 作为恢复目标水位。RecoverState:执行第二道防线,State DB 完成快照加载与 WAL 回放,确认最终水位 state_seq。RecoverIndex:执行第一道防线,FTS 索引完成快照加载与增量回放。回放目标水位同样是 state_seq。如果索引回放失败,触发全量重建兜底。VerifyAndSwitch:执行对账校验,全部通过后开放读写。这个状态机的关键点在于,所有阶段都围绕同一个全局水位 state_seq 对齐,而不是各自为政。恢复过程中任何一个阶段失败,状态机都会回退到 Prepare 或直接进入降级分支,不会带着不一致的中间状态对外服务。状态机的状态转换日志我完整保留,每次故障都能复盘出到底卡在哪一步、耗时多少。4.2 对账机制:三路校验对账是 VerifyAndSwitch 阶段的执行体,我设计了三个维度的校验:第一是计数对账。State DB 中所有有效文档的总数应该等于索引文档总数。这里我用了一个很笨但很有效的办法:状态库维护一个"有效文档计数"的原子变量,索引侧统计段内 doc 数,两边比对。数量对不上就说明一定有问题。第二是水位对账。索引 manifest 的 watermark 必须等于 state_seq 才算通过。低于说明差数据,高于说明索引超前,两种情况都算失败。注意索引超前也失败,是因为超前意味着索引里可能出现了状态库中尚不存在的数据,这正是最怕的脏数据形态。第三是抽样对账。按 key 的哈希取模,随机抽取约 1% 的文档,比对状态库中的字段与索引中的字段是否一致,尤其是文本内容。抽样对账主要用来发现"计数一致但内容不一致"的隐蔽问题,这类问题通常不会在数量上暴露出来。对账不通过时,我不会让服务直接上线,而是进入"局部重建"分支:定位到不一致的索引段或分区,只重建那一部分,而不是全量推倒。局部重建的粒度越细,恢复代价越小,这是长时间打磨之后沉淀下来的高效路径。4.3 写入链路和最终一致性的取舍双防线设计绕不开一个问题:实时写入时,状态库和 FTS 索引到底要不要强一致?实际实现中我选择了最终一致,没有做同步双写。原因很现实:如果在事务提交路径上同步重建索引,写入延迟会从亚毫秒级飙升到几十毫秒级,业务无法接受。所以我采用"状态库同步落 WAL、索引异步更新"的模式。但这里有一条铁律:索引只允许滞后,不允许超前。具体落地是:索引事件必须携带对应的 seq,索引在任一时间点的可见水位都不会高于状态库的已提交水位;任何情况下,索引不允许包含状态库中尚未提交的数据。这条铁律保证了即使崩溃,最坏情况只是"差一些记录搜不到",而绝不会出现"搜到已经回滚的数据"。用生活化的类比就是:状态库是账本,FTS 索引是记账之后整理出来的目录卡片。账本必须实时准确,目录卡片可以晚点更新,但绝不能出现在账本里根本不存在的一笔账。4.4 切换对外服务的条件恢复完成之后,我制定了两个阶段的切换策略:先开放只读查询:对账通过后,索引可以先开放历史数据的只读查询,但不允许写入,也不允许实时查询。再开放写入:将新写入的请求重新接入,同时恢复异步索引更新链路。确认第一批索引事件正常消费之后,才算真正完成切换。这个切换策略看起来保守,但非常有效。它的作用是让线上流量一点点压上来,而不是在恢复完成瞬间把所有请求一次性打进来,把隐藏问题全部暴露出来。经过几次故障演练验证,这个策略确实帮我们挡掉过至少一次"恢复成功但索引链路没就绪"的隐形事故。5. 恢复性能调优与关键参数参考5.1 三个性能瓶颈恢复速度主要卡在三个环节,我在调优时逐一做了针对性处理:磁盘顺序读带宽。WAL 回放和索引增量回放都是顺序读,在 SSD 上顺序读和随机读差异不大,但机械盘上差别巨大。建议 WAL 放在独立 SSD,并且与数据文件分开,避免恢复时和正常写入抢 IO。索引构建 CPU。增量回放时的分词和倒排构建是 CPU 密集操作,务必用多核并行,但同时要保证同一文档的版本处理是有序的。我按文档 ID 哈希分桶并行,每个桶内部保持顺序,桶之间互不干扰。fsync 频率。快照和 checkpoint 写入不能太频繁,否则恢复时的磁盘负担会和正常业务写入打架。我控制在每 30 秒一次 checkpoint,既能保证崩溃恢复窗口可控,又不会明显拖慢业务写路径。5.2 关键参数参考参数默认值调整建议状态快照间隔10 分钟数据量大时缩到 5 分钟,减小 WAL 回放长度索引快照间隔10 分钟与状态快照错开,避免同时落盘抢 IOWAL 回放批次1 万条/批批次内顺序应用,批次边界做一次校验索引回放批量10 万条/批批量构建段,减少段数量,降低后续 merge 压力索引 refresh 间隔回放期间禁用恢复过程不对外实时搜索,攒批更划算checkpoint 周期30 秒与快照错峰,防止 IO 抖动这些参数是我在标准测试机上反复压出来的参考值,不同业务的数据模型差别很大,建议在自己的环境里跑一轮恢复基准测试再定值。5.3 一组实测数据参考我拿一套模拟生产环境的缩量版本做了基准测试,规模是单节点约 3 亿条消息,索引文件约 400GB,WAL 约 80GB。结果如下:全量重建索引耗时:约 46 分钟;索引快照加增量回放耗时(故障窗口 30 分钟,增量约 20 万条):约 40 秒;状态快照加 WAL 回放耗时(快照 10 分钟前,WAL 约 300 万条):约 55 秒;整体 RTO(从准备到开放写入):约 2 分钟,其中对账耗时占比不到 10%。40 秒和 46 分钟的差距,就是第一道防线存在的意义。当然,增量回放耗时和增量大小成正比,如果故障窗口特别长,增量达到千万级,回放时间也会相应变长。这时候可以先触发一次索引快照缩短恢复时间,这也是我在线上演练中经常用的一招。另外,恢复过程的进度可观测性一定要做足。我在 Hermes 的状态恢复模块里加了一个内部进度端点,返回已回放 seq、总回放目标 seq、当前耗时和预计剩余时间。故障演练时全靠它判断恢复是否在预期时间内,而不是等到超时才去翻日志。6. 踩坑记录:三次差点翻车的故障演练6.1 坑一:快照与 WAL 之间的空档期第一版实现里,生成状态快照的过程很简单:直接遍历内存哈希表,边遍历边写入快照文件。表面看没问题,但快照生成期间 WAL 还在持续写入。等我快照写完,记录的尾部水位是"遍历开始时"的 seq,而实际上快照文件里已经包含了一部分遍历期间新写入的数据。结果就是水位偏低,恢复时会重复回放已经包含在快照里的数据。大多数操作是幂等的,但 DELETE 事件重复回放会导致文档被错误删除,对索引的破坏是肉眼可见的。解法是给快照打上"开始水位"和"结束水位"两个标记。快照文件包含的,是从开始水位到结束水位之间所有已提交数据;WAL 回放只处理结束水位之后的事件,中间部分靠快照自身保证完整。这个双向水位设计后来也被用到了索引快照上,彻底解决了空档期问题。6.2 坑二:索引回放撞上段合并增量回放期间,后台的索引段合并进程还在运行。某个段文件正在被回放线程读取,合并进程却把它标记为删除,并在落盘后清理了文件句柄。结果回放线程读到一半报文件不存在,恢复直接失败。第一次遇到时我很困惑,文件明明在,怎么读着读着就没了。修法有两个层面:一是回放期间临时冻结后台段合并,这是最简单直接的方案;二是把段删除改成"标记删除加延迟物理删除",确保有读取句柄的段不会被强制清理。现在线上运行的是后者,因为它不会阻塞正常业务的段合并,回放和合并可以共存。6.3 坑三:WAL 尾部撕裂写引发启动阻塞断电模拟测试时发现,WAL 文件最后一条记录只写了一半,checksum 校验失败,恢复流程直接卡住拒绝启动。第一次遇到这个问题,我下意识认为"数据都损坏了,一定要人工介入"。后来把事务语义理清楚才明白:WAL 尾部撕裂只影响"尚未确认提交"的事务,业务从未收到提交确认,丢掉它们不会产生不一致。所以现在的处理方式是:识别到尾部半条记录就安全截断,并记录一条degraded_recovery事件;同时把索引水位回退到截断点,保证两侧仍然对齐。这个逻辑上线之后,断电故障的恢复成功率从 92% 提升到了 99.8%。6.4 故障注入测试怎么做想要双防线真正可靠,必须靠故障注入持续验证。我维护了一套脚本清单,每次改动恢复逻辑都要完整跑一遍:# 模拟进程崩溃 kill -9 $(pgrep hermes-node) # 模拟 WAL 尾部撕裂写 truncate -s -512 $WAL_DIR/hm-wal-000003.log # 模拟索引段文件损坏 dd if=/dev/urandom of=$INDEX_DIR/seg_0042.bin bs=4096 count=1 seek=5 conv=notrunc除了这三种,还有磁盘限流模拟 IO 故障、人为删除快照文件、制造副本不同步等极端场景。每次注入后都要验证三件事:服务能否在目标 RTO 内完成恢复;恢复后查询结果是否与注入前一致,允许声明过的丢失窗口;对账日志中是否记录了完整的降级链路。这套故障注入清单现在已经成为发布流程的一部分,任何改动只要能通过这条清单,我对双防线在真实故障下的表现就基本有信心。整套方案做下来,我自己最大的体会是:恢复逻辑不是一次写出来的,而是靠故障演练不断"养"出来的。第一版上线的头一个月,每一次演练几乎都会暴露新的边界问题,上面三个坑只是其中印象最深的。另外还有一个建议送给正在做类似系统的同学:把对账逻辑做成线上常驻的巡检任务,而不是只在恢复时跑一次。双防线最大的价值不只体现在恢复那一刻,更体现在日常运行中能持续发现索引和状态之间的细微偏差。等真出事的时候,你对整个系统的信心是完全不一样的。