只要跑过 Hermes 状态库这套东西的人,基本都经历过类似的凌晨:节点异常退出,业务请求全打进来,状态库倒是回放到最新进度了,搜索却返回半年前的结果。更抓狂的是,索引文件看起来是完整的,状态记录也都在,两边却对不上。这种问题,光靠“重放一遍 WAL”根本救不回来——因为 FTS 索引不是从 WAL 里直接生成的。
我最初接手 Hermes 的恢复逻辑时,把恢复想窄了,以为只要状态库能拉起来就行。后来在多次故障复盘里才真正看懂:Hermes 的数据层其实是两条独立链路,一条走精确状态查询,一条走全文检索。它们共用同一份业务数据,却各自维护自己的落地格式。恢复要做的事,是让这两条链路在同一时间点上重新对齐。这也就是标题里“双重防线”的本意:第一道防线负责把 FTS 索引重建到可用状态,第二道防线负责把状态库通过快照和 WAL 回放到正确位置。两道防线同时成立,系统才真正算恢复。
这篇文章我把当时拆解恢复机制的全过程整理出来,包括 FTS 索引的冷启与补偿、状态库的快照加日志回放、两条链路的编排与切换,以及我在实际故障里踩过和排查过的坑。对正在设计或接手带全文检索能力的持久化系统的同学,应该能省不少弯路。
1. 先厘清“双份数据”的根源
1.1 一条写入路径上的两份记录
Hermes 里每次业务写入,比如更新某个实体对象,最终会落到两个地方:
- 状态库:按对象 ID 组织,存储最新、最权威的状态数据。查询模式以精确命中为主,例如“查这个 ID 当前是什么状态”。
- FTS 索引:按分词后的词项组织,存储用于全文检索的倒排数据。查询模式是“包含哪些关键字”。
这两个地方消费的是同一份数据,但存储格式和更新时机并不天然一致。状态库可以采用类似 LSM 的追加写结构,把变更记录到 WAL,再异步刷盘;FTS 索引则需要把文档内容切片、分词、构建倒排表,更新成本明显更高。
更麻烦的是,两者的更新在故障场景下不是原子的。进程可能在状态库写入成功后、FTS 索引更新前崩溃,也可能反过来。如果你没有一套恢复机制,两条链路就会停在不同的逻辑时间点,用户看到的结果就分裂了。
1.2 崩溃恢复不是“把数据找回来”那么简单
很多第一次接触这部分的同学会问:状态库有 WAL 和快照,FTS 索引也能从业务数据全量扫一遍重新生成,各恢复各的不就行了?
问题在于“恢复”有个隐含要求:恢复到的位置要一致。状态库可以回放到最新写入,但如果 FTS 索引只追上被崩溃打断的那一批,你就得知道那批到底是哪一批。没有对齐位置的话,哪怕两边各自“成功恢复”,合在一起也是错的。
举个例子,进程处理到第 1000 条写入,其中第 950 条已经提交状态库,但 FTS 索引只更新到第 900 条。这时副本重启,状态库回放完 WAL 到达第 1000 条,FTS 索引如果只从自身已有数据继续,它并不知道自己丢在第 900 条。它得先从某个一致性快照或全量数据源重新确认基线,再往后补偿。
这正是双重防线的价值:FTS 索引能否独立重建是一回事,重建完成后能否与状态库对齐是另一回事。一个负责能力,一个负责正确性。
1.3 为什么设计成两道防线而不是一次到位
单靠“状态库恢复完”或者单靠“重建 FTS 索引”都不够稳定。我的理解是,两道防线本质上分别解决了两个不同风险:
- 第一道防线(FTS 索引恢复)解决的是可用性风险:索引文件可能部分损坏、部分过期,不重建就无法提供完整的搜索能力。
- 第二道防线(状态库恢复)解决的是数据一致性风险:状态库必须回到最近一个正确的逻辑点,才能作为 FTS 重建的可靠数据源。
两者也不是纯粹的上下级关系。状态库提供了 FTS 重建所需的基线数据;FTS 索引重建完成后,反过来也能验证状态库的恢复结果,比如通过统计数量和摘要校验,找出某一条链路的遗漏或重复。用一句话总结:第一道防线负责把搜索拉回可用,第二道防线负责给重建过程提供可信的坐标,两者互相兜底。
2. 第一道防线:FTS 索引的冷启与补偿
2.1 FTS 索引在 Hermes 里的定位
先说清楚,FTS 索引在 Hermes 中属于“可重建衍生数据”,它不是唯一的事实来源。状态库才是事实来源。这个定位决定了恢复时的思路:我们可以删掉一个分片的索引,从状态库或最近一致快照重新拉数据,而不影响业务数据的正确性。
但“可重建”不等于“重建起来很容易”。FTS 索引的构建是典型的 CPU 和 IO 密集型操作。如果数据量已经到亿级,全量扫描重建可能要跑很久。所以恢复时第一步要做的不是盲目全量扫,而是评估现有索引的可用程度。
按我的经验,存在三种情况:
- 索引完全没损坏,只是落后。这种情况做增量补偿即可。
- 索引有部分段损坏,但大部分段完整。能做段级替换,尽量做段级替换。
- 数据段大面积损坏或元数据丢失。这时候就得全量重建。
2.2 全量重建还是增量补录:一条决策链
决策链的核心是“现有索引的完整度”和“检查点偏移量”。
如果 Hermes 状态库维护了一个逻辑时钟或者单调递增的写入序号,FTS 索引自己也记录了已经消费到哪个序号,那么恢复就很简单:从记录的位置往后重放变更即可。这属于增量补录,成本最低。
但崩溃恢复里经常遇到的尴尬是:FTS 索引没来得及记录“我消费到哪了”。写入侧把文档发给索引模块,索引模块在内存里快排完准备落盘,进程没了。索引的已消费位置丢失,你无法精确知道该从哪里补。
这种情况下,我倾向于把 FTS 索引的恢复分成两段:
- 先从一个可信基线出发重建主流数据。
- 再用状态库 WAL 里的新增变更做补偿,消除基准点之后的差异。
这里的基线可以是状态库最近的快照,也可以是上次确认成功的索引段。为了减少重建量,通常取后者,但前提是能确认该索引段的完整性。如果不能确认,就选状态库快照,宁可多跑一点,也不能让错误基线流传下去。
2.3 重建流程的最小骨架
下面这段伪代码是我在实际恢复作业里抽取出来并简化过的。它描述了用“快照扫描 + WAL 增量补偿”重建 FTS 索引的全过程:
def recover_fts_index(instance_id, base_snapshot, wal_entries): # 阶段一:打开一个暂存索引,不直接动当前线上索引 with fts.writer.open_staging_index(instance_id) as staging: # 阶段二:从基础快照全量写入 for doc in base_snapshot.scan_all(): staging.add_document(doc) # 阶段三:用 WAL 增量补偿,跳过基础快照已经包含的序号 snapshot_pos = base_snapshot.max_seq for entry in wal_entries: if entry.seq <= snapshot_pos: continue if entry.op == "upsert": staging.upsert(entry.doc_id, entry.doc) elif entry.op == "delete": staging.delete(entry.doc_id) # 阶段四:校验后切换 expected_count = ( base_snapshot.doc_count - base_snapshot.deleted_count + count_wal_upserts_after(snapshot_pos) - count_wal_deletes_after(snapshot_pos) ) if staging.doc_count != expected_count: raise IndexRecoveryError("count mismatch") staging.validate_checksums() # 全部成功后,原子替换线上索引 fts.index.switch_epoch(instance_id, staging.epoch)这个流程的核心有两个。第一,使用暂存索引。恢复过程中当前线上索引还能继续提供读,不会因为重建就完全不可用。第二,增量补偿的关键是精确跳过基线已经包含的序号。因为 WAL 里既可能有重复记录,也可能有乱序记录,所以一定要用序号或版本号去重,不能闭着眼睛全部重放。
2.4 这里容易踩的几个坑
第一个坑是删除操作丢失。很多人在重建索引时只补偿 upsert,忘了 delete。结果搜索里能看到已经被删除的文档,表现为“状态库里查不到,搜索里还能搜到”。冷启动时,全量扫描得到的是当前基线数据,这个基线一般不含已删除项,所以问题不大;但增量补偿阶段,delete 必须被一并处理。我在代码里特意把 delete 分支写出来,就是为了强调这一点。
第二个坑是重复添加。如果两条 WAL 记录针对同一个 doc_id,后一条覆盖前一条,没问题;但如果你没有按 doc_id 做 upsert 而是直接 add_document,就会产生重复文档,导致搜索结果重复。Hermes 里每个文档都应该有唯一 doc_id,重放时用 upsert 而不是 add。
第三个坑是校验时机。count 校验只能发现明显缺失,发现不了内容错乱。所以在 count 之后我还会做一次全文摘要的抽样校验:随机抽取几个关键字,查询搜索出来的文档标题和正文摘要,和状态库里存的对一下。全量对比成本太高,抽样校验性价比最高。
3. 第二道防线:状态库快照与 WAL 的闭环回放
3.1 快照和 WAL 是恢复的“地基”
第二道防线面对的是状态库本身。Hermes 状态库采用快照加日志的经典做法:定期生成快照,记录某个逻辑时间点的完整状态;日常写入持续追加到 WAL。恢复时,先加载最近一个完整快照,再按顺序回放从这个快照之后产生的 WAL 条目,直到追上最新进度。
这里的“逻辑时间点”,可以是一串全局单调递增的序号,也可以是带时间戳的版本向量。不论哪种,都要保证一件事:这个点在 WAL 里能精确定位。如果快照标记的序号在 WAL 里找不到对应位置,回放就无法开始。
恢复步骤落实到代码层面大概是这样的逻辑:
load_latest_snapshot() start_pos = snapshot.meta.sequence for entry in wal.read_from(start_pos): apply(entry) checkpoint_if_needed() verify_state() mark_recovered()每一条 WAL 记录都要被幂等处理。比如同一个更新在不同节点上被重试,序号相同但内容可能重复,应用时必须通过 seq 或者操作唯一 ID 判断是否已经处理。
3.2 幂等和乱序:回放时最容易被低估的问题
WAL 回放不是订单式执行。崩溃可能发生在 WAL 写了一半的时候,也可能发生在状态库已经提交但 ack 没返回的时候。客户端重试会带来重复记录,网络分区会导致乱序,因此恢复逻辑必须容忍这两种情况。
我对幂等性的做法是,在业务状态表里记录每个操作的唯一 ID 或者序号。回放时先检查这个 ID 是否已存在,存在就不重复执行,不存在才执行并写回。这样即使同一条记录回放两次,最终状态也只有一个版本。
乱序问题更隐蔽。Hermes 是多写或多副本架构时,不同副本可能对同一实体的更新顺序不一致。恢复时如果只按“到达顺序”回放,最终状态可能是错的。正确做法是按单调序号或者版本号排序,而不是按 WAL 中的存储顺序。换句话说,回放引擎要维护一个优先级队列,只有 seq 大于当前已应用水位线的记录才能执行。
3.3 栅栏机制:防止恢复后又被“幽灵写入”干扰
分布式环境下,恢复还有一个容易被忽略的问题:旧领导者可能还活着,或者网络分区中的节点恢复后带着旧数据加入集群,干扰新主节点的回放。如果不做隔离,状态库回放到一半,旧节点又写入了一批数据,整个恢复过程就错乱了。
Hermes 的做法是引入 epoch 栅栏。每次发生领导者切换或者恢复流程启动时,生成一个新的 epoch。所有写入操作都必须携带当前 epoch,只有 epoch 大于等于最新值的写入才会被接受。旧节点带着旧 epoch 的写入会被直接拒绝,恢复流程内部也只在当前 epoch 下处理 WAL,避免读到上一个时代的残留写入。
用生活化一点的比喻:就像电影院散场后重新清场,只有持新票入场的人才能落座,旧票不管之前能不能看,这回一律失效。这个机制让第二道防线的回放能够有一个干净的起点,也防止恢复完成后被迟到的旧数据污染。
3.4 回放完整性校验不能只看条数
状态库回放完,不能直接宣布“恢复成功”。我最开始也犯过这个错误:WAL 条数对得上,业务接口能通,就认为没问题。结果第二天发现某些实体的版本被回退了一小段,原因是 WAL 里有一条记录被重复应用,覆盖了更新的状态。
后来我在回放结束时加了三层校验:
- 数量校验:已应用记录数和 WAL 记录数一致。
- 水位线校验:状态库的最新 seq 等于回放到的最新 seq。
- 抽样状态校验:随机选一批业务实体,比对恢复后的值和快照期的预期值。
其中水位线校验最直接,也最容易被忽略。它不只是要“最终都应用了”,还要确认“应用后形成的结果等于原本该有的结果”。
4. 两道防线的协同:恢复编排和原子换挡
4.1 恢复编排的整体时序
分别恢复两道防线其实不难,难在把它们编排成同一条流水线。假设我们决定先恢复状态库,再恢复 FTS 索引,时序大概是:
- 状态库加载最新快照,开始回放 WAL,直到接近最新水位。
- 以当前状态库恢复到的 seq 作为 FTS 索引重建的基线。
- FTS 索引从该基线的数据快照或同步接口拉取数据,进行全量或增量构建。
- 构建完成后,验证 FTS 索引与状态库在相同 seq 上的数据一致性。
- 原子切换:新索引上线,搜索流量从旧索引切到新索引。
这里有个细节:为什么状态库要先回放到一个稳定的 seq,再让 FTS 索引从这个点重建?因为如果状态库还在不停回放,基线本身在移动,FTS 索引扫描到一半,前面的数据和后面的数据已经不一致,重建结果就不可能是确定性的。
把状态库回放暂停或者停在某个一致点,等 FTS 索引完成后,再让两者同步推进,这是我验证过最稳的方式。
4.2 原子换挡:让新索引和旧状态无感切换
重建 FTS 索引过程中,线上搜索不能停。所以恢复流程会先把索引写到暂存区,等一切校验通过,再通过一次换挡操作把线上索引指向新构建的索引。
换挡本身要设计成原子性的,通常是对某个元数据记录做一次条件更新,例如把 current_epoch 从旧值更新到新值。搜索请求在换挡前后可能短暂拿到不同版本的数据,但不会出现“一半请求命中旧索引、一半请求命中新索引”的中间状态。实现上,可以用一个版本号字段控制:
switch_index(instance_id, old_epoch, new_epoch): if meta.current_epoch == old_epoch: meta.current_epoch = new_epoch meta.index_ref = new_index_path return ok else: return conflict条件更新失败时,说明有另一个恢复流程已经切换过,这时应该回滚当前流程而不是强行继续。
4.3 两道防线不平衡会引发什么后果
协同编排里最常出的问题,是两条防线完成时间相差太远。比如状态库回放很快,FTS 索引全量重建却很慢。如果 FTS 索引还在构建,搜索服务只能继续用旧索引,那么即使状态库已经是最新的,用户看到的搜索结果依然是旧的。
这个阶段要容忍“状态新、搜索旧”的窗口,但窗口不能无限长。我会做两件事:
- 给 FTS 重建任务设置分片并行度,把大索引拆成多个分片并行构建,再用归并段合并。
- 用 Delta Merge 机制,在新索引构建完成后,将恢复期间产生的新增变更再次合并进去,减少切流后的追赶时间。
实际测试下来,1000 万级文档的全量重建,在合理的分片并行下,通常能在分钟内完成。如果数据量更大,就得靠增量和滚动迁移策略,而不是单纯扛一个全量任务。
5. 典型故障现象与排查实录
5.1 搜索返回的结果和状态列表对不上
这是最典型的 FTS 落后问题。现象是:状态库里某个实体已经更新,但搜索接口按关键词还是返回旧内容。
排查第一步,先比较实体在状态库里的版本号和 FTS 索引里的版本号。Hermes 会在索引文档的元数据里附带版本号,如果两者不一致,基本可以确定索引落后。
接着查索引模块记录的消费水位,看看它是停在哪一条写入上面。如果水位查询不到,说明索引模块的进度元数据丢失,只能退回全量重建或从最近一致快照重建。
这个问题的教训是:FTS 索引的进度信息一定要持久化,不能只存在内存里。后来的版本里,我把消费进度和状态库恢复点一起写入快照,恢复时能直接读出,大大缩短排查时间。
5.2 索引文件一致性校验失败,但状态库正常
现象是重启后索引加载报错,某个段的 checksum 不对。通常发生在磁盘异常或强制杀进程时,半边段文件已被修改,另半边没有。
这时候别尝试继续读旧段,也别只删一个段文件就让它自动重建,很容易出现段和段之间的数据交叉过期。稳妥做法是标记整个索引为待恢复,走一次两阶段恢复:先从状态库取基线,再补增量。虽然慢一点,能保证不残留坏数据。
遇到这种情况还有个选择:如果 FTS 有副本,直接从副本复制一份完整索引比全量重建更快。但前提是副本本身没有同类问题,最好先在上线前置机做一次完整性校验。
5.3 恢复流程反复卡在两个防线之间
我碰到过一次比较隐蔽的问题:状态库已经恢复完成,FTS 索引重建也完成了,但每次切流后线上马上又出现不一致告警。
反复排查后发现,问题出在切流时机。状态库恢复完成后还在接收业务侧的重试写入,新的写入不断产生,FTS 索引却停在切流前的位置。切流后,新写入早就过了,索引却没跟上,不一致就复现了。
解决办法是,在切流前先将状态库进入“恢复后同步模式”,把新增量缓冲到临时队列,让 FTS 索引追到和新写入相同的水位,再执行原子切流。或者更简单:切流后立刻触发一次小范围的增量补偿,把切流期间缓冲的变更一次性补齐。
5.4 排查顺序和速查表
我梳理了一张排查顺序表,基本能覆盖 90% 的恢复异常:
| 现象 | 先查状态库恢复点 | 再查 FTS 索引水位 | 最终处置建议 |
|---|---|---|---|
| 搜索滞后,状态正常 | 确认状态库水位 | 查看索引消费水位 | 增量补偿或重建 |
| 搜索正常,状态异常 | 查看 WAL 回放是否乱序 | 核对索引基线版本 | 重放状态库,核对幂等 |
| 索引段损坏 | 确认基线可用 | 检查段校验和 | 全量重建或副本复制 |
| 搜索和状态都正常,但结果冲突 | 核对两侧版本号 | 核对切流时间点 | 检查是否存在脏读,清理缓冲队列 |
这张表不是理论推演,是我实际调试时真用过的路径。每次遇到新告警,我都先定两侧的“水位一致性”,再决定走哪条恢复路径,能省下大量翻日志的时间。
6. 实操心得和一些能复用的经验
6.1 恢复演练一定要定时做,别等真出事
只说“有恢复机制”是不够的。我第一次完整跑恢复流程是在故障已经发生之后,结果每一步都在赶时间,相当被动。后来我养成了每两周做一次故障注入演练的习惯:随机杀进程、模拟磁盘写坏、拔掉副本网络,然后观察恢复流程能不能在限定时间内完成。
恢复演练发现的典型问题有两个:一是 WAL 清理策略太激进,导致回放起点之前的数据已经没了;二是恢复任务优先级太低,在正常业务请求的压力下几乎抢不到资源。这两个问题在平时静默运行根本看不出来,只有演练时才会暴露。
6.2 监控指标不能只看“恢复完成”
监控维度上,除了常规的“是否恢复完成”之外,我更看重三个指标:
- FTS 索引落后条数:实时反映索引消费水位和状态库水位差距。
- 恢复时间窗口:从故障发生到切流完成的总时长。
- 校验失败次数:重建时抽样校验和历史校验的失败计数。
落后者条数超过一个阈值,就该触发告警。恢复时长超过预期,说明恢复流程可能在等待资源或卡在某一轮重试上。校验失败次数哪怕只有一次,也要查清楚,因为一次校验失败往往意味着有一片数据范围存在损坏。
6.3 最后再说一个很重要的设计取舍
设计双重防线时,很容易陷入“两边都要做得尽善尽美”的误区。我的体会是:状态库恢复必须优先保证正确性,可以慢,不能错;FTS 索引恢复则要优先保证时效性,可以在正确性校验上做抽样,但要尽快恢复搜索能力。
这个取舍看起来很反直觉,因为搜索出错的感知更强。但反过来想想,状态库一旦错了,所有下游查询都会基于错误事实,连排查都无从下手。FTS 索引错了,至少还能用状态库精确查询对账,把错误范围缩到搜索侧。所以我把第二道防线的校验做得更重,把第一道防线的恢复速度做得更快,两条防线各司其职,配合起来反而是最稳的。
如果你也在设计类似的带全文搜索能力的持久化系统,我建议先别急着堆功能,先把“恢复之后,两侧数据能对齐到某个逻辑点”这个最基础的问题想清楚,再往上添别的。