上一篇把索引拆成了多个分片。分片解决了容量问题,但带来了新问题:一个分片进程挂了,那个分片上的文档就搜不到了。
本篇用只读副本实现读取冗余,用健康检查发现故障节点,用快照备份保证数据可恢复。目标:停一个进程后查询仍然可用(降级),从快照恢复后数据损失可量化(RPO/RTO)。
只读副本
为什么要副本
单个分片只有一个进程时:
- 进程挂了 → 该分片所有文档不可搜索
- 升级或重启 → 搜索服务中断
副本让同一个分片的数据存在于多个进程上。主节点负责写入,副本负责查询。
基于已提交快照的同步
主节点 writer.commit() 后,副本从共享目录打开新的 reader:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| class PrimaryNode { private final IndexWriter writer; private final Path indexDir;
void indexAndCommit(Document doc) throws IOException { writer.addDocument(doc); writer.commit(); } }
class ReplicaNode { private volatile DirectoryReader reader; private final Path indexDir;
void refresh() throws IOException { DirectoryReader newReader = DirectoryReader.openIfChanged(reader); if (newReader != null) { DirectoryReader oldReader = this.reader; this.reader = newReader; oldReader.close(); } }
TopDocs search(Query query, int topK) throws IOException { IndexSearcher searcher = new IndexSearcher(reader); return searcher.search(query, topK); } }
|
DirectoryReader.openIfChanged() 检查目录中是否有新的 commit point。有新 commit 时返回新 reader,否则返回 null。
副本不接受写入
副本只打开 DirectoryReader,不创建 IndexWriter。好处:
- 不需要写锁——多个副本可以同时读同一个目录
- 不需要处理并发写入冲突
- 副本故障不会损坏索引
同步延迟
主节点 commit 到副本 refresh 之间有延迟:
- 共享文件系统(NFS/本地磁盘):取决于文件系统缓存刷新,通常 < 1 秒
- 文件复制:取决于网络带宽和 segment 大小,通常秒级
副本可能返回稍旧的数据——最终一致性,不是强一致性。搜索场景通常可以接受。
健康检查
健康状态
1 2 3 4 5 6 7 8 9 10
| enum NodeHealth { GREEN, YELLOW, RED }
record NodeStatus( int shardId, boolean isPrimary, NodeHealth health, long lastSuccessAt, long lastCheckAt, String errorMessage ) {}
|
健康检查器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| class HealthChecker { private final Map<String, NodeStatus> nodeStatuses = new ConcurrentHashMap<>(); private final Duration timeout; private final Duration checkInterval;
HealthChecker(Duration timeout, Duration checkInterval) { this.timeout = timeout; this.checkInterval = checkInterval; }
void checkNode(ShardClient client, int shardId, boolean isPrimary) { String nodeKey = shardId + (isPrimary ? "-primary" : "-replica"); long now = System.currentTimeMillis();
try { ShardResult result = client.search("_health", 1); nodeStatuses.put(nodeKey, new NodeStatus( shardId, isPrimary, NodeHealth.GREEN, now, now, null)); } catch (Exception e) { NodeStatus prev = nodeStatuses.get(nodeKey); long lastSuccess = prev != null ? prev.lastSuccessAt() : 0;
NodeHealth health; if (now - lastSuccess > timeout.toMillis() * 3) { health = NodeHealth.RED; } else { health = NodeHealth.YELLOW; }
nodeStatuses.put(nodeKey, new NodeStatus( shardId, isPrimary, health, lastSuccess, now, e.getMessage())); } }
List<NodeStatus> getUnhealthyNodes() { return nodeStatuses.values().stream() .filter(s -> s.health() != NodeHealth.GREEN) .toList(); } }
|
健康检查每 5 秒运行一次。连续 3 次失败(15 秒)标记为 RED。
查询路由集成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ShardClient selectNode(int shardId) { ShardClient primary = getPrimary(shardId); List<ShardClient> replicas = getReplicas(shardId);
for (ShardClient replica : replicas) { if (isHealthy(replica)) return replica; }
if (isHealthy(primary)) return primary;
return null; }
|
部分失败处理
降级查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| SearchResult searchWithDegradation(String queryText, int topK) { List<CompletableFuture<ShardResult>> futures = new ArrayList<>(); List<Integer> unavailableShards = new ArrayList<>();
for (int i = 0; i < numShards; i++) { ShardClient client = selectNode(i); if (client == null) { unavailableShards.add(i); continue; } final ShardClient c = client; futures.add(CompletableFuture.supplyAsync( () -> c.search(queryText, topK))); }
List<ShardResult> results = futures.stream() .map(f -> { try { return f.get(200, TimeUnit.MILLISECONDS); } catch (Exception e) { return ShardResult.EMPTY; } }) .toList();
SearchResult merged = mergeResults(results, topK);
if (!unavailableShards.isEmpty()) { System.out.printf("降级查询:分片 %s 不可用,结果可能不完整\n", unavailableShards); }
return new SearchResult( merged.hits(), !unavailableShards.isEmpty() ); }
|
响应头中标记 X-Partial-Results: true 和 X-Missing-Shards: 1,2,让客户端知道结果不完整。
快照备份
创建快照
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| class SnapshotManager { private final IndexWriter writer; private final SnapshotDeletionPolicy snapshotPolicy;
SnapshotManager(IndexWriter writer) { this.writer = writer; this.snapshotPolicy = (SnapshotDeletionPolicy) writer.getConfig().getIndexDeletionPolicy(); }
Path createSnapshot(Path backupDir) throws IOException { writer.commit(); IndexCommit commit = snapshotPolicy.snapshot();
try { Path snapshotDir = backupDir.resolve( "snapshot-" + commit.getGeneration()); Files.createDirectories(snapshotDir);
Directory sourceDir = commit.getDirectory(); for (String fileName : commit.getFileNames()) { Path target = snapshotDir.resolve(fileName); Files.copy( Path.of(sourceDir.toString(), fileName), target); }
return snapshotDir; } finally { snapshotPolicy.release(commit); } } }
|
SnapshotDeletionPolicy 防止备份期间 Lucene 合并时删除正在复制的 segment 文件。
备份调度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| void scheduleBackups(SnapshotManager manager, Path backupDir, Duration interval) { Thread.ofVirtual().start(() -> { while (!Thread.currentThread().isInterrupted()) { try { Path snapshot = manager.createSnapshot(backupDir); System.out.printf("备份完成: %s\n", snapshot);
cleanOldSnapshots(backupDir, 3);
Thread.sleep(interval); } catch (InterruptedException e) { break; } catch (Exception e) { System.err.printf("备份失败: %s\n", e.getMessage()); } } }); }
|
从快照恢复
恢复流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class RecoveryManager {
void recover(Path snapshotDir, Path indexDir, Analyzer analyzer) throws IOException { long startTime = System.currentTimeMillis();
deleteDirectoryContents(indexDir);
for (Path file : Files.list(snapshotDir).toList()) { Files.copy(file, indexDir.resolve(file.getFileName())); }
try (DirectoryReader reader = DirectoryReader.open( FSDirectory.open(indexDir))) { int numDocs = reader.numDocs(); System.out.printf("索引恢复完成: %d 文档\n", numDocs); }
long rtoMillis = System.currentTimeMillis() - startTime; System.out.printf("RTO: %d ms\n", rtoMillis); } }
|
测量 RPO 和 RTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| void measureRpoRto(IndexWriter writer, Path backupDir, Path indexDir) throws Exception { for (int i = 0; i < 1000; i++) { writer.addDocument(createDoc(i)); } writer.commit(); long commitTime = System.currentTimeMillis();
Path snapshot = snapshotManager.createSnapshot(backupDir);
for (int i = 1000; i < 1050; i++) { writer.addDocument(createDoc(i)); } long crashTime = System.currentTimeMillis();
writer.close();
long recoveryStart = System.currentTimeMillis(); recoveryManager.recover(snapshot, indexDir, analyzer); long recoveryEnd = System.currentTimeMillis();
try (DirectoryReader reader = DirectoryReader.open( FSDirectory.open(indexDir))) { int recovered = reader.numDocs(); int lost = 1050 - recovered;
System.out.printf(""" === RPO/RTO 报告 === 最后提交: %d 文档 故障时: %d 文档 恢复后: %d 文档 丢失: %d 文档 RPO (数据): %d 文档 (%d ms 的写入) RTO (时间): %d ms """, 1000, 1050, recovered, lost, lost, crashTime - commitTime, recoveryEnd - recoveryStart); } }
|
预期输出:
1 2 3 4 5 6 7
| === RPO/RTO 报告 === 最后提交: 1000 文档 故障时: 1050 文档 恢复后: 1000 文档 丢失: 50 文档 RPO (数据): 50 文档 (320 ms 的写入) RTO (时间): 1250 ms
|
RPO = 最后 commit 到故障之间的写入。减小 RPO 的方法:更频繁地 commit。代价:commit 有 I/O 开销。
降级演示
停一个进程
1 2 3 4 5 6 7 8 9 10 11 12
| 初始状态: 3 分片 × (1 主 + 1 副本) = 6 个进程
1. 查询 "搜索引擎" → 返回 10 条结果,partial=false 2. 停止 Shard1 的主节点 3. 健康检查检测到 Shard1 主节点 RED 4. 查询路由将 Shard1 的查询发送到副本 5. 查询 "搜索引擎" → 返回 10 条结果,partial=false
6. 停止 Shard1 的副本 7. 查询路由发现 Shard1 无可用节点 8. 查询 "搜索引擎" → 返回结果,partial=true,X-Missing-Shards: 1 9. 结果中缺少 Shard1 上的文档,但仍然返回其他分片的结果
|
当前局限
- 共享文件系统要求主节点和副本在同一台机器或 NFS 上——跨机器需要文件复制方案
- 没有自动故障转移——副本不会自动提升为主节点
- 没有共识协议——主节点故障后需要人工干预
- 备份是全量复制——增量备份需要额外实现
练习
- 为一个分片配置一个只读副本,验证副本能读到主节点写入的数据
- 实现健康检查,模拟节点故障后的状态转换
- 停止一个分片的所有节点,验证降级查询行为
- 创建快照备份并恢复,测量 RPO 和 RTO
- 在不同 commit 频率下测量 RPO,画出 commit 频率 vs RPO 的关系
延伸阅读
- Elasticsearch: Index Recovery and Shard Allocation
- Apache Lucene: SnapshotDeletionPolicy, ReplicaNode
- Martin Kleppmann, “Designing Data-Intensive Applications”, Chapter 5: Replication