
上个月有个做知识库产品的朋友找我说他的检索服务在文档量涨到一千两百万之后彻底不灵了搜索框敲下去要等两三秒聚合面板更是直接超时运维那边天天喊磁盘水位告急。他问我的第一句话就是是不是该换一个比 ES 快 5 倍的搜索引擎。这话我听过太多次了多到我一听见快 5 倍就知道接下来要花两个小时去掰扯基准口径。搜索引擎的性能从来不是一个单一数字它跟你查什么、数据长什么样、索引怎么建、硬件给多少强相关。同一个引擎换来换去可能只让 P99 从 2.1 秒降到 1.8 秒换个姿势用反而能让它从 2.1 秒掉到 300 毫秒。这篇就把我这些年踩过的坑、做过的对照测试、以及真正把检索链路提速的经历整理出来从 ES 为什么会慢讲起再到几款常被拿来对比的引擎到底强在哪、弱在哪最后给一套可以直接照着跑的落地流程。如果你正在被写入吞吐、向量检索延迟、存储空间这三件事中的任意一件折磨这篇应该能帮你把方向选对少走几个月的弯路。1. ES 在什么负载下会变慢先把慢的根因说透很多人抱怨 ES 慢其实抱怨的是三件完全不同的事写入慢、检索慢、聚合慢。这三件事背后的瓶颈机制差别很大混在一起谈就会得出ES 不行这种没用的结论。我习惯先把症状拆开再看是哪一段链路在拖。1.1 写入链路上的三次放大refresh、translog、mergeES 的写入不是写进去就完事它是一条流水线。文档先进入内存缓冲区同时追加到 translog默认每 1 秒执行一次 refresh把缓冲区里的数据变成一个可搜索的 segmenttranslog 默认每个请求都 fsync后台还有 merge 线程不断把小 segment 合并成大 segment。这条设计的代价是写入吞吐被三件事同时限制——refresh 频率、translog 落盘策略、以及 merge 的磁盘 IO 占用。批量导入场景下这个代价特别明显。我见过一个案例单节点每秒写 8000 条CPU 只用了 40%磁盘 util 却常年在 90% 以上最后定位到就是 merge 在抢 IO。当时把refresh_interval从 1s 改成 -1导入期间彻底关闭自动刷新translog 的durability从request改成async再配合force_merge在导入结束后统一做一次整体导入时间从 47 分钟降到了 11 分钟。注意这个改法只适合离线批量导入线上实时写入不要关 refresh否则你的搜索结果会有明显的看不见新数据窗口。另一个常被忽略的点是分片数量。分片不是越多越快。每个分片是一个独立的 Lucene 索引有自己的内存结构、自己的 merge 线程、自己的文件句柄。分片过多时一次查询要扇出到几十个分片再合并结果协调节点的队列会先炸掉。我的经验值是按单分片 20GB 到 40GB 规划先按这个比例算出总量再和节点数 × 每节点核数做个平衡通常不会差太多。1.2 向量检索为什么最容易成为瓶颈近几年 ES 最常见的性能事故都出在dense_vector上。原因是向量检索和倒排检索的资源模型完全不同倒排检索主要吃内存和磁盘随机读向量检索吃的是大量的浮点运算。ES 的向量索引是per-segment的每个 segment 里维护一份独立的 HNSW 图。这意味着 segment 越多knn查询需要遍历的图就越多召回和延迟都会恶化。我实测过同一份 200 万条向量数据768 维在 segment 数量 30 个和 force_merge 到 1 个的情况下同样的 top-10 查询 P95 分别是 240ms 和 62ms差了三倍多。很多人说向量检索时间太长其实一大半是这个原因。第二个坑是带过滤的向量检索。ES 的knn支持filter但在旧版本里过滤是后置的可能出现先取 top-100 再过滤导致结果不足的情况新版本做了前置过滤的优化但过滤条件的选择性太强时图遍历会退化。如果你的业务是在某个租户的数据里做向量检索建议把租户字段做成路由键让每个租户的数据落在一起或者干脆按租户分索引别指望一个全局大索引扛住所有租户的过滤。1.3 JVM 堆与文件系统缓存之间的拉扯ES 是 JVM 应用这个事实带来两个老生常谈但每次都有人踩的问题堆不要超过 32GB否则压缩指针失效对象头膨胀实际可用内存反而变少以及剩下的内存全部留给文件系统缓存。Lucene 的倒排表、FST、doc_values 都依赖 page cache 才能跑得快一旦堆吃掉太多物理内存page cache 被挤出去查询就会开始大量随机读磁盘延迟瞬间上一个数量级。我有一次帮人排查一个周日半夜慢、工作日正常的怪现象最后发现是周日跑定时备份任务备份进程把 page cache 全冲掉了等到周一上班时缓存还没完全热回来。解决办法很土备份任务加ionice降优先级并且备份完成后主动跑一遍热点查询预热。所以当你准备换引擎之前先确认一下现在的瓶颈到底在哪一层别把 page cache 的问题当成引擎选型问题。2. 快 5 倍这句话该怎么验证基准口径决定结论我参与过不少次引擎选型的对照测试最深的体会是同一个引擎在不同测试口径下的性能差异比不同引擎之间的差异还大。所以看到任何快 N 倍的宣传先问四个问题数据集多大、文档多大、查什么类型的查询、并发多少。2.1 四类典型负载和它们各自的指标口径第一类是面向用户的搜索特点是查询种类多、有分页、有高亮、QPS 中等但要求低延迟核心指标是 P95/P99 延迟和召回质量。第二类是日志/事件检索特点是无固定 schema、写入量巨大、查询以时间范围加关键词过滤为主核心指标是摄入吞吐和磁盘压缩比。第三类是向量相似检索核心指标是单查询延迟、召回率recallk和索引内存占用。第四类是分析聚合特点是扫大量数据做分组统计核心指标是扫描速度和内存峰值。这四类负载对引擎的要求几乎是正交的。一个在日志检索上比 ES 快 5 倍的引擎可能在聚合分析上比 ES 慢因为它压根没为列式扫描做优化。所以选型第一步不是看跑分而是先给你的业务归类明确你最不能妥协的是哪个指标。2.2 我做过的一组对照测试数据下面这组数据来自我自己在一台 16 核 / 64GB / NVMe 的机器上做的对照测试数据集是 300 万条中文商品文档平均文档 1.2KB查询组合是关键词加过滤加排序并发 50。这些数字只代表这个场景换个数据集一定会变请当参考而不是结论。引擎索引耗时查询 P95查询 P99索引磁盘占用冷启动查询 P95ES 8.x 单节点 1 分片21 min48 ms120 ms3.4 GB410 msES 8.x 单节点 6 分片24 min76 ms190 ms3.5 GB620 msMeilisearch6 min9 ms22 ms1.1 GB35 msTypesense5 min11 ms26 ms1.3 GB40 msManticore Search8 min14 ms31 ms1.6 GB55 msQuickwit9 min18 ms日志类查询44 ms0.9 GB列式压缩60 ms从这张表能看出快 5 倍是怎么来的如果把查询延迟当作唯一指标Meilisearch 在这个场景下确实比 ES 快了五倍左右。但请注意ES 在这张表里输的不是架构而是默认配置——它默认开了聚合、开了_source全量存储、分了多个分片、refresh 每秒一次。后面我会讲怎么把这些配置拧到合理位置那时候差距会明显缩小。2.3 容易被自己骗到的三个测试陷阱第一个陷阱是没预热。Lucene 类的引擎首次查询要加载 FST 和 doc_values 到 page cache冷启动延迟可能是热态的 5 到 10 倍。如果不预热就采样你测到的其实是冷启动速度不是稳态性能。我的做法是正式采样前先跑 2000 次混合查询把缓存喂满。第二个陷阱是只看平均延迟不看尾延迟。平均延迟 30ms 听起来很美但 P99 是 800ms 的话用户依然会觉得卡。搜索类业务永远盯 P95 和 P99平均值基本没有参考价值。第三个陷阱是忽略召回质量只看快。有引擎快是因为它默认只返回近似结果或者干脆分词策略很粗。我见过一个案例换引擎后延迟从 90ms 降到 15ms上线两周后客服投诉搜索搜不到东西一查发现新引擎的默认分词对中文长词处理得不好。所以对照测试必须带上人工标注的查询集测 recall10速度和质量一起看。3. 几个常被推荐的引擎各自适合什么场景市面上被拿来和 ES 对比的引擎我基本都跑过一轮。下面不复述官方文档的功能列表只说我自己在真实项目里的感受——哪些坑我踩过哪些场景我最后真的用了它。3.1 按底层写入模型给它们分个类理解一个搜索引擎的性能最有效的切入点是看它的写入与索引模型。大致分三派内存优先型代表是 Meilisearch 和 Typesense。整个倒排索引常驻内存写内存再异步落盘。优点是查询极快、毫秒级、冷启动快缺点是内存成本高超大数据集几亿文档会非常贵且分布式能力相对弱。段合并型代表是 Lucene 系ES、OpenSearch和 Tantivy 系Quickwit、Meilisearch 底层也用 Tantivy。写入生成不可变 segment后台合并。优点是写入友好、水平扩展成熟缺点是查询要处理多段、merge 吃 IO、需要调优。列式/倒排混合型代表是 Quickwit 和 Manticore 的部分索引模式。面向日志和时序压缩比极高适合写多读少、按时间范围查的场景。这个分类能解释很多现象。比如为什么 Meilisearch 在百万级文档上碾压 ES因为 ES 每次查询要跨 segment 合并打分而 Meilisearch 直接在一份内存倒排上做前缀检索。也能解释为什么 Meilisearch 在十亿级文档上就不太好用内存装不下。3.2 向量检索能力横向对比如果你的核心需求是向量检索那选型逻辑和全文检索完全不同。这张表是我按实际使用体验整理的标注了我认为的适用边界引擎/方案向量索引过滤向量内存占用我的评价ES dense_vectorHNSWper-segment支持版本间差异大高适合已有 ES 集群、向量只是附加需求的场景不要指望它做十亿级向量OpenSearch k-NNHNSW / IVF支持中高与 ES 类似插件生态稍好Manticore SearchHNSW / IVF支持中单机性能好SQL 接口顺手运维简单VespaHNSW 多级支持能力最强高大规模向量检索的强者代价是学习曲线陡专用向量库如 Qdrant、MilvusHNSW / IVF / 量化支持可调纯向量场景首选但要额外维护一套系统我的建议很直接向量检索的延迟问题八成不是引擎选错了是量化没做、段没合并、过滤没前置。在换系统之前先试试把向量做标量量化float32 转 int8内存直接降到四分之一召回损失通常在 1% 到 3% 之间再做一次 force_merge延迟往往就能砍掉一半。这两步做完还不满足再考虑上专用向量库。3.3 我给别人的选型建议清单选型这件事我总结成一句话先看数据量级和读写成比例再看团队运维能力最后才看跑分。文档量在千万级以内、以搜索为主、团队没有专职检索运维优先考虑单机就能扛住的方案内存优先型足够用部署简单出问题好排查。文档量在亿级以上、有成熟的运维体系、需要聚合分析留在 Lucene 系把调优做扎实比迁移成本低得多。写多读少、以时间范围查询为主直接上列式日志检索方案压缩比和摄入吞吐是它的强项。纯向量检索、规模在千万级以上考虑专用向量库把全文和向量拆成两套系统各做各的。向量只是附属需求、主体还是全文检索别拆系统在现有引擎上做量化加调优。这里要特别提醒一句不要为了追求跑分上的倍数而拆系统。每多一套系统就多一份数据同步、多一份一致性风险、多一份半夜被叫起来的概率。我自己就吃过这个亏为了向量性能拆出一套独立服务结果同步延迟导致搜索结果显示的向量相似度和实际内容对不上排查了三天。4. 从零把一条检索链路跑通可复现的最小闭环光聊选型不够下面给一套我实际用过的落地流程。以内存优先型引擎为例这类引擎上手最快也最容易验证效果同时给出 ES 侧的对照写法。4.1 环境准备与索引定义容器化部署是验证阶段最省事的方式一条命令起服务数据挂在本地卷上随时删掉重来docker run -d --name search-demo \ -p 7700:7700 \ -v /data/search-demo:/meili_data \ -e MEILI_MASTER_KEYdev_only_key \ -e MEILI_ENVdevelopment \ getmeili/meilisearch:v1.8索引定义这一步很多人偷懒直接让引擎自动推断字段类型结果中文分词出问题、数字被当字符串排序。手动定义映射能省掉后面大量返工。以 Typesense 为例schema 里明确每个字段的类型、是否需要分面、是否需要索引{ name: products, fields: [ { name: title, type: string, infix: true }, { name: category, type: string, facet: true }, { name: brand, type: string, facet: true }, { name: price, type: float }, { name: created_at, type: int64 }, { name: description, type: string, index: false } ], default_sorting_field: created_at }注意description我设成了index: false。这是个非常实用的技巧长文本字段如果不参与检索就不要建索引它只会拖慢写入、撑大磁盘。真正需要全文检索的往往只有标题和几个标签字段正文可以用检索命中后按 ID 回源数据库的方式处理。4.2 批量写入与 Java 侧的异步写入批量写入的核心是批大小和并发数的平衡。批太小网络往返开销占比高批太大单次请求超时风险高。我一般从 1000 条一批、4 到 8 个并发开始试观察服务端的队列深度再调。import json import requests def bulk_index(docs, batch_size1000): url http://localhost:7700/indexes/products/documents headers { Content-Type: application/json, Authorization: Bearer dev_only_key, } for i in range(0, len(docs), batch_size): batch docs[i:i batch_size] resp requests.post(url, headersheaders, datajson.dumps(batch)) resp.raise_for_status() print(f已写入 {i len(batch)} 条)如果主体系统是 Java 且数据还在 ES 里写入侧建议用官方的高层批量客户端而不是手搓 HTTP。老版本用BulkProcessor新版本推荐用BulkIngester后者支持背压和更细的刷盘控制。关键参数有三个flushInterval、单个 bulk 请求的目标大小、以及最大并发请求数。我踩过的坑是只调大了批量大小却没调并发结果队列积压内存里堆了几十万条待发送的文档最后 OOM。// 伪代码展示参数关注点而非完整实现 BulkIngester ingester BulkIngester.of(b - b .client(client) .maxOperations(2000) .flushInterval(5, TimeUnit.SECONDS) .maxConcurrentRequests(8) .listener(new BulkListener() { Override public void afterBulk(long id, BulkRequest request, BulkResponse response) { if (response.hasFailures()) { log.error(部分文档写入失败{}, response.buildFailureMessage()); } } }));写入失败一定要处理。我见过太多项目hasFailures()直接忽略最后数据缺了一批还找不到原因。至少要把失败的文档 ID 和错误原因打到日志里并且做一份可重放的死信队列。4.3 查询接口与相关性调优查询侧最容易犯的错误是把数据库思维带进搜索引擎。比如用完全匹配的思路写查询或者指望引擎自动理解同义词。搜索引擎的相关性是一层层堆出来的需要显式配置。以中文场景为例必须处理的三件事分词、同义词、字段权重。字段权重是最容易被忽略但收益最大的一项。同样是小米这个词出现在标题里和出现在描述里重要性完全不同权重应该差 3 到 5 倍。配置好权重之后通常不需要复杂的排序表达式就能得到不错的相关性。另外分页要慎用深分页。offset10000这种查询在任何引擎上都会变慢因为引擎需要把前面一万条取出来再丢掉。需要遍历全量数据的场景用游标或者search_after这类基于排序值的翻页方式。4.4 从 MySQL 增量同步到检索引擎数据源在 MySQL 是绝大多数国内项目的现状所以同步链路必须讲。主流方案有三条我按推荐度排方案原理优点代价binlog 订阅如 Canal伪装成从库解析 binlog 转成变更事件近实时、对业务无侵入、能拿到全量变更需要额外组件和位点管理定时全量加增量查询按更新时间字段轮询拉取实现最简单、无需额外组件有延迟、删除操作难捕获、对数据库有压力双写业务代码写完库再写索引实时性最好侵入业务、一致性难保证、回滚困难Canal 方案要特别注意两点。第一是位点管理服务重启后必须能从上次消费的位置继续否则要么丢数据要么重复消费重复消费时靠文档 ID 做幂等覆盖即可丢数据就是事故。第二是删除事件的语义物理删除会产生一条 DELETE 类型的事件如果业务用的是逻辑删除那就得在应用层判断状态字段变化后主动删除索引文档否则会出现库里已经删了但搜索还能搜到的脏数据。5. 不换引擎也能提速存储与写入的优化清单这一节是我最想让大家先看完再决定要不要换系统的部分。下面这些调整我几乎在每个 ES 项目上都做过收益经常比换引擎还大。5.1 Mapping 层面的取舍告诉引擎你不需要什么ES 的默认 mapping 是全都给你存一份。_source存原文、doc_values存列式、倒排存索引一个字段的磁盘占用可能是原始数据的 3 到 5 倍。优化手法有这些不需要参与排序和聚合的字段把doc_values关掉。不需要检索的字段把index关掉或者干脆不要放进索引只留在数据库里。不需要原始值的字段考虑裁剪_source或者用includes只保留必要字段。长文本字段用合适的分析器别用标准分词器硬切中文分出来的词元数量会多好几倍。我做过一次完整的 mapping 优化索引磁盘占用从 3.4GB 降到了 1.1GB查询 P95 从 48ms 降到 31ms。改动内容只是关了六个不需要聚合字段的 doc_values、把三个长文本字段的索引去掉、换了个中文分词器。这个投入产出比比换个引擎重新搭一套系统高太多了。5.2 压缩与量化向量和倒排的通用思路对于向量字段压缩的收益是数量级的。float32 是 4 字节一个维度768 维就是 3KB 一条一千万条就是 30GB纯靠内存基本扛不住。标量量化到 int8 之后变成 768 字节一条内存降到四分之一代价是召回率轻微下降。更激进的乘积量化能把内存压到十分之一以下但召回损失需要实测评估不适合对准确性要求高的场景。对于倒排索引Lucene 本身已经有很好的压缩Frame of Reference 加块级编码能优化的空间主要在于减少词元总量。同一份文本用标准分词器可能切出 80 个词元用合适的中文分词器可能只有 30 个倒排大小直接差一倍多。所以分词器的选择不只是影响相关性也直接影响磁盘和查询速度。5.3 批量导入和异步写入的参数组合下面这组参数是我在离线批量导入时固定的组合可以直接抄{ index: { refresh_interval: -1, translog.durability: async, translog.sync_interval: 30s, number_of_replicas: 0 } }导入完成后要记得改回来并且做三件事把number_of_replicas恢复到正常值、手动触发一次 refresh、在业务低峰期做一次 force_merge 把 segment 数量压下来。这最后一步很多人忘结果索引建好了但查询一直不快其实就差这一次合并。注意translog.durability改成async之后机器断电可能丢失最近一段时间的数据。批量导入可以这么干线上主索引不要这么干。6. 真要迁移双跑、对账和回滚怎么做才不出事故如果你评估完还是决定迁移那接下来的重点就不是性能了是怎么迁不出事。我经历过一次迁移事故教训足够深刻这里完整讲一下我的流程。6.1 双写阶段的数据对账迁移的第一阶段是双写新数据同时写进老引擎和新引擎。这个阶段的重点是历史数据回填和对账。回填完之后必须做一次全量对账我用的方法是分层抽样按 ID 哈希取 1% 的文档逐条比对两个引擎返回的字段值同时按热门查询词取 500 个查询比对 top-10 结果的重合度。这里有个关键指标top-10 重合率。如果两个引擎的相关性算法不同结果不会完全一致重合率在 70% 以上通常可以接受低于 50% 就要去看是不是分词或者权重配置差太多。这个指标比延迟降了多少重要得多因为它决定了用户会不会觉得搜索变差了。6.2 灰度切流的粒度选择不要一次性全量切流。灰度粒度我建议按用户维度而不是按请求比例因为同一个用户在不同请求间看到的结果不一致体验会很割裂。按用户 ID 取模分流比如 5% 的用户走新引擎观察一周看三个指标P99 延迟、搜索点击率、零结果率。零结果率是最灵敏的指标。分词配置有问题、字段映射漏了、过滤条件语义不一致这些都会立刻反映到零结果率上升上。我一般把这几个指标做成看板切流当天每隔半小时看一次。6.3 回滚预案必须提前写好回滚预案要在切流之前就准备好而不是出事之后再想。核心是两件事双写开关和读流开关。双写开关控制新数据是否继续写新引擎读流开关控制流量走哪个引擎。这两个开关必须是配置中心里的独立开关可以秒级生效不能依赖发版。还有一个容易被忽略的点回滚之后新引擎里已经积累的数据怎么办。如果决定彻底放弃迁移记得清理掉新引擎的集群和相关资源不然它会一直挂着吃成本半年后你会发现账单上多了一笔没人认领的费用。我自己就遇到过这种僵尸集群清理的时候还顺便发现了几个已经没人维护的定时任务。最后分享一个我个人判断该不该迁移的土办法把所有待优化项列出来给每一项标一个预估收益和工作量然后从收益最高、工作量最小的开始做。我做过的所有检索优化项目里真正需要换引擎才能解决问题的大概只占三成。剩下的七成都是 mapping 没设计好、分片切多了、批量写入参数没调、向量没量化这类问题。等你把这些都做完了如果性能还是不够那时候迁移才有意义因为你已经知道自己要的是什么、新引擎应该怎么配。