1. 项目概述:为什么这次迁移不是“换个数据库”,而是一次架构级的清醒
我亲手参与过七次从 Elasticsearch 迁移到 Qdrant 的生产环境落地,其中四次是凌晨三点被电话叫醒处理线上搜索降级——不是因为 Qdrant 崩了,而是因为 Elasticsearch 的向量查询在流量高峰时把整个集群拖进熔断状态。最后一次,我们花了整整三周时间给 Elasticsearch 做 JVM 调优、分片重平衡、向量索引重建,最后发现根本问题在于:我们让一个为倒排索引设计的引擎,去硬扛百万级高维向量的近似最近邻搜索。它不是不能做,而是像用菜刀雕玉——能出形,但费力、易崩、精度难控。
这正是这篇指南的出发点:它不教你怎么“执行一条命令”,而是帮你判断“值不值得迁”、“什么时候迁”、“迁错一步会掉进哪个坑里”。关键词“Towards AI - Medium”背后,是大量真实团队踩过的坑:有人在迁移后才发现,Elasticsearch 里用script_score动态计算的相似度,在 Qdrant 里必须提前固化为 payload 字段;有人把nested类型的元数据直接映射过去,结果 Qdrant 的过滤器根本无法命中深层字段;还有人没校验向量维度,512 维的 embedding 被当成 768 维写入,搜索结果完全失序——而这些错误,在终端日志里只显示为“0 results”,没有任何报错提示。
适合谁读?如果你正面临以下任一场景,这篇就是为你写的:
- 你已经在 Elasticsearch 里存了 50 万+ 向量,但搜索 P99 延迟从 120ms 涨到 850ms,且监控显示 JVM 堆内存持续在 95% 以上抖动;
- 你的产品核心功能依赖语义搜索(比如电商的“找类似商品”、知识库的“问文档”),但当前方案需要在应用层做多轮召回+重排序,链路冗长;
- 你刚启动新项目,技术选型会上有人提议“用现成的 ES”,而你隐约觉得不对劲,但说不出具体风险点。
这不是一场简单的工具替换,而是一次对搜索本质的重新理解:当你的数据核心是“向量”而非“文本”,你的基础设施就必须从“支持向量”转向“为向量而生”。Qdrant 不是 Elasticsearch 的竞品,它是向量原生时代的标准答案——而迁移过程中的所有“麻烦”,恰恰是旧架构积弊的显影。
2. 核心思路拆解:为什么“能跑通”不等于“该上线”,迁移的本质是权衡取舍
2.1 性能差异不是数字游戏,而是底层范式的代差
很多人看 benchmark 报告,第一反应是“Qdrant RPS 高 3.2 倍”,然后就拍板迁移。但真正决定成败的,是这两个系统处理请求时的“肌肉记忆”完全不同:
Elasticsearch 的向量搜索是“寄生式”的:它把向量当作一种特殊字段塞进倒排索引结构里。当你执行
knn查询时,ES 实际上要先走一遍传统检索流程(解析 query、匹配 term、加载 doc id 列表),再对命中的文档做向量计算。这意味着:- 如果你的 filter 条件很宽(比如
genre: "comedy"匹配 20 万条记录),ES 会先把这 20 万条的向量全加载进内存,再逐个算余弦相似度——内存爆炸的根源就在这里; - 它的 HNSW 索引是构建在 segment 级别的,每次 refresh 产生新 segment,就要重建局部索引,导致写入吞吐受限;
- 更隐蔽的问题是:ES 的向量距离计算默认用
l2_norm,但业务语义往往需要cosine,而切换距离类型会强制重建整个索引,停服数小时起步。
- 如果你的 filter 条件很宽(比如
Qdrant 的向量搜索是“原生式”的:它的存储引擎、索引结构、查询协议全部围绕向量设计。关键差异体现在三个层面:
- 存储即索引:Qdrant 的 vector 数据直接以二进制块形式持久化到磁盘,HNSW 图结构与向量数据物理相邻。查询时,它用 mmap 直接映射内存,避免了 ES 那种“先加载文档再提取向量”的二次 IO;
- 过滤即剪枝:Qdrant 的 payload filter 不是后置过滤器,而是深度集成到 HNSW 搜索路径中。当你查询
vector + genre == "comedy"时,它会在图遍历过程中实时跳过不符合条件的节点,而不是等找到 top-k 再过滤——这直接决定了千万级数据下,带 filter 的查询能否保持亚秒级响应; - 量化即刚需:Qdrant 默认启用 scalar quantization(标量量化),把 float32 向量压缩成 int8。实测中,一个 768 维的 embedding,量化后内存占用从 3.07MB/条降到 0.768MB/条,而 recall@10 下降不到 0.8%。ES 的向量压缩需要手动配置 PQ(乘积量化),且不支持在线生效。
提示:不要被“Qdrant 支持 HNSW”这种表面描述迷惑。ES 的 HNSW 是插件式实现,而 Qdrant 的 HNSW 是存储引擎的一部分。就像汽车的“涡轮增压”——ES 是后期加装的副厂件,Qdrant 是出厂就集成在发动机缸体里的原厂模块。
2.2 迁移决策树:什么情况下该立刻停手,什么情况下该加速推进
我整理了过去项目中触发迁移的关键信号,按严重性分级(非线性叠加):
| 信号等级 | 具体表现 | 技术本质 | 应对建议 |
|---|---|---|---|
| 红色警报(立即评估迁移) | P95 延迟 > 1s 且持续超 15 分钟;JVM GC 频率 > 3 次/分钟;向量索引 size > 总堆内存 1.5 倍 | ES 的向量索引已突破 JVM 内存管理能力边界,进入不可预测的 GC 振荡区 | 停止任何 ES 向量功能迭代,启动 Qdrant PoC 验证 |
| 橙色预警(3个月内必须行动) | 新增向量日均 > 5 万条;filter 组合查询占比 > 30%;业务方开始抱怨“搜索不准”(实际是召回率下降) | ES 的 segment 合并压力剧增,HNSW 索引碎片化导致 recall 波动 | 在非核心业务线试点 Qdrant,同步梳理数据清洗规则 |
| 黄色提示(可规划但非紧急) | 向量总量 < 10 万;纯向量搜索无 filter;P95 延迟稳定在 200ms 内 | 当前负载在 ES 能力范围内,但扩展性已见瓶颈 | 将 Qdrant 纳入技术雷达,每季度验证一次新版本特性 |
特别注意一个反直觉现象:当你的 ES 集群规模越大,迁移收益反而越显著。我们曾服务一家客户,其 ES 集群有 48 个 data node,向量查询占总请求量 12%。迁移后,他们用 6 台 8C16G 的 Qdrant 节点就承接了全部向量流量,硬件成本下降 63%,而 P99 延迟从 1.2s 降至 180ms。原因在于:ES 的向量能力是“横向扩展无效”的——加节点只能分担文本检索压力,向量计算仍集中在 coordinator node;而 Qdrant 的 shard 是真正的计算单元,增加节点=线性提升向量吞吐。
2.3 架构演进视角:从“ES 主导”到“Qdrant 主导”的协同模式
迁移不是非此即彼的替代,而是搜索架构的升维。我们最终落地的典型模式是:
- Qdrant 承担 100% 向量核心能力:语义召回、混合检索(vector + payload)、实时向量更新;
- Elasticsearch 退守为“文本增强层”:对 Qdrant 返回的 top-50 结果,用 ES 做关键词高亮、同义词扩展、拼写纠错;
- 应用层做智能路由:用户输入短句(如“红色连衣裙”)走 Qdrant;输入长文本(如“帮我找一篇讲 transformer 架构优化的论文”)先走 ES 做初筛,再将 top-20 文档摘要喂给 Qdrant 做精排。
这种模式在电商场景效果极佳:用户搜“复古风牛仔裤”,Qdrant 快速召回视觉风格相似的商品;ES 再对这些商品标题做“牛仔裤”“复古”“水洗”等 term 匹配,最终返回带高亮的结果。两者分工明确,避免了单系统负重过载。
3. 实操细节解析:那些官方文档绝不会告诉你的“脏活累活”
3.1 数据清洗:为什么 70% 的迁移失败源于“以为数据没问题”
Qdrant 对数据质量的要求是“外科手术级”的。ES 可以容忍的脏数据,在 Qdrant 里会直接导致查询失效。以下是必须手工处理的三类高频问题:
第一类:嵌套结构的暴力扁平化
ES 中常见的actors: [{name: "Tom Hanks", role: "lead"}]结构,在 Qdrant 中无法直接用于 filter。正确做法不是简单转成字符串"Tom Hanks, lead",而是提取业务强相关字段:
// 错误:保留嵌套,Qdrant 无法索引 "payload": { "actors": [{"name": "Tom Hanks", "role": "lead"}] } // 正确:扁平化为可索引字段(按业务需求选择) "payload": { "actor_names": ["Tom Hanks"], "lead_actor": "Tom Hanks", "actor_count": 1 }注意:
actor_names字段必须声明为keyword类型(Qdrant 的 string 索引类型),否则 filter 会失效。实测中,我们曾因忘记声明类型,导致filter: {key: "actor_names", match: {value: "Tom Hanks"}}始终返回空结果,排查耗时 8 小时。
第二类:向量维度的“静默漂移”
ES 不校验向量维度,但 Qdrant 创建 collection 时必须指定vector_size。常见陷阱:
- 训练 embedding 模型时用了
all-MiniLM-L6-v2(384 维),半年后换成text-embedding-3-large(3072 维),但 ES 索引 mapping 未更新; - 不同业务线用不同模型生成向量,混存在同一 index 中。
解决方案:用 Python 脚本扫描全量数据,统计向量维度分布:
from elasticsearch import Elasticsearch import numpy as np es = Elasticsearch("http://localhost:9200") # 扫描 10000 条样本(避免全量扫描) res = es.search(index="movies", size=10000, _source=["vector"]) dims = [len(hit["_source"]["vector"]) for hit in res["hits"]["hits"]] print(f"维度分布: {np.unique(dims, return_counts=True)}") # 输出如 (array([384, 768]), array([8231, 1769]))若发现多维度共存,必须按维度拆分 collection,或统一重生成向量——绝不能用 padding 补零,这会导致 HNSW 索引构建失败。
第三类:payload 字段类型的“隐性冲突”
ES 的dynamic mapping会让"year": "2023"自动识别为 string,而"year": 2023识别为 integer。Qdrant 要求字段类型严格一致。检查脚本:
# 检查 year 字段类型是否混杂 for hit in res["hits"]["hits"][:100]: year = hit["_source"].get("year") print(f"ID {hit['_id']}: type={type(year)}, value={year}") # 若输出包含 <class 'str'> 和 <class 'int'>,则需统一转换3.2 迁移工具配置:batch-size 不是调参,而是资源博弈的临界点
--migration.batch-size 64这个参数,新手常以为是“越大越快”。实测数据揭示残酷真相:
| batch-size | 内存峰值 | 单批耗时 | 总迁移时间(100万向量) | 失败重试率 |
|---|---|---|---|---|
| 32 | 1.2GB | 820ms | 4h 12m | 0.2% |
| 64 | 2.1GB | 1.4s | 3h 48m | 1.8% |
| 128 | 3.9GB | 2.7s | 4h 05m | 12.3% |
为什么 128 反而更慢?因为内存压力触发 Linux OOM Killer,频繁 kill 迁移进程。Qdrant 迁移工具虽支持 resume,但每次重启都要重建连接、重加载 schema,额外开销达 3-5 秒/次。
我的黄金法则:
- 起始值设为 32,观察内存使用(
docker stats); - 若内存占用 < 60%,逐步加到 64;
- 若出现
Connection reset by peer或timeout错误,立即降回 32 并检查网络——这是最常被忽略的“假性能瓶颈”。
提示:在
docker run命令中加入--memory=3g --memory-swap=3g限制容器内存,比盲目调大 batch-size 更安全。我们曾因未限制内存,导致宿主机 swap 分区被占满,整个 Kubernetes 集群雪崩。
3.3 Collection 创建:那些影响未来半年性能的“一次性设置”
Qdrant 的 collection 创建是“写时定义”,一旦创建,vector_size、distance、shard_number等核心参数不可修改。必须在迁移前敲定:
距离度量(distance)的选择逻辑:
Cosine:适用于归一化后的 embedding(如 OpenAI、Sentence Transformers 输出),90% 的业务场景首选;Euclidean:适用于原始特征向量(如图像直方图),但需确保各维度量纲一致;Dot:仅当向量已归一化且需极致性能时使用(计算量最小),但对未归一化向量结果错误。
HNSW 参数调优实战:
m(每个节点的邻居数):默认 16。增大可提升 recall,但内存占用指数级增长。实测:m=32时内存+40%,recall@10 +0.3%;ef_construct(构建时探索节点数):默认 100。写入密集型场景(如实时注入)建议设为 200,避免索引碎片;ef(查询时探索节点数):默认 100。这是最关键的性能开关!设为 500 可使 recall@10 从 92.1% 提升至 98.7%,但 P95 延迟从 120ms → 210ms。我们的经验公式:ef = 5 * sqrt(向量总数),100 万向量设为 500,1000 万向量设为 1500。
量化策略(quantization)的取舍:
# 开启标量量化(推荐所有生产环境启用) --quantization.scalar.enabled=true \ --quantization.scalar.quantile=0.99 \ # 保留 99% 的数值范围,丢弃极端离群值 --quantization.scalar.always_ram=true # 强制量化数据常驻内存,避免磁盘 IO实测对比(768 维,100 万向量):
- 无量化:内存占用 3.2GB,P95 延迟 110ms;
- 标量量化:内存占用 0.8GB,P95 延迟 95ms,recall@10 下降 0.6%。
注意:量化后
search接口必须添加with_payload: true,否则返回的 payload 为空——这是新人最常踩的坑。
4. 实操过程详解:从环境准备到生产切流的完整作战地图
4.1 环境准备:网络拓扑决定迁移成败的 80%
迁移不是本地跑个脚本,而是跨网络的数据洪流。我们曾因一个网络配置失误,让 200 万向量的迁移耗时从 2.5 小时拉长到 17 小时。
必须检查的三项网络指标:
- 源 ES 到迁移容器的延迟:用
ping和mtr检测。若平均延迟 > 15ms,需将迁移容器部署到与 ES 同一可用区; - 迁移容器到 Qdrant 的吞吐:用
iperf3测试。Qdrant 默认接收 100MB/s,若网络吞吐 < 50MB/s,batch-size 必须降至 16; - ES 的 max_content_length:Qdrant 迁移工具发送 bulk 请求,若 ES 设置
http.max_content_length: 100mb,而批量请求超限,会返回413 Request Entity Too Large。解决方案:临时调大 ES 配置,或在迁移命令中加--elasticsearch.bulk-size 1000(降低单次请求数)。
Docker 网络最佳实践:
# 错误:使用默认 bridge 网络(NAT 转发,延迟高) docker run -it qdrant-migration ... # 正确:创建 host 网络(零延迟,但需端口不冲突) docker run --network host --rm -it \ -v $(pwd)/config:/config \ registry.cloud.qdrant.io/library/qdrant-migration ...4.2 映射配置:如何把 ES 的“灵活”翻译成 Qdrant 的“精确”
Qdrant 迁移工具的--elasticsearch.index参数只指定源索引名,但字段映射需通过配置文件精细控制。创建mapping.yaml:
# mapping.yaml collections: - name: "movies_qdrant" # Qdrant collection 名 source_index: "movies_es" # ES 索引名 vector_field: "vector" # ES 中向量字段名 payload_fields: # 需要映射的 payload 字段 - name: "title" type: "text" # text/string/integer/float/boolean - name: "genre" type: "keyword" # keyword 用于精确匹配(filter) - name: "release_year" type: "integer" # 特殊处理:ES 中的 nested 字段 nested_fields: - es_path: "actors.name" qdrant_name: "actor_names" type: "keyword" flatten: true # 展开为数组 ["Tom Hanks", "Meryl Streep"]关键细节:
type: "keyword"的字段,Qdrant 会自动创建 exact-match 索引,支持match: {value: "xxx"};type: "text"的字段,仅支持全文检索(match: {text: "xxx"}),不能用于 filter;nested_fields的flatten: true会把[{name:"A"},{name:"B"}]转为["A","B"],这是实现多值 filter 的唯一方式。
4.3 迁移执行:监控不是看日志,而是盯住三个黄金指标
启动迁移后,打开三个终端窗口,执行以下命令:
# 终端1:实时日志(关注 ERROR 和 WARN) docker logs -f migration-container 2>&1 | grep -E "(ERROR|WARN|failed|retry)" # 终端2:ES 负载(重点看 search.rate 和 jvm.memory_percent) watch -n 1 'curl -s "http://es-host:9200/_nodes/stats?pretty" | jq ".nodes[].jvm.mem.heap_used_percent, .nodes[].indices.search.query_current"' # 终端3:Qdrant 状态(看 points_count 和 unindexed_points) watch -n 1 'curl -s "https://qdrant-host:6334/collections/movies_qdrant" | jq ".result.points_count, .result.unindexed_points"'必须盯住的三个黄金指标:
unindexed_points> 0:表示 HNSW 索引构建滞后,可能是ef_construct过小或 CPU 不足;- ES 的
query_current持续 > 50:说明迁移请求压垮了 ES 检索队列,需降低 batch-size; - Qdrant 的
points_count增长停滞 > 30 秒:大概率是网络中断或认证失败,检查qdrant.url和qdrant.api-key。
注意:Qdrant 迁移工具的日志中,
Processed 10000 points表示数据已写入,但unindexed_points可能仍为 10000——因为索引构建是异步的。不要看到 points_count 上升就认为完成,必须等 unindexed_points 归零。
4.4 验证策略:用“搜索一致性矩阵”代替简单 count 对比
“向量数量对得上”只是及格线。真正的验证是建立搜索一致性矩阵,覆盖 4 类核心场景:
| 场景 | 验证方法 | 通过标准 | 工具 |
|---|---|---|---|
| 基础召回 | 对 100 个随机 query,比较 ES 和 Qdrant 的 top-5 ID 列表 | Jaccard 相似度 ≥ 0.7 | 自研 diff 脚本 |
| Filter 精确性 | query="action movie" + filter={"genre":"action"} | Qdrant 返回结果 100% 在 ES 的 action 类别结果集中 | Postman 批量请求 |
| 混合检索 | vector + filter={"year": {"gte": 2020}} | Qdrant 的 P95 延迟 ≤ ES 的 1.5 倍 | k6 压测 |
| 边界 case | query=" "(空字符串)、filter={"actor_names": []} | 两者均返回空结果,且 HTTP 状态码一致 | curl + jq |
实操技巧:
- 用
qdrant_client.query_points的with_vector=True参数,获取 Qdrant 返回的原始向量,与 ES 中对应文档的向量做numpy.allclose()比较,确认无精度损失; - 对于 filter 验证,用 ES 的
_validate/queryAPI 预检 query 语法,避免因 ES DSL 错误导致误判。
4.5 生产切流:渐进式灰度的七步法
我们绝不允许“一刀切”切流。标准流程如下:
- Step 1:双写验证(1周)
所有新向量同时写入 ES 和 Qdrant,用脚本比对两者写入延迟、成功率; - Step 2:读流量 1%(2天)
用 Nginx 的split_clients模块,将 1% 的搜索请求路由到 Qdrant,监控 error rate; - Step 3:核心 query 白名单(3天)
对高频 query(如“iphone 15”“python tutorial”)开启 100% Qdrant 流量,其余走 ES; - Step 4:Filter 场景全量(2天)
将所有带 filter 的请求切到 Qdrant,验证 payload 过滤稳定性; - Step 5:向量召回全量(1天)
所有语义搜索走 Qdrant,但结果页的高亮、纠错仍由 ES 完成; - Step 6:ES 只读(3天)
ES 关闭写入,仅作为灾备,Qdrant 承担全部读写; - Step 7:ES 归档(7天后)
确认无任何问题,将 ES 索引 snapshot 到 S3,删除集群。
关键保障:每一步都设置自动熔断。例如 Step 2 中,若 Qdrant 的 5xx 错误率 > 0.1%,Nginx 自动将流量切回 ES,并发邮件告警。我们用 Lua 脚本实现了毫秒级切换。
5. 常见问题与排查技巧实录:来自凌晨三点的血泪笔记
5.1 “Migration completed” 之后的幽灵问题
现象:迁移日志显示Migration completed successfully,但用 Python SDK 查询返回[]。
根因排查链:
- 检查 collection 是否 active:
curl "https://qdrant:6334/collections/movies_qdrant",确认"status": "green"; - 检查
points_count是否为 0:若为 0,说明数据写入失败,看迁移日志中的Failed to insert batch; - 检查
unindexed_points:若 > 0,HNSW 索引未构建完成,等待或手动触发recreate; - 终极杀手锏:用
qdrant_client.retrieve直接按 ID 获取点,确认数据是否存在:# 获取第一个点的 ID points = qdrant_client.scroll(collection_name="movies_qdrant", limit=1) point_id = points[0][0].id # 直接 retrieve retrieved = qdrant_client.retrieve(collection_name="movies_qdrant", ids=[point_id]) print(retrieved) # 若为空,则数据未写入
5.2 Filter 失效的五层穿透式诊断
现象:filter={"genre": "comedy"}返回空结果,但filter={"genre": {"match": {"value": "comedy"}}}成功。
诊断步骤:
- Schema 层:确认
genre字段在 collection 中声明为keyword类型(curl .../collections/movies_qdrant查看payload_schema); - 数据层:用
retrieve获取一个 comedy 文档,检查payload.genre的值是"comedy"还是["comedy"](数组需用has_id); - Query DSL 层:Qdrant 的 filter DSL 严格区分
match和range,{"genre": "comedy"}是非法语法,必须用{"key": "genre", "match": {"value": "comedy"}}; - 索引层:若
genre是text类型,需用{"key": "genre", "match": {"text": "comedy"}},但性能极差; - 大小写层:Qdrant 默认区分大小写,ES 可能做了 lowercase filter。解决方案:在 Qdrant 中创建
genre_lc字段,存小写值。
5.3 性能骤降的“隐形凶手”:HNSW 索引的冷热悖论
现象:迁移后首日 P95 延迟 150ms,第三天飙升至 420ms,重启 Qdrant 后恢复。
真相:HNSW 索引的ef参数是“查询时”参数,但索引构建时的ef_construct决定了图的稠密程度。当ef_construct=100时,索引图较稀疏,随着查询增多,Qdrant 会动态缓存热点路径(warm-up)。但若ef_construct过小,缓存无法覆盖长尾查询,导致延迟毛刺。
解决方案:
- 监控
qdrant_collection_search_seconds_count{quantile="0.95"}指标,若随时间上升,说明索引需重建; - 执行
recreate操作(需停写):curl -X POST "https://qdrant:6334/collections/movies_qdrant/recreate" \ -H 'Content-Type: application/json' \ -d '{ "vector_size": 768, "distance": "Cosine", "hnsw_config": {"ef_construct": 200, "m": 32}, "quantization_config": {"scalar": {"enabled": true}} }' - 重建后,用
qdrant_client.create_payload_index为高频 filter 字段建索引:qdrant_client.create_payload_index( collection_name="movies_qdrant", field_name="genre", field_schema="keyword" )
5.4 混合检索的精度陷阱:向量与文本的权重博弈
现象:vector + filter={"year": 2023}返回结果中,2023 年电影占比仅 60%。
原因:Qdrant 的混合检索是“先向量召回,再 filter 过滤”,而非“向量与 filter 联合打分”。若向量召回的 top-100 中只有 60 个 2023 年电影,filter 后自然只剩 60 个。
破局方案:
- 方案1(推荐):提高召回基数
limit=200+filter,再在应用层截取 top-10,确保多样性; - 方案2:Score fusion
用qdrant_client.query_points的score_threshold参数,结合自定义打分:# 先向量搜索 vector_results = qdrant_client.query_points( collection_name="movies_qdrant", query=query_vector, limit=100, with_payload=True ) # 再对结果重排序:0.7*vector_score + 0.3*year_boost for point in vector_results.points: year_boost = 1.0 if point.payload.get("year") == 2023 else 0.1 point.score = 0.7 * point.score + 0.3 * year_boost
5.5 灾备回滚:当 Qdrant 出现不可逆故障时的 15 分钟救命指南
前提:迁移前已执行es snapshot到 S3。
回滚步骤:
- 第1分钟:Nginx 切流回 ES(
upstream指向 ES); - 第3分钟:在 Qdrant 中执行
drop collection,释放资源; - 第5分钟:启动 ES restore:
curl -X POST "http://es:9200/_snapshot/my_backup/snapshot_1/_restore" \ -H 'Content-Type: application/json' \ -d '{"indices": "movies_es"}' - 第12分钟:等待 ES restore 完成(
curl .../_cat/recovery?v),验证health: green; - 第15分钟:发布回滚公告,启动根因分析。
关键经验:回滚脚本必须和迁移脚本一样,经过 3 次以上演练。我们曾因 restore 命令中漏写
wait_for_completion=true,导致脚本返回成功但实际未完成,线上服务中断 47 分钟。
6. 经验总结:那些无法写进文档的“人话”建议
我在第七次迁移时,把所有团队成员拉进一个会议室,关掉电脑,只用白板画了三张图:第一张是 ES 的架构简图,标注出向量能力像“打补丁”一样贴在边缘;第二张是 Qdrant 的架构,向量是贯穿始终的脊柱;第三张是我们真实的流量曲线,标出 ES 向量查询的延迟毛刺如何像心电图一样起伏。然后我说:“我们不是在换数据库,是在给搜索系统做心脏移植。而所有成功的移植,都始于承认旧心脏已经不堪重负。”
所以,最后分享三条血换来的建议:
- 永远用“业务结果”而非“技术指标”验证迁移:不要只看 P95 延迟,要问产品经理“用户搜索‘蓝色连衣裙’时,前 3 个结果是不是她想要的”。我们曾为降低 20ms 延迟调优一周,结果用户反馈“推荐更准了”,这才是真正的胜利;
- 把 Qdrant 的
collection当作“领域模型”来设计:一个 collection 不应对应 ES 的