向量数据库的生产运维经验——上线之后才踩到的那些坑
向量数据库的生产运维经验——上线之后才踩到的那些坑
去年 9 月,我们的 Milvus 集群出了一个诡异的问题:查询越来越慢。
最开始是偶发的,某些请求延迟从正常的 200ms 爬到了 800ms。我以为是网络抖动,没太在意。两周后,平均延迟变成了 500ms,P99 达到了 3 秒。用户开始投诉了。
我把整个排查过程都记下来了,因为这种问题在网上几乎找不到现成的答案。向量数据库的运维经验目前还很稀缺,大家都在摸着石头过河。
这篇文章是我踩坑之后的总结,给还没踩到这些坑的人提个醒。
向量数据库不是 MySQL
这句话说起来简单,但真正理解它需要踩几个坑。
MySQL DBA 的直觉告诉你:慢了?加索引。加了索引还慢?加内存。内存够了还慢?分库分表。
这些直觉在向量数据库里部分有效,但有些完全不适用,有些需要换个方式理解。
关键差异 1:索引不是加速某个字段查询,是加速向量近邻搜索
向量数据库的索引(IVF、HNSW 等)是针对高维向量的近邻搜索算法构建的,不是 B-tree。索引参数的选择,会同时影响查询速度和查询精度(Recall),这是关系数据库里完全没有的概念——你不能通过加速来换取精度损失(或者说,MySQL 不会因为你加了索引就把查询结果算错)。
关键差异 2:数据写入和查询之间有延迟
Milvus 的数据写入是先进内存缓冲(Segment),后台异步 flush 到磁盘并建立索引。这意味着:你刚写入的数据,可能过几秒甚至几分钟才能被查询到。这是正常行为,不是 bug。但如果你不了解这个,会以为系统出问题了。
关键差异 3:删除是软删除,不立刻释放空间
Milvus 的删除操作不会立刻从磁盘删除数据,只是标记为已删除。物理清理(Compaction)需要显式触发或等待后台任务。大量频繁删除之后,物理空间不会减少,查询时还需要过滤掉那些被标记删除的数据,会影响性能。
关键差异 4:没有通用的 EXPLAIN 工具
MySQL 里遇到慢查询,EXPLAIN 一下就能看到执行计划,知道哪里慢。Milvus 目前没有等价的工具,排查需要靠监控指标和日志。
查询越来越慢的排查过程
回到我那个问题。我是这样一步步排查的:
第一步:排除网络和应用层问题
先在应用层加了详细计时,确认延迟主要在 Milvus 查询本身,不是网络或应用逻辑。
@Slf4j
public class TimedVectorSearch {
private final VectorStore vectorStore;
public List<Document> searchWithTiming(String query) {
long startTotal = System.currentTimeMillis();
// 阶段 1:向量化
long startEmbed = System.currentTimeMillis();
// ... 向量化
long embedTime = System.currentTimeMillis() - startEmbed;
// 阶段 2:向量检索
long startSearch = System.currentTimeMillis();
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(10)
);
long searchTime = System.currentTimeMillis() - startSearch;
long totalTime = System.currentTimeMillis() - startTotal;
log.info("Search timing - total: {}ms, embed: {}ms, search: {}ms",
totalTime, embedTime, searchTime);
return results;
}
}日志显示,searchTime 在持续增长,embed 时间稳定。问题确实在 Milvus。
第二步:检查 Milvus 指标
Milvus 暴露了 Prometheus metrics,我在 Grafana 里看这几个关键指标:
# 查询延迟(分位数)
milvus_querynode_sq_latency_sum / milvus_querynode_sq_latency_count
milvus_querynode_sq_latency_bucket
# 内存使用
milvus_querynode_entity_num
process_resident_memory_bytes
# Segment 状态
milvus_datanode_num_segments
milvus_datanode_num_growing_segments发现 milvus_datanode_num_segments 在持续增长,已经到了一个很高的数字,但 num_growing_segments(内存中的段)也很多。
第三步:理解问题根因
查了 Milvus 文档才明白:
我们的数据更新很频繁(增量索引),每次更新都会产生新的 Segment。Milvus 的后台 Compaction 没有跟上写入速度,导致 Segment 碎片化越来越严重。
Segment 碎片化的影响:
- 查询时需要扫描更多 Segment,每个 Segment 都要做索引查询然后汇总
- 每个 Segment 的搜索都有一定的固定开销,Segment 越多,开销越大
- 内存里驻留了大量小 Segment,内存压力大
第四步:解决
手动触发 Compaction:
@Service
@Slf4j
public class MilvusMaintenanceService {
private final MilvusServiceClient milvusClient;
@Value("${milvus.collection.name}")
private String collectionName;
/**
* 触发 Compaction,合并碎片化的 Segment
*/
public void triggerCompaction() {
log.info("Triggering manual compaction for collection: {}", collectionName);
CompactParam compactParam = CompactParam.newBuilder()
.withCollectionName(collectionName)
.build();
R<ManualCompactionResponse> response = milvusClient.manualCompact(compactParam);
if (response.getStatus() != R.Status.Success.getCode()) {
log.error("Compaction failed: {}", response.getMessage());
return;
}
long compactionId = response.getData().getCompactionID();
log.info("Compaction triggered, ID: {}", compactionId);
// 等待 Compaction 完成
waitForCompaction(compactionId);
}
private void waitForCompaction(long compactionId) {
GetCompactionStateParam stateParam = GetCompactionStateParam.newBuilder()
.withCompactionID(compactionId)
.build();
int maxRetries = 60;
for (int i = 0; i < maxRetries; i++) {
R<GetCompactionStateResponse> stateResponse = milvusClient.getCompactionState(stateParam);
if (stateResponse.getStatus() == R.Status.Success.getCode()) {
CompactionState state = stateResponse.getData().getState();
log.info("Compaction state: {}", state);
if (state == CompactionState.Completed) {
log.info("Compaction completed successfully");
return;
}
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.warn("Compaction did not complete within expected time");
}
/**
* 索引重建——在 Compaction 后,重建索引以获得最佳查询性能
*/
public void rebuildIndex() {
log.info("Starting index rebuild for collection: {}", collectionName);
// 先 Release collection(释放内存中的索引)
ReleaseCollectionParam releaseParam = ReleaseCollectionParam.newBuilder()
.withCollectionName(collectionName)
.build();
milvusClient.releaseCollection(releaseParam);
// 重建索引
DropIndexParam dropParam = DropIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName("embedding")
.build();
milvusClient.dropIndex(dropParam);
// 重新创建索引(HNSW 索引,平衡速度和精度)
Map<String, Object> indexParams = new HashMap<>();
indexParams.put("M", 16); // HNSW 图的每个节点最大邻居数
indexParams.put("efConstruction", 200); // 构建索引时的搜索宽度
CreateIndexParam indexParam = CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName("embedding")
.withIndexType(IndexType.HNSW)
.withMetricType(MetricType.COSINE)
.withExtraParam(new Gson().toJson(indexParams))
.build();
R<RpcStatus> response = milvusClient.createIndex(indexParam);
if (response.getStatus() != R.Status.Success.getCode()) {
throw new RuntimeException("Index rebuild failed: " + response.getMessage());
}
// 加载 collection 到内存
LoadCollectionParam loadParam = LoadCollectionParam.newBuilder()
.withCollectionName(collectionName)
.build();
milvusClient.loadCollection(loadParam);
log.info("Index rebuild completed for collection: {}", collectionName);
}
}Compaction + 索引重建之后,查询延迟从 500ms 降回了 150ms。
生产运维的关键监控指标
经过这次排查,我整理了一套向量数据库的监控指标清单:
@Component
@Slf4j
public class MilvusHealthMonitor {
private final MilvusServiceClient milvusClient;
private final AlertService alertService;
@Value("${milvus.collection.name}")
private String collectionName;
@Scheduled(fixedRate = 60000) // 每分钟检查
public void healthCheck() {
try {
checkCollectionStats();
checkMemoryUsage();
} catch (Exception e) {
log.error("Health check failed", e);
}
}
private void checkCollectionStats() {
GetCollectionStatisticsParam statsParam = GetCollectionStatisticsParam.newBuilder()
.withCollectionName(collectionName)
.build();
R<GetCollectionStatisticsResponse> response = milvusClient.getCollectionStatistics(statsParam);
if (response.getStatus() == R.Status.Success.getCode()) {
Map<String, String> stats = response.getData().getStats().stream()
.collect(Collectors.toMap(
KeyValuePair::getKey,
KeyValuePair::getValue
));
String rowCountStr = stats.get("row_count");
if (rowCountStr != null) {
long rowCount = Long.parseLong(rowCountStr);
log.info("Collection row count: {}", rowCount);
// 行数超过阈值,告警
if (rowCount > 10_000_000L) {
alertService.warn("Milvus collection row count exceeds 10M: " + rowCount);
}
}
}
}
/**
* 检查查询延迟(通过执行一次简单查询计时)
*/
@Scheduled(fixedRate = 300000) // 每 5 分钟
public void checkQueryLatency() {
// 用一个固定的测试向量测量延迟
float[] testVector = getTestVector();
long start = System.currentTimeMillis();
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVectors(Collections.singletonList(floatArrayToList(testVector)))
.withTopK(5)
.withMetricType(MetricType.COSINE)
.build();
milvusClient.search(searchParam);
long latency = System.currentTimeMillis() - start;
log.info("Health check query latency: {}ms", latency);
// 延迟超过阈值告警
if (latency > 1000) {
alertService.error("Milvus query latency too high: " + latency + "ms");
}
}
}数据备份方案
Milvus 没有像 MySQL 那样成熟的 mysqldump 工具,备份需要自己设计方案。
我们用的是两层备份:
层 1:原始文档备份
向量数据可以从原始文档重建,所以原始文档的备份是最重要的。我们把所有原始文档存在对象存储(MinIO / S3)上,这部分有完善的备份策略。
层 2:向量数据快照
Milvus 支持通过 Backup 工具做集合快照,但恢复较慢。对于我们的场景,这个快照主要用于灾难恢复,不作为主要备份手段。
@Service
@Slf4j
public class VectorBackupService {
private final MilvusServiceClient milvusClient;
private final ObjectStorageClient ossClient;
@Value("${milvus.collection.name}")
private String collectionName;
/**
* 导出向量数据到对象存储
* 注意:这是一个近似方案,通过批量查询导出,不是 Milvus 原生备份
* 适合数量级在百万以内的场景
*/
public void exportToObjectStorage(String backupPath) throws Exception {
log.info("Starting vector data export to: {}", backupPath);
// 查询所有数据(分批次)
long offset = 0;
int batchSize = 10000;
int exportedCount = 0;
while (true) {
QueryParam queryParam = QueryParam.newBuilder()
.withCollectionName(collectionName)
.withExpr("id >= 0") // 查所有
.withOffset(offset)
.withLimit(batchSize)
.addOutField("id")
.addOutField("content")
.addOutField("embedding")
// 加上所有重要的 metadata 字段
.addOutField("source_file")
.addOutField("document_key")
.addOutField("public_visible")
.build();
R<QueryResults> response = milvusClient.query(queryParam);
if (response.getStatus() != R.Status.Success.getCode()) {
throw new RuntimeException("Export query failed: " + response.getMessage());
}
List<Map<String, Object>> batch = extractResults(response.getData());
if (batch.isEmpty()) break;
// 序列化并上传
String batchJson = new ObjectMapper().writeValueAsString(batch);
String batchPath = backupPath + "/batch_" + (offset / batchSize) + ".json";
ossClient.upload(batchPath, batchJson.getBytes(StandardCharsets.UTF_8));
exportedCount += batch.size();
offset += batchSize;
log.info("Exported {} records...", exportedCount);
if (batch.size() < batchSize) break;
}
log.info("Export completed. Total: {} records", exportedCount);
}
}给运维同学的实操建议
我们团队有个传统:开发同学要参与自己系统的运维。基于这个经历,我给不熟悉 AI 系统的运维同学整理了几条实操建议:
建议 1:了解 Segment 的生命周期
Milvus 的数据以 Segment 为单位管理,理解 Growing Segment(内存)和 Sealed Segment(磁盘)的区别,理解 Flush 和 Compaction 的触发条件,是运维 Milvus 的基础知识。
建议 2:定期 Compaction 要纳入运维计划
写入频繁的场景,建议每周安排一次 Compaction + 索引重建,在业务低峰期执行。不要等问题出现再处理。
@Component
public class WeeklyMaintenanceTask {
private final MilvusMaintenanceService maintenanceService;
// 每周日凌晨 3 点执行
@Scheduled(cron = "0 0 3 ? * SUN")
public void weeklyMaintenance() {
log.info("Starting weekly maintenance");
maintenanceService.triggerCompaction();
maintenanceService.rebuildIndex();
log.info("Weekly maintenance completed");
}
}建议 3:内存是向量数据库的核心资源
Milvus 把索引加载到内存里才能高效查询。如果 collection 太大,内存装不下,就会出现频繁的 load/release,性能会很差。要定期监控内存使用,提前扩容。经验数值:Collection 的向量数据大小 * 3 以上的内存才安全。
建议 4:查询参数 ef 影响速度和精度
HNSW 索引的查询参数 ef(搜索时的候选集大小)是个重要的调优参数:
ef越大,精度越高,但速度越慢- 默认值通常是 64,对大多数场景够用
- 如果发现查询精度低,可以先增大
ef看看
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVectors(...)
.withTopK(10)
.withMetricType(MetricType.COSINE)
.withParams("{\"ef\": 128}") // 增大 ef 提升精度
.build();建议 5:不要在高峰期做运维操作
索引重建和 Compaction 都是 CPU/IO 密集型操作,会影响正在进行的查询。一定要在业务低峰期执行。
容量规划
向量数据库的容量规划需要考虑的维度和关系型数据库不同:
| 资源 | 估算方法 |
|---|---|
| 存储 | 向量维度 × 4字节 × 文档块数 × 1.5(索引开销) |
| 内存 | 存储大小 × 2-3(加载到内存后的放大系数) |
| CPU | 根据 QPS 和延迟要求评估,向量检索是 CPU 密集型 |
实例:1000 万条 1536 维向量(OpenAI Embedding 维度):
- 原始向量大小:1000万 × 1536 × 4字节 ≈ 58GB
- HNSW 索引:约额外 20-30GB
- 内存需求:至少 200GB 以上
总结
向量数据库的运维体系和关系型数据库差异很大。核心要记住几点:
- 查询慢了先查 Segment 碎片化情况,定期 Compaction 是必要的运维操作
- 删除不会立刻释放空间,需要 Compaction 才会清理
- 内存是核心资源,要重点监控和提前规划
- 写入的数据不是立刻可查的,有延迟窗口
- 建立完善的监控体系,比出了问题再排查效率高多了
踩坑是不可避免的,但踩完坑要把经验记下来,让后来的人不用再踩一遍。
