资讯动态

FTS索引与状态数据双重恢复:分布式存储高可用实践

发布时间:2026/10/21 14:20:18 来源:尧图企业网站定制
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 索引通常只关心最新版本。如果直接重放 WALFTS 索引里可能出现“一个键多个版本同时命中搜索”的脏数据。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 索引恢复主要依赖独立的文档变更日志。这个日志在系统内部没毛病但如果你需要把同一个状态库的数据同步到多个外部搜索系统就会发现每对接一个下游就要重复维护一套变更日志消费者。这也是很多系统走向 CDCChange Data Capture方案的原因——把状态库 WAL 解析成统一的变更事件流FTS 索引恢复、事件通知、数仓同步都可以订阅同一份数据。我之前在另一个系统里做过类似的实践直接把数据库日志解析成 JSON 格式的变更事件推送到消息队列。下游的索引模块消费事件流更新自己的索引并且记录消费位点。恢复时只需要从上次记录的消费位点继续消费即可既不需要扫描全量数据也不需要依赖业务方做双写。这个思路和 Hermes 的文档变更日志本质上是一样的区别在于 CDC 是标准化的、独立于业务的数据通道更容易做到多场景复用。如果要朝这个方向演进建议先保持 Hermes 已有的独立变更日志作为内部快速通道另外搭建向外的 CDC 事件流避免把内部实现的细节暴露给太多外部系统。两层架构可以并存过渡期会更稳。6.2 恢复演练与混沌注入最后聊聊我一直很推崇的实践定期做恢复演练。很多人觉得恢复机制写进了代码就等于万事大吉但真正故障来临时你才会发现很多细节压根没验证过。比如磁盘满了时快照能不能正常生成、FTS 索引段文件损坏时加载逻辑会不会报错、恢复过程中节点又被拉起时会不会重复执行恢复这些问题统统只能靠演练来暴露。我们团队目前的做法是每个月选一个低峰时段挑一个非关键的从节点手动触发一次进程强杀然后观察恢复流程是否按预期走完。渐渐把演练升级成更加接近真实的混沌测试随机停顿一段时间的写入、随机删除某个索引段文件、随机让网络分区几分钟。每轮演练结束我们都会收集恢复耗时、索引追赶时长、校验命中率这些数据并据此调优参数。演练还有一个额外收益能够反向验证监控告警的有效性。如果恢复过程中出了问题却没有告警那说明你的监控项配置不到位。真正经历过几次演练恢复流程的可靠性会明显提升远比临时抱佛脚式的排查要强得多。我在实际运维中还发现一个细节恢复脚本不要只做成“自动执行然后结束”最好在每一步节点都输出当前位点、处理了多少 WAL 记录、索引追平了多少条文档这样故障发生时即使自动化最终没跑完人工接手时也能迅速判断问题出在哪个环节。把可观测性做进恢复流程是非常划算的投入。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑