1. 这个问题的来源:一次状态回放引发的搜索错乱
1.1 生产环境中的故障现场
先说个我实际经历过的故障场景。某个业务模块依赖一套自研的状态数据库(我们内部管它叫 Hermes State DB),它同时承担两类职责:一类是保存业务实体的最新状态,另一类是为这些状态数据提供全文检索能力。某个深夜,主节点因为磁盘异常直接宕机,哨兵机制把流量切到了从节点。从节点负载飙升、内存翻了几倍,最终才把状态数据追平。但从节点上的 FTS 索引,却在回放过程中悄悄丢了一批文档。
问题在第二天早上才暴露。运营同事搜索某个关键词,返回的结果比前一天少了将近三成,而且缺的正是最近几个小时写入的那批高价值数据。最麻烦的是,状态查询接口返回的数据是正常的,只有搜索接口是错的。同一个系统,两种对外能力,一个健康一个残缺,这就成了典型的“数据没丢,但索引丢了”的尴尬局面。
这种事故真正麻烦的地方在于:状态恢复的逻辑已经成功执行了,事务日志也确认重放完毕,应用层根本不会报警。只有当你去对比 FTS 索引和底层状态数据时,才会发现两边已经分叉。也就是说,单纯做好状态数据库本身的恢复是远远不够的,如果 FTS 索引不具备独立的恢复能力,那么状态恢复得再完美,也掩盖不了搜索数据残缺的事实。
1.2 为什么“状态恢复”和“FTS 索引”被当成两件事
我见过不少团队,对键值存储、关系型数据库的恢复机制很熟悉,讲到 WAL、快照、归档、回放,头头是道。但一提到全文检索索引,大家的第一反应是“重建就好了”。重建确实能解决静态数据的分裂,但放在生产环境里,一个几千万文档的 FTS 索引,重建需要多长时间?少则数十分钟,多则几个小时。重建期间窗口上在线搜索请求怎么办?这个业务能不能接受?这些都是现实问题。
更关键的是,FTS 索引本身有自己的生命周期。底层状态数据通过 WAL 重放回到某个一致点,但索引可能停留在更早的时间点;或者在数据写入过程中,索引段合并、删除标记已经发生了变化,而状态数据库的快照并没有覆盖这些变化。两者之间没有一个天然的强一致保证。你不能假设“状态库里数据对了,那索引就对了”。在 Hermes 的架构里,我们把这两者拆成两条独立的恢复链路,但又在编排层把它们收拢成一道完整的恢复流程。这就是所谓“双重防线”的由来:一条防线管状态数据本身的正确性,另一条防线管 FTS 索引与状态数据的一致性。
1.3 Hermes State DB 的架构定位
简单交代一下背景。Hermes State DB 我们可以把它理解成一个“以状态为核心”的分布式存储系统,底层持久化采用类 LSM 结构,支持多版本并发控制,对外提供几点查询的同时,在同一个进程空间里嵌入了 FTS 索引模块。写入路径是:业务请求到达后,先写事务日志,更新内存状态表,异步把变更同步给 FTS 索引器。读取路径则分两种:精确的键值查询直接走状态表,而全文搜索则完全依赖 FTS 索引。
之所以把 FTS 索引放在同一个进程中而不是拆成独立搜索引擎,主要是为了部署简单和事务语义一致。但这样做也带来一个副作用:一旦进程崩溃,内存态和索引态可能同时丢失,恢复时必须同时考虑两个模块。很多人以为 FTS 索引存放在磁盘上就不会丢,实际上它还有大量内存中的倒排缓存和段元数据。如果进程被强杀,磁盘上的索引段可能停留在某个一致状态,但后续的追加写数据已经丢了。
Hermes 的恢复思路因此被设计成两条线:第一条线是“状态数据恢复线”,从快照和 WAL 重放完整状态表;第二条线是“FTS 索引恢复线”,比对索引与状态数据的时间点差异,增量补齐,必要时局部重建。下面我会把这两条线的具体机制拆开讲清楚。
2. 第一道防线:状态数据库本身的恢复机制
2.1 状态存储的分层设计
Hermes 的状态存储采用内存态与持久化态双层结构。内存态是一个跳表加速的哈希表,保持热点数据的极低延迟访问;持久化态则落在 LSM 的 SSTable 文件中。每次写入先追加到 WAL,再更新内存表,WAL 刷盘成功后才向客户端返回成功。这样设计的好处是显而易见的:内存表可以随时从 WAL 重放出来,SSTable 只需要周期性合并即可。
但分层设计也带来了恢复粒度的问题。假设 WAL 文件很大——比如积累了 20GB 的日志——如果每次故障都从最早的 WAL 开始重放,耗时根本不可接受。所以 Herman 引入了快照机制:后台定期生成一致性快照,把当前内存表和已经落盘的 SSTable 元信息固化下来。恢复时只需要加载最近一份快照,然后从快照对应的时间点开始重放之后的 WAL 记录,而不是从头重放。
这个机制听起来不复杂,真正考验人的是快照与 WAL 的分界点对齐。如果快照生成过程中还有写入,那么快照本身可能是不完整的。解决方法是给每个 WAL 记录打上单调递增的序号,快照里记录“这个快照包含了哪些序号之前的全部写入”。恢复时从快照标记的序号开始找后续 WAL,就能精确接上。
2.2 WAL 重放与快照合并的取舍
恢复过程中有个容易被忽略的细节:WAL 重放不是简单地把记录写回内存表。因为 WAL 里记录的可能是“键 + 最新值”的整行覆盖,也可能是针对某个字段的增量更新。对于状态类数据,最常见的语义是覆盖或者删除。如果两笔 WAL 记录针对同一个键,一笔删除、一笔写入,重放时如果严格按照顺序执行,就会出现一个键的多次内存分配和释放。大压力恢复场景下,这种重复操作会放大内存抖动。
所以 Hermes 在重放之前会先做一次“WAL 压缩”:把所有针对同一键的操作合并成最后的有效操作。比如某个键在 WAL 中被写入了三次、删除了一次,最终结果其实是“已删除”,那么压缩后就只剩一条删除记录。这么做不仅能显著减少重放耗时,还能避免临时的内存尖峰。当然,压缩过程的代价是需要额外维护一个哈希表来做映射,不过因为只是在恢复期临时存在,所以整体收益还是很划算的。
另一个取舍是快照合并的频率。快照生成得太频繁,磁盘占用高;生成得太稀疏,恢复时重放的 WAL 就多。我的经验是,按“时间 + 日志总量”两个维度共同触发:既设置一个时间周期(比如 30 分钟),也设置一个 WAL 累计大小阈值(比如 4GB),谁先到就触发快照。实战下来,这个策略比单一固定周期要稳得多。
2.3 确保状态一致性的校验手段
状态恢复完成,并不代表数据一定正确。为了让恢复结果可信,Hermes 在恢复流程里内置了一个校验阶段:恢复完成后统计总键数、分片哈希值,与快照中记录的元数据做比对。如果从节点或者新主节点在恢复时出现了异常中断,校验值大概率会不一致,系统就会标记为“恢复失败”,而不是稀里糊涂地开始对外服务。
这里有一个容易踩的坑:哈希校验本身也会耗时。几亿个键算一遍哈希,几秒钟过去了。如果每次故障恢复都要校验一次,恢复窗口会被拉长。我们的做法是把校验分两类:恢复完成后先做一次轻量校验——只校验 WAL 尾部序号连续性和每个分片的键数范围;后台再异步做全量哈希比对。这两者组合起来,既保证了尽快恢复服务,又不会放过静默损坏。
在这道防线的设计上,我还想强调一一点:状态数据恢复的目标不是“恢复到故障前的最新时刻”,而是“恢复到一致且可对外服务的时刻”。有时候因为从节点追不上主节点的写入,你会被迫接受一个小范围的回退。只要回退的窗口远小于业务容忍度,这就是合理的工程选择。
3. 第二道防线:FTS 索引与状态数据的联动恢复
3.1 FTS 索引为什么不直接复用 WAL
前文提到,FTS 索引的数据变更源头在状态数据的写入路径上。从理论上说,似乎可以复用状态库的 WAL 来重建索引——把 WAL 里的每条写入都经过分词器处理后回填索引。这个方案简单直接,但实际并不可行。
第一个原因是数据格式不对。状态库 WAL 记录的是“键、版本号、原始字段值”,但 FTS 索引需要的是分词后的倒排列表。同一个字段值可能被拆成几十个词项,如果靠重放 WAL 重建索引,等于把全部分词逻辑重新执行一遍。对于高吞吐写入的场景,恢复期的 CPU 开销会非常高。
第二个原因是删除语义的冲突。状态库的删除操作会直接删掉键,但 FTS 索引里一个词项会映射到多个文档,删除一个文档需要更新所有包含它的倒排列表。这个更新操作很难从原始的 WAL 记录中高效推导出来,因为倒排结构是一个跨文档聚合的数据结构,和底层键值存储的粒度完全不同。
第三个原因是版本问题。状态库采用多版本机制,同一时刻可能存在一个键的多个历史版本,但 FTS 索引通常只关心最新版本。如果直接重放 WAL,FTS 索引里可能出现“一个键多个版本同时命中搜索”的脏数据。Hermes 的做法是让 FTS 恢复拥有独立的“文档变更日志”,由专门的索引同步线程维护,记录粒度直接是“文档 ID、操作类型、字段快照”。这样恢复时只需要回放这个精简的变更日志,就能准确还原搜索层的数据。
3.2 索引失效的三种典型场景
做了这么多年系统运维,我总结下来,FTS 索引与状态数据分叉的诱因基本都是下面三种。
第一种是进程被强杀后,内存索引丢失。FTS 索引的写入往往先缓存在内存缓冲区,达到阈值后再批量刷成磁盘段。强杀会导致缓冲区里的文档丢失。如果状态库 WAL 已经记录了这些写入,那么底层状态是完整的,但索引少了一截。这就是我在文章开头描述的故障,属于最典型的一种。
第二种是恢复回放过程中的索引静默失败。WAL 重放时,某个字段因为数据格式异常、字符编码问题,分词器报错后索引线程选择跳过,但状态库已经把这个写入记录判定为成功。两边对同一事件的理解不一致,最终表现为部分文档无法被搜索到。
第三种是索引段合并与快照时间点错位。如果 FTS 索引正在做段合并,合并后的新段还没有持久化完,状态库快照已经生成了。恢复时按快照时间点加载索引段,可能加载到旧的段集合,那些已经被合并掉的文档就莫名其妙地消失了。
这三种场景在日志层面都很难直接观察到,因为它们不表现为状态库自身的错误。所以 Hermes 的 FTS 恢复防线不单独依赖变更日志回放,还会引入一个“索引探测”过程,遍历索引元数据与实际状态数据做抽样比对,发现偏差超过阈值就触发局部重建。
3.3 主从切换后的索引追赶策略
主从切换时,FTS 索引的恢复难度还会再上一个台阶。因为新主节点在对外提供写入之前,必须先把自身的状态数据和 FTS 索引追到一个合理的时间点。如果从节点一直处于只读状态,它的索引同步线程可能还在持续消费复制流,那问题还不大。但如果从节点落后主节点太多,或者它在故障期间自己也重启过,索引追赶就会变得很复杂。
Hermes 的做法是引入一个“索引追赶水位线”。每个分片维护两个指针:一个是状态数据的复制位点,另一个是 FTS 索引的同步位点。正常运行时,两者相差极小;故障恢复时,先拉状态数据到最新的复制位点,然后从索引同步位点开始,批量回放文档变更日志。只有当索引位点追到状态位点之后,新主节点才允许切换为可写状态。
这里有一个值得注意的设计细节:追赶过程中,搜索请求不能被直接拒绝,否则业务会大面积超时。我们的方案是把这个时段内的搜索请求路由到旧索引的只读副本,同时对新写入的数据做标记,等追赶完成后合并结果返回。这样用户感知到的只是结果可能偏旧,但不会报错。要真正做到这一点,需要索引模块支持按时间戳过滤的“延迟可见性”查询,这比简单粗暴地临时下线搜索功能要复杂,但体验和可靠性都会好很多。
4. 双重防线如何协同:恢复流程全景梳理
4.1 Hermes State DB 中的完整恢复流程
把两道防线放到同一个时间轴上,整体流程是这么走的。
故障触发后,集群监控先选举出新的主节点或者选一个副本来执行恢复。第一步不是立刻启动数据加载,而是先检查本地持久化文件:读取最新的快照元数据和 FTS 索引段元数据,判断两者各自停留在哪个时间点。在此基础上算出“恢复差距”——状态数据缺失多少日志、索引缺了多少变更。
第二步,加载状态快照并回放 WAL,执行我之前说的 WAL 压缩、并发重放、轻量校验。状态数据恢复到目标位点后,标记状态层为“可服务”。
第三步,开始 FTS 索引恢复。先从文档变更日志读到最后一条已应用的序号,再从该序号向后拉取变更记录,一边重放一边构建内存索引。重放过程中需监控 CPU 和内存占用,避免节点刚恢复就再次被压垮。
第四步,做联合校验。重点看三个指标:状态库总键数与索引文档数的偏差、抽样文档的内容比对、最近 N 分钟写入是否已全部可见。全部通过后,节点正式切换为可读可写状态,整个恢复流程才算真正结束。
这个流程里最容易被忽略的是第一步。很多系统一恢复就急着加载数据,但不想清楚“从什么时间点开始恢复”,后面会非常被动。把状态数据和 FTS 索引各自的位点先摸清楚,恢复目标就很明确了。
4.2 先恢复状态再重建索引,还是并行恢复
有个问题我经常被问到:状态恢复和 FTS 索引恢复能不能并行?毕竟并行可以节省时间。从工程角度讲,分两个阶段串行更保险,但部分环节是可以偷步的。
状态库 WAL 重放和 FTS 变更日志回放,两者确实有一定并行空间。因为 FTS 变更日志描述的是已经分词好的文档内容,不需要依赖状态库重放的结果,所以可以让两个线程同时开始工作。但并行恢复会带来一个新的问题——内存竞争。状态重放要分配大量内存构建哈希表,索引重放也要分配内存构建倒排表,两个高强度任务同时跑,很容易触发内存溢出。
我的实际经验是:把并行度控制在合理的比例。比如状态重放优先分配 60% 的可用内存,索引重放分配 40%,用资源管理的办法避免互相挤兑。另外,不要并行做全量重放,而是先并行跑一段“预热阶段”,把状态数据和索引都恢复到距离目标时间点 5 到 10 分钟的窗口内,剩下的最后由状态层先冲到目标点并等待,索引再单线程完成最后阶段,此时的追赶压力已经小很多。
这个过程本质上是在恢复时间和稳定性之间做平衡。如果你的集群节点内存够大、磁盘吞吐足够好,并行度可以高一些;反之就老老实实串行,别为了省几十秒把节点搞崩。
4.3 恢复过程中的一致性与可用性取舍
双重防线协同的过程里,一个无法回避的话题是 RPO 和 RTO 的取舍。RPO 决定你能够容忍丢失多少数据,RTO 决定你多久能恢复可用。状态库恢复和 FTS 索引恢复各自对 RPO、RTO 的诉求并不完全一致。
先说状态库:它在设计上是尽量把 RPO 打到零,因为 WAL 同步刷盘能保证已提交事务不丢。但 FTS 索引为了吞吐量,通常采用异步批量刷盘,所以它的 RPO 天然大于零——最多可能丢几秒的索引数据。因此,如果业务要求“搜索也必须零丢失”,你就必须让索引写入也走同步刷新链路,代价是写入吞吐下降不少。我们在 Hermes 里把索引同步模式设计成可配置的:默认异步模式,只有需要在关键业务中才能开启同步模式。
再说可用性。恢复过程中,新主节点是否要开放只读流量?我的建议是,状态层只要完成 WAL 重放和轻量校验,就可以开放精确查询的只读流量。而 FTS 索引在完全追平之前,不建议直接开放搜索流量,否则用户会看到明显的数据缺失。这里可以搞一个优雅降级:搜索接口返回时附带一个提示参数说明“索引仍在追赶中”,或者直接把旧索引副本上标记为过期的文档过滤掉。总之,宁可让用户搜到稍旧的结果,也不要让用户看到断崖式的数据缺失。
5. 实践经验:我在 Hermes State DB 上踩过的坑
5.1 FTS 索引段合并与状态快照不一致
这个坑值得单独写一段,因为它在我们的历史上造成过不止一次线上事故。
FTS 索引底层也采用类 LSM 的段结构。新的文档先写内存缓冲,刷盘后生成小的不可变段,后台线程定期把小段合并成大段。问题出在段合并和状态快照的配合上:状态快照生成时记录的是“当前活跃段集合”,但如果合并线程正在把 A、B 两个小段合并成 C 段,并且已经删除了 A、B 的引用,那么快照可能记录到不完整的索引文件列表。
恢复的时候,系统只加载快照里记录的段,恰恰 C 段没被记录进去,于是大量文档凭空消失。最迷惑的是,索引目录里其实还存在 C 段文件,只是加载逻辑不认它。
解决这个问题需要在索引层引入“段清单文件”的概念。每次段集合发生变化时,先原子写入一个包含完整段清单的元数据文件,然后加载逻辑只信任这个清单,而不是随意扫描目录。快照生成时也必须保证捕捉到的是一份完整的段清单,也就是说,段合并期间禁止生成快照,或者快照时必须等待合并提交完成。看起来是很小的改动,但细节决定成败。
5.2 恢复过程中的只读请求处理
恢复过程中只读请求怎么处理,看起来简单,实际上非常考验设计。刚开始我们觉得,让只读请求直接打到未完成恢复的节点上,读到的数据只要不是错的就行。后来发现没那么简单。
状态恢复刚开始时,内存里可能还没有加载完数据,如果此时一个查询请求来了,可能出现两种情况:一是这个键还没被加载,查不到;二是这个键在旧版本快照中已经存在,但被新数据覆盖了,查询读了旧的版本。这两种情况都不是我们希望看到的。
Hermes 的解决办法是引入“全局恢复屏障”:在每个分片恢复完成前,该分片上的读写请求全部排队等待或者被路由到其他节点。恢复完成后,先短暂拒绝写请求,等 FTS 索引也追平后,统一放开读写。这样做虽然会让恢复期的可用性略有下降,但能避免查询读到中间态。后来我们还加了一个机制,让客户端感知到节点正在恢复,自动回避该节点,把流量分发到其他健康节点,进一步降低恢复压力。
5.3 从搜索错乱到业务可感知的案例分析
再回头看文章开头说的那次事故。当时我们只做了状态库的 WAL 重放,FTS 索引没有独立的恢复流程,所以恢复后的节点从状态查询角度看完全正常,但搜索索引中的文档数明显少于状态库中的有效文档数。
这个问题其实在恢复后几分钟内就能发现,只需要做一次简单的对比查询:取最近 100 条写入的键,去搜索接口里逐一验证能否命中。但由于我们没有把这个校验环节做到自动恢复流程里,而是靠人工巡检,导致问题暴露延迟了整整一个晚上。
后来我们在恢复流程里加了一个“索引自检模块”,专门负责抽样对比。每次恢复完成后,自检模块会随机选取一万个最近写入的文档 ID,去搜索接口验证命中情况。如果命中率低于 99.9%,系统会自动触发局部索引重建,并且发出告警。从那之后,这类搜索错乱的问题从“用户投诉后才知道”变成了“系统恢复时自己就能发现”。
这个案例给我的教训是:恢复机制本身做得多么完善,都不如一个可靠的验证机制重要。恢复是一种行为,验证是确保行为结果正确的手段。在双重防线的设计里,验证环节应该被当成第三道隐形防线来对待。
6. 后续可以这样演进
6.1 增量索引同步与 CDC 结合
目前 Hermes 的 FTS 索引恢复主要依赖独立的文档变更日志。这个日志在系统内部没毛病,但如果你需要把同一个状态库的数据同步到多个外部搜索系统,就会发现每对接一个下游,就要重复维护一套变更日志消费者。这也是很多系统走向 CDC(Change Data Capture)方案的原因——把状态库 WAL 解析成统一的变更事件流,FTS 索引恢复、事件通知、数仓同步都可以订阅同一份数据。
我之前在另一个系统里做过类似的实践:直接把数据库日志解析成 JSON 格式的变更事件,推送到消息队列。下游的索引模块消费事件流,更新自己的索引,并且记录消费位点。恢复时,只需要从上次记录的消费位点继续消费即可,既不需要扫描全量数据,也不需要依赖业务方做双写。这个思路和 Hermes 的文档变更日志本质上是一样的,区别在于 CDC 是标准化的、独立于业务的数据通道,更容易做到多场景复用。
如果要朝这个方向演进,建议先保持 Hermes 已有的独立变更日志作为内部快速通道,另外搭建向外的 CDC 事件流,避免把内部实现的细节暴露给太多外部系统。两层架构可以并存,过渡期会更稳。
6.2 恢复演练与混沌注入
最后聊聊我一直很推崇的实践:定期做恢复演练。很多人觉得恢复机制写进了代码就等于万事大吉,但真正故障来临时,你才会发现很多细节压根没验证过。比如磁盘满了时快照能不能正常生成、FTS 索引段文件损坏时加载逻辑会不会报错、恢复过程中节点又被拉起时会不会重复执行恢复,这些问题统统只能靠演练来暴露。
我们团队目前的做法是每个月选一个低峰时段,挑一个非关键的从节点,手动触发一次进程强杀,然后观察恢复流程是否按预期走完。渐渐把演练升级成更加接近真实的混沌测试:随机停顿一段时间的写入、随机删除某个索引段文件、随机让网络分区几分钟。每轮演练结束,我们都会收集恢复耗时、索引追赶时长、校验命中率这些数据,并据此调优参数。
演练还有一个额外收益:能够反向验证监控告警的有效性。如果恢复过程中出了问题却没有告警,那说明你的监控项配置不到位。真正经历过几次演练,恢复流程的可靠性会明显提升,远比临时抱佛脚式的排查要强得多。
我在实际运维中还发现一个细节:恢复脚本不要只做成“自动执行然后结束”,最好在每一步节点都输出当前位点、处理了多少 WAL 记录、索引追平了多少条文档,这样故障发生时即使自动化最终没跑完,人工接手时也能迅速判断问题出在哪个环节。把可观测性做进恢复流程,是非常划算的投入。