暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

ETCD 损坏,还没有备份,Milvus 数据还有救吗?

ZILLIZ 2026-06-25
123
最近在 Milvus 社区里,我们帮一位用户处理了个比较棘手的问题。
他的 Milvus 实例遇到了 ETCD 数据损坏。更麻烦的是,他没有保留原始数据,也没有提前使用 Milvus Backup 做备份,业务数据还不知道能不能拿回来。
这基本上是所有主流数据库能想象的最差的情况了。
但好在当时还有一个机会:MinIO 里的对象数据目录仍然存在。
基于这一背景,我们的数据恢复过程中,没有尝试直接“修好”旧 Milvus 实例,而是换了一条路:启动一套全新的 Milvus,重建 collection schema,再利用 Java SDK 的 bulkInsert 从旧 MinIO 里的 binlog 中把 segment 导入到新 collection。最终,这批数据被成功恢复。
这篇文章就是对这次社区救援过程的复盘。
(注意:这不是标准备份方案,也不是推荐的日常恢复方式。仅作为当 ETCD 已经损坏、没有原始数据、也没有 Milvus Backup 时,MinIO 中残留的 binlog 时,一种可能的挽救数据的机会。)

1. 社区紧急求助:ETCD 损坏后,collection 消失了

用户的 Milvus Standalone 实例原本运行正常,核心组件包括 Milvus、ETCD 和 MinIO。ETCD 负责保存 Milvus 的元数据,MinIO 负责保存对象数据,包括 insert log、stats log、index 文件等。
实验环境中,旧实例一开始是健康的:
    cd /home/zilliz/mnt/lcl/260milvus
    docker compose ps
    输出示例:
      NAME                IMAGE                                      SERVICE      STATUS
      milvus-etcd         quay.io/coreos/etcd:v3.5.18                etcd         Up
      milvus-minio        minio/minio:RELEASE.2024-12-18T13-15-44Z   minio        Up
      milvus-standalone   milvusdb/milvus:v2.6.17                    standalone   Up
      旧实例的 volumes 目录中包含三类数据:
        etcd/
        milvus/
        minio/
        为了复现社区用户遇到的问题,我们在实验中直接移走 ETCD 数据目录,模拟 ETCD 损坏:
          cd /home/zilliz/mnt/lcl/260milvus/volumes
          mv etcd ../etcd_old
          这一步之后,Milvus 无法再从 ETCD 中读取 collection 元数据。Attu 中也看不到原来的 collection。
          这就是问题的核心:ETCD 损坏后,业务数据文件并不一定丢了,但 Milvus 已经失去了理解这些文件的元数据。
          换句话说,MinIO 里的数据可能还在,但 Milvus 不知道这些数据属于哪个 collection,不知道 schema 是什么,也不知道哪些 segment 仍然是有效数据。

          2. 为什么 MinIO 还在,Milvus 却不能自动恢复?

          要理解这个问题,先要分清 ETCD 和 MinIO 在 Milvus 里的职责。
          ETCD 保存的是元数据,例如:
          • database 信息
          • collection 信息
          • partition 信息
          • schema
          • segment id
          • segment 状态
          • 索引元数据
          • compaction 后的新旧 segment 关系
          MinIO 保存的是对象数据,例如:
          • insert_log
          • stats_log
          • delta_log
          • index 文件
          • WAL 相关文件
          正常情况下,Milvus 需要先从 ETCD 里知道有哪些 collection、每个 collection 的 schema、哪些 segment 当前有效,然后再去 MinIO 读取对应的对象文件。
          如果 ETCD 完全不可读,MinIO 只能提供文件。它不能回答这些关键问题:
          • 这个 collection id 对应哪个业务 collection?
          • 这个 segment 是 live 还是 dropped?
          • 这个 segment 是否已经被 compaction 替代?
          • 这个 segment 是否来自旧实验或历史残留?
          • field id 和业务字段的映射是否仍然可信?
          所以,MinIO 里有文件,不等于 Milvus 能自动恢复 collection。

          3. 恢复思路:不修旧实例,而是新建 Milvus 再导入旧 binlog

          下图是这次恢复的整体思路:旧 ETCD 已经不可读,就不再尝试从旧 ETCD 中读取任何 key/value;可以用旧 MinIO 中的 insert_log 和 stats_log 作为数据来源;新 Milvus 负责创建新的元数据和新的 segment。
          这次恢复没有继续在旧实例上反复尝试启动或覆盖目录。
          原因很简单:如果旧实例状态已经不可靠,继续在原目录上操作,可能进一步破坏现场。更稳妥的做法是隔离旧数据,在新路径启动一套干净的 Milvus 实例。
          整体流程如下:
          1. 保留旧 MinIO 数据目录。
          2. 不读取旧 ETCD。
          3. 在新路径启动一套空的 Milvus Standalone。
          4. 根据已有信息创建相同 schema 的新 collection。
          5. 将旧 MinIO 中的 insert_log 和 stats_log 复制到新 MinIO 的 backup_data/ 前缀下。
          6. 扫描候选 segment。
          7. 使用 Java SDK bulkInsert 导入候选 segment。
          8. 校验 row count、主键范围、抽样数据和向量检索结果。
          9. 只把确认后的 segment 导入正式恢复 collection。
          在实验中,我们先启动一套新的 Milvus:
            cd /home/zilliz/mnt/lcl/260milvus/new
            docker compose up -d
            输出示例:
              [+] Running 3/3
              ✔ Container milvus-etcd        Started
              ✔ Container milvus-minio       Started
              ✔ Container milvus-standalone  Started
              接着准备新旧 MinIO 路径:
                OLD_BUCKET=/home/zilliz/mnt/lcl/260milvus/volumes/minio/a-bucket/files
                NEW_BUCKET=/home/zilliz/mnt/lcl/260milvus/new/volumes/minio/a-bucket/files
                把旧 MinIO 文件复制到新 MinIO 的 backup_data 前缀:
                  mkdir -p "$NEW_BUCKET/backup_data"
                  rsync -a "$OLD_BUCKET/insert_log" "$NEW_BUCKET/backup_data/"
                  rsync -a "$OLD_BUCKET/stats_log" "$NEW_BUCKET/backup_data/"
                  复制后,新 Milvus 可以通过下面这种路径读取旧 binlog:
                    backup_data/insert_log///
                    backup_data/stats_log///
                    这里的关键是隔离。不要把旧对象文件直接混入新实例自己的数据路径。放到 backup_data/ 下,便于扫描、导入和回滚。

                    4. 创建相同 schema 的新 collection

                    旧 ETCD 已经不可读,所以新 Milvus 不知道原 collection 的 schema。恢复前必须人工创建一个 schema 兼容的新 collection,建表的 schema 一般在业务代码中都可以找到。
                    本次实验中,旧 collection 名为 hello_milvus,核心字段如下:
                      {
                        "collection_name": "hello_milvus",
                        "fields": [
                          {
                            "name": "id",
                            "data_type": 5,
                            "is_primary_key": true,
                            "autoID": true
                          },
                          {
                            "name": "vector",
                            "data_type": 101,
                            "dim": 128,
                            "indexes": [
                              {
                                "index_name": "vector",
                                "index_type": "AUTOINDEX",
                                "metric_type": "COSINE"
                              }
                            ]
                          },
                          {
                            "name": "varchar",
                            "data_type": 21,
                            "max_length": 256
                          }
                        ],
                        "enable_dynamic_field": false,
                        "consistency_level": "Bounded",
                        "shards_num": 1
                      }
                      在新 Milvus 中,我们创建一个 schema 一致的新 collection:
                        from pymilvus import MilvusClient, DataType
                        client = MilvusClient(uri="http://localhost:19530", db_name="default")
                        collection_name = "hello_milvus_recover"
                        if client.has_collection(collection_name):
                            client.drop_collection(collection_name)
                        schema = MilvusClient.create_schema(
                            auto_id=True,
                            enable_dynamic_field=False,
                        )
                        schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
                        schema.add_field("vector", DataType.FLOAT_VECTOR, dim=128)
                        schema.add_field("varchar", DataType.VARCHAR, max_length=256)
                        index_params = MilvusClient.prepare_index_params()
                        index_params.add_index(
                            field_name="vector",
                            index_type="AUTOINDEX",
                            metric_type="COSINE",
                        )
                        client.create_collection(
                            collection_name=collection_name,
                            schema=schema,
                            index_params=index_params,
                        )
                        这里一定要注意:schema 必须和旧 binlog 兼容。
                        字段类型、向量维度、主键设置、动态字段配置如果不一致,导入可能失败。更糟糕的情况是导入成功,但数据解释不正确。

                        5. 扫描旧 MinIO 中的候选 segment

                        接下来扫描 backup_data/insert_log 下的旧 segment 路径:
                          NEW_BUCKET=/home/zilliz/mnt/lcl/260milvus/new/volumes/minio/a-bucket/files
                          find "$NEW_BUCKET/backup_data/insert_log" \
                            -mindepth 3 -maxdepth 3 -type d \
                            | sed "s#${NEW_BUCKET}/##" \
                            | sort
                          实验中扫描到两个候选 segment:
                            backup_data/insert_log/466895334486314780/466895334486314781/466895334486514943
                            backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                            路径格式是:
                              backup_data/insert_log///
                              对应本次实验:
                                old collection id: 466895334486314780
                                old partition id: 466895334486314781
                                candidate segment 1: 466895334486514943
                                candidate segment 2: 466895334486515954
                                这一步只能说明 MinIO 中存在两个候选 segment 目录。它不能说明两个都应该恢复。
                                这句话很重要。后面出现的 200 行问题,就是因为两个 segment 都能成功导入,但它们并不一定代表两批不同的业务数据。

                                6. 使用 Java SDK bulkInsert 导入旧 segment

                                本文实验使用的是 Java SDK  MilvusServiceClient.bulkInsert 来调用 backup=true 的 binlog 导入模式。核心接口是 bulkInsert,并设置几个关键参数:
                                  backup=true
                                  storage_version=2  #2.6 以后的版本使用的 storage v2,之前的版本是 v1
                                  files=["backup_data/insert_log/.../<segment_id>"]   #files 传的是 insert_log segment 路径
                                  核心代码如下:
                                    R<ImportResponse> importResp = client.bulkInsert(
                                        BulkInsertParam.newBuilder()
                                            .withDatabaseName(dbName)
                                            .withCollectionName(collectionName)
                                            .withPartitionName("_default")
                                            .withOption("backup", "true")
                                            .withOption("storage_version", storageVersion)
                                            .withFiles(Collections.singletonList(segmentPath))
                                            .build()
                                    );
                                    导入任务需要轮询状态,直到完成:
                                      while (true) {
                                          R<GetImportStateResponse> stateResp = client.getBulkInsertState(
                                              GetBulkInsertStateParam.newBuilder()
                                                  .withTask(taskID)
                                                  .build()
                                          );
                                          ImportState state = stateResp.getData().getState();
                                          System.out.println("Import state: " + state);
                                          if (state == ImportState.ImportCompleted) {
                                              break;
                                          }
                                          if (state == ImportState.ImportFailed ||
                                              state == ImportState.ImportFailedAndCleaned) {
                                              throw new RuntimeException("import failed: " + stateResp.getData());
                                          }
                                          Thread.sleep(1000);
                                      }
                                      Maven 依赖如下:
                                        <dependency>
                                          <groupId>io.milvus</groupId>
                                          <artifactId>milvus-sdk-java</artifactId>
                                          <version>3.0.1</version>
                                        </dependency>
                                        先导入第一个 segment:
                                          cat > /tmp/import_paths.txt <<'EOF'
                                          backup_data/insert_log/466895334486314780/466895334486314781/466895334486514943
                                          EOF
                                          执行导入:
                                            cd /home/zilliz/mnt/lcl/260milvus/etcd_recovery/restore-java
                                            DB_NAME=default \
                                            COLLECTION_NAME="hello_milvus_recover" \
                                            IMPORT_PATHS_FILE=/tmp/import_paths.txt \
                                            STORAGE_VERSION=2 \
                                            mvn -q exec:java \
                                              -Dexec.mainClass=io.milvus.recovery.ImportIntoExistingCollection
                                            输出结果:
                                              Target collection: default.hello_milvus_recover
                                              Segment paths: 1
                                              Importing segment: backup_data/insert_log/466895334486314780/466895334486314781/466895334486514943
                                              Import task ID: 466896649728034340
                                              Import state: ImportPending
                                              Import state: ImportStarted
                                              Import state: ImportStarted
                                              Import state: ImportStarted
                                              Import state: ImportCompleted
                                              collection statistics: [key: "row_count"
                                              value: "100"
                                              ]
                                              到这里看起来恢复成功了。row count 是 100,和原始 collection 一致。
                                              但真正的问题出现在第二个 segment。
                                              继续导入第二个 segment:
                                                cat > /tmp/import_paths.txt <<'EOF'
                                                backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                                                EOF
                                                继续导入到同一个 collection:
                                                  DB_NAME=default \
                                                  COLLECTION_NAME="hello_milvus_recover" \
                                                  IMPORT_PATHS_FILE=/tmp/import_paths.txt \
                                                  STORAGE_VERSION=2 \
                                                  mvn -q exec:java \
                                                    -Dexec.mainClass=io.milvus.recovery.ImportIntoExistingCollection
                                                  输出结果:
                                                    Target collection: default.hello_milvus_recover
                                                    Segment paths: 1
                                                    Importing segment: backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                                                    Import task ID: 466896649728083097
                                                    Import state: ImportPending
                                                    Import state: ImportStarted
                                                    Import state: ImportStarted
                                                    Import state: ImportCompleted
                                                    collection statistics: [key: "row_count"
                                                    value: "200"
                                                    ]
                                                    原始 collection 只有 100 行。为什么恢复后变成了 200 行?

                                                    7. 第一次踩坑:可读 segment 不等于 live segment

                                                    根因是:MinIO 中有两个 schema 兼容的 segment 目录,bulkInsert 把它们都成功导入了。但这两个 segment 很可能是同一批 100 行数据的两份物理形态。
                                                    这不是 bulkInsert 的 bug。
                                                    bulkInsert 的职责是导入你指定的 segment。只要路径合法、binlog 可读、schema 兼容,它就可以导入。它不会替你判断这个 segment 是当前有效数据,还是 compaction 前的历史形态。
                                                    进一步检查两个旧 segment 的文件结构:
                                                      for s in 466895334486514943 466895334486515954; do
                                                        echo "SEGMENT=$s"
                                                        find /home/zilliz/mnt/lcl/260milvus/volumes/minio/a-bucket/files/insert_log/466895334486314780/466895334486314781/$s \
                                                          -type f -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n' | sort
                                                      done
                                                      关键结果如下:
                                                        SEGMENT=466895334486514943
                                                        2026-06-10 12:15:36 1621  .../514943/0/.../part.1
                                                        2026-06-10 12:15:36 2960  .../514943/1/.../part.1
                                                        2026-06-10 12:15:36 47724 .../514943/101/.../part.1
                                                        SEGMENT=466895334486515954
                                                        2026-06-10 12:15:39 1621  .../515954/0/.../part.1
                                                        2026-06-10 12:15:39 2960  .../515954/1/.../part.1
                                                        2026-06-10 12:15:39 47724 .../515954/101/.../part.1
                                                        两个 segment 的特征非常接近:
                                                        • 字段目录一致:0、1、101
                                                        • 文件大小一致:1621、2960、47724
                                                        • 写入时间只差约 3 秒
                                                        这直接说明了它们不是两批不同业务数据,而是同一批数据在 Milvus 生命周期中的两个物理 segment。
                                                        所以,问题不是“哪个 segment 不能用”。问题是:两个都能用,但不应该都导入到正式恢复 collection。
                                                        这就是没有 ETCD 元数据时最大的风险。MinIO 能告诉你有哪些对象文件,但不能告诉你哪些 segment 是 live,哪些 segment 已经 dropped,哪些 segment 已经被 compaction 替代。

                                                        8. 如何校验:把候选 segment 分别导入不同 collection

                                                        这种情况下,不建议一口气把所有候选 segment 导入正式 collection。
                                                        更稳妥的做法是把候选 segment 分别导入不同的临时 collection,例如:
                                                          hello_milvus_recover_candidate_1
                                                          hello_milvus_recover_candidate_2
                                                          然后对比它们的数据是否一致。
                                                          可以从几个维度检查:
                                                          • row count 是否一致
                                                          • distinct primary key 数量是否一致
                                                          • 主键最小值和最大值是否一致
                                                          • 抽样字段是否一致
                                                          • 向量检索结果是否一致
                                                          • 是否存在重复主键
                                                          • 是否和业务侧记录一致
                                                          例如使用 PyMilvus 检查 row count、主键范围和 distinct id 数量:
                                                            from pymilvus import MilvusClient
                                                            client = MilvusClient(uri="http://localhost:19530", db_name="default")
                                                            for name in ["hello_milvus_recover", "hello_milvus_recover2"]:
                                                                print(name, client.get_collection_stats(name))
                                                                rows = client.query(
                                                                    collection_name=name,
                                                                    filter="id >= 0",
                                                                    output_fields=["id", "varchar"],
                                                                    limit=10000,
                                                                )
                                                                ids = [r["id"] for r in rows]
                                                                print(
                                                                    "query_rows", len(rows),
                                                                    "distinct_ids", len(set(ids)),
                                                                    "min", min(ids),
                                                                    "max", max(ids)
                                                                )
                                                            输出如下:
                                                              hello_milvus_recover {'row_count': 200}
                                                              query_rows 100 distinct_ids 100 min 466895334486314942 max 466895334486315041
                                                              hello_milvus_recover2 {'row_count': 100}
                                                              query_rows 100 distinct_ids 100 min 466895334486314942 max 466895334486315041
                                                              这个结果说明:hello_milvus_recover 的 row_count=200 是因为物理导入了两个 segment。但从查询结果、distinct id、主键范围看,两份 segment 对应的是同一批主键数据。
                                                              如果使用 Attu,也可以把两个候选 segment 分别导入两个临时 collection,然后直接对比数据样例、主键范围和行数。
                                                              这种校验很重要。没有 ETCD 时,恢复流程不能只依赖 MinIO 目录结构,必须依赖外部信息和数据对比来判断最终候选 segment。

                                                              9. 正确恢复:只导入确认后的 segment

                                                              在这次实验中,两个候选 segment 内容等价。本实验结合文件大小、写入时间、单独导入校验、主键范围和业务样本,最终选择较新的这个 segment 路径:
                                                                backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                                                                我们新建了一个恢复 collection:
                                                                  hello_milvus_recover2
                                                                  然后只导入这个较新的 segment:
                                                                    cat > /tmp/import_paths.txt <<'EOF'
                                                                    backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                                                                    EOF
                                                                    cd /home/zilliz/mnt/lcl/260milvus/etcd_recovery/restore-java
                                                                    DB_NAME=default \
                                                                    COLLECTION_NAME="hello_milvus_recover2" \
                                                                    IMPORT_PATHS_FILE=/tmp/import_paths.txt \
                                                                    STORAGE_VERSION=2 \
                                                                    mvn -q exec:java \
                                                                      -Dexec.mainClass=io.milvus.recovery.ImportIntoExistingCollection
                                                                    输出:
                                                                      Target collection: default.hello_milvus_recover2
                                                                      Segment paths: 1
                                                                      Importing segment: backup_data/insert_log/466895334486314780/466895334486314781/466895334486515954
                                                                      Import task ID: 466896649729193732
                                                                      Import state: ImportPending
                                                                      Import state: ImportStarted
                                                                      Import state: ImportStarted
                                                                      Import state: ImportCompleted
                                                                      collection statistics: [key: "row_count"
                                                                      value: "100"
                                                                      ]
                                                                      这次恢复结果和原始 collection 一致:100 行。
                                                                      最终我们没有把 MinIO 中所有可读 segment 全部导入,而是先对候选 segment 做探测和校验,再把确认后的 segment 导入正式恢复 collection。

                                                                      10. 这种方法有哪些局限?

                                                                      这次案例能恢复成功,但它并不代表“ETCD 坏了,只要 MinIO 在就一定能完整恢复”。
                                                                      这套方法有几个明确限制。
                                                                      第一,无法仅凭 MinIO 判断 segment 是否 live。
                                                                      MinIO 保存对象文件,但不保存完整的 segment 生命周期状态。segment 是否 live、是否 dropped、是否被 compaction 替代,原本要从 ETCD 元数据判断。
                                                                      第二,可能出现重复导入。
                                                                      这次 row count 从 100 变成 200,就是最直接的例子。如果把 compaction 前后的 segment 都导入,Milvus 会把它们登记为新 collection 中的 live segment,物理行数就会增加。
                                                                      第三,必须知道原 collection schema。
                                                                      如果 schema 完全丢失,恢复难度会大幅增加。字段类型、向量维度、主键配置、动态字段配置、字段 id 映射等信息都可能影响导入结果。
                                                                      第四,delete 和 delta log 会让问题更复杂。
                                                                      如果原 collection 有大量删除操作,仅导入 insert log 可能无法还原删除后的可见数据状态。是否需要处理 delta log,要结合具体数据和 Milvus 版本判断。
                                                                      第五,多 collection、多 partition、多 database 会让人工判断成本上升。
                                                                      这次实验只有一个 collection,数据量也很小,所以可以用 row count 和主键范围快速判断。真实生产环境中的 segment 数量可能很多,人工校验会复杂得多。
                                                                      所以,这种方法只能作为最后兜底手段。它可以帮助你在极端情况下尽量救数据,但不能替代标准备份。

                                                                      11. 经验总结:备份永远比抢救便宜

                                                                      当 ETCD 已经损坏、没有原始数据、也没有 Milvus Backup 时,MinIO binlog 仍然可能提供最后一次挽救数据的机会。但这不是个常态化解决方案。整个过程你需要重建 schema,扫描 segment,逐个导入,反复校验,还要承担重复导入和数据不一致的风险。
                                                                      更稳妥的做法,是不要让自己走到这一步。
                                                                      如果你在生产环境中使用 Milvus,建议至少做好三件事。
                                                                      第一,定期备份 ETCD。
                                                                      ETCD 是 Milvus 的元数据中心。collection、schema、segment 状态等关键信息都依赖它。一旦 ETCD 损坏,恢复难度会明显上升。
                                                                      第二,优先使用 Milvus Backup。
                                                                      Milvus Backup 是更适合生产环境的备份恢复工具。它比事后从 MinIO 里手动判断 segment 更可靠,也更适合自动化和演练。
                                                                      第三,如果是 Standalone 部署,定期备份完整 volumes 目录。
                                                                      对于 Docker Compose 或单机 Standalone 部署,至少要备份完整 volumes 目录,包括:
                                                                        volumes/
                                                                        ├── etcd/
                                                                        ├── milvus/
                                                                        └── minio/
                                                                        不要只备份 MinIO,也不要只备份 ETCD。Milvus 的完整恢复依赖元数据和对象数据同时可用。
                                                                        当然,更直接可靠的方案,是直接选择ZillizCloud,把复杂的运维与安全交给我们,企业只需要关注应用与创新的速度。

                                                                        作者介绍

                                                                        李成龙

                                                                        Zilliz 资深开源布道师

                                                                        阅读推荐
                                                                        如何通过修改Segment 形态,让你的 Milvus 性能原地翻倍
                                                                        Agent时代,静态容量规划注定失败!聊聊 Zilliz Cloud 的AutoScale设计
                                                                        官宣:Zilliz Vector Lakebase正式发布,作为向量数据库开创者,我们为何推出Vector Lakebase




                                                                        观点|从Vector Database到Vector Lakebase,如何定义AI data infra的下一个十年
                                                                        为什么每个重复建设AI 数据pipeline的企业,都需要一个Vector Lakebase

                                                                        文章转载自ZILLIZ,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                                                        评论