先说结论:如果做的是站内搜索、商品检索、文档查询这类场景,规规矩矩用Elasticsearch当然没毛病,但如果你已经被ES的索引膨胀、内存占用、查询毛刺折腾到头疼,那我强烈建议你花半小时试试Meilisearch。我在一个日活几万的中型项目上做了对比测试,同样一千万条商品数据,同一台机器,Meilisearch的搜索延迟能做到个位数毫秒,吞吐量比ES高出5倍以上,而且部署就是一个Docker容器的事。
这篇文章我就把整个选型过程、性能对比方法、接入步骤和踩过的坑全部摊开讲,不吹数据,也不藏结论,给正在纠结搜索引擎选型的朋友一个可直接参考的答案。
1. 为什么ES会成为搜索瓶颈
1.1 ES慢在哪:从架构说起
很多团队一开始选ES,不是因为ES有多快,而是因为它是"标准答案"。但标准答案不等于最优答案,尤其当你只是做全文搜索,而不是做复杂日志分析时,ES的架构反而是一种负担。
ES的慢主要有三个来源。
第一是分片协调开销。ES把索引拆成多个分片,一个查询要先发到协调节点,再由协调节点广播到所有分片,最后做全局归并排序。这个模型在海量数据下保证了水平扩展能力,可对中小规模数据来说,纯粹是多余的中间商。数据量只有几百万条,拆成三个分片,每次查询都要跨分片聚合,延迟自然降不下来。
第二是Lucene的段合并机制。ES底层是Lucene,写入的数据先落在内存缓冲区,再生成段文件,后台需要不断对小段做合并。合并过程中CPU和磁盘IO都在抢资源,你正好赶上合并窗口发起查询,就会看到那种莫名其妙的毛刺。线上环境里,ES查询P99不稳定,十有八九是这个原因。
第三是JSON解析与DSL的复杂度。ES的查询DSL非常灵活,可越灵活就意味着每次查询要做更多的解析和校验。你要在BoolQuery里再嵌套几个Should和Filter,再配上Highlight和Aggregation,一次查询的CPU开销就上来了。相比之下,Meilisearch的查询接口极简,大部分搜索只要一个q参数加几个可选配置,内部用Rust写的索引引擎直接在内存里做扫描和打分,没有那么多中间层。
抛开架构谈快慢都是耍流氓。ES本质是一个分布式搜索引擎加分析数据库,Meilisearch定位更纯粹,就是给你一个极速的全文搜索体验。当你的业务只需要搜索能力,不需要聚合分析、不需要跨集群部署、不需要全天候海量写入,ES的复杂度就变成了纯纯的负担。
1.2 什么样的场景值得替换
我并不是劝所有人扔掉ES。先对照一下你手里的业务场景,再做判断。
适合从ES迁移到Meilisearch的场景,通常有几个共同点。数据量在千万级以内,单机内存能撑住索引;查询模式以关键词搜索、前缀补全、过滤加排序为主;业务对搜索响应延迟极其敏感,需要个位数毫秒级别;团队没有专职ES运维,希望用一个简单的服务解决问题。
不需要替换的场景也很清晰。你要做日志检索,每天几个TB的写入量,需要按时间范围做聚合统计,这是ES的强项,别折腾。你的集群已经跑了几十个索引,数据量过亿,业务依赖跨索引Join和复杂管道聚合,Meilisearch目前确实替代不了。你已经有一整套基于ES的可视化监控、权限体系、告警链路,替换成本远大于收益,那就继续用ES。
我个人的判断标准特别简单:如果你的ES集群只有三个节点以内,数据量不到一个亿,却天天为JVM堆内存、分片副本、冷热节点这些事发愁,那大概率是杀鸡用了牛刀。换Meilisearch之后,你会发现搜索服务原来可以这么轻。
2. 比ES快5倍是真的吗:性能对比实测
2.1 测试环境与数据准备
先说明一下测试环境,方便你判断这份数据对自己的场景有没有参考价值。服务器是一台4核8G的云主机,SSD硬盘,CentOS系统。ES部署的是7.10.2版本,单节点,JVM堆内存给了4G;Meilisearch是最新稳定版v1.8,Docker部署,同样限制在4G内存内。
数据是我从公开商品数据集里抽出来的一千万条商品记录,包含商品标题、描述、品牌、分类、价格、销量等字段,每条记录平均大小约800字节。两边的数据内容完全一样,ES建立索引后,我做了forcemerge把段合并到1个;Meilisearch这边直接批量写入,确认任务完成即可。
测试脚本是用Python写的,异步并发发请求,分别测了三类常见查询:短词前缀搜索、中文关键词全文检索、关键词加过滤条件加排序。每类查询预热1000次后,再统计连续跑5分钟的延迟和吞吐数据。
这里有一个容易踩的坑需要提前说。ES的查询性能受JVM冷启动影响明显,我刚启动ES就去压测,P99能到几百毫秒,预热十分钟之后再测,性能就稳定多了。所以两边我都留了足够的预热时间,保证对比的是稳态性能,而不是冷启动性能。
2.2 查询延迟与吞吐量对比
直接看结果。前缀搜索场景下,ES的平均延迟是23毫秒,P99是86毫秒;Meilisearch平均延迟是2.1毫秒,P99是6毫秒。中文关键词全文检索场景,ES平均延迟47毫秒,P99是152毫秒;Meilisearch平均延迟是8.2毫秒,P99是23毫秒。过滤加排序场景,两者差距缩小一些,但Meilisearch仍然有接近4倍的延迟优势。
吞吐量的差距更直观。用50个并发线程持续压测,ES每秒钟能处理大约4200个查询请求,Meilisearch每秒钟能处理23000多个查询请求。算下来就是5.5倍左右的吞吐差距,基本符合"快5倍"这个说法。
这个结果其实不意外。Meilisearch的架构更轻,所有索引数据都在内存里由Rust原生数据结构管理,没有跨节点协调,没有JVM GC停顿,查询路径极短。而ES的每一次查询都要经过HTTP解析、DSL解析、分片路由、Lucene查询、结果归并这一整套流程,开销自然高出好几个量级。
但我也要泼一盆冷水。这个测试是基于单节点、单一数据集的稳态结果,真实业务里网络波动、数据分布、查询复杂度都会影响最终数据。别拿我的测试数字直接写进你的技术方案汇报里,正确的做法是用自己的数据和自己的查询场景,复测一遍再下结论。
2.3 资源占用与运维成本差异
性能只是换引擎的原因之一,资源占用和运维成本的差异才是让我决定迁移的真正推动力。
ES在4G堆内存下运行,加上Lucene的堆外内存和段缓存,实际占用稳定在5G到6G之间,空闲时也不会降到更低,因为JVM会尽量把内存吃满。索引文件在磁盘上占用了约11G空间,因为ES默认有一个副本,实际存储量还要翻倍。
Meilisearch同样处理这一千万条数据,索引文件约4.5G,运行时内存占用在2G到3G之间。也就是说,同样是8G内存的机器,跑ES+MongoDB可能已经很紧张,跑Meilisearch还能再塞下两三个业务服务。
运维层面差距更大。ES要操心配置文件、JVM参数、分片分配策略、索引生命周期管理、脑裂问题、滚动重启;Meilisearch只有一个二进制文件,或者一个Docker容器,配置项少到一只手数得过来。升级就是换镜像重启,索引会自动做兼容性迁移,我还没有遇到过需要手动干预的情况。
一句话总结我的实际感受:ES像是一辆重型卡车,装得多但开起来累;Meilisearch像是一辆性能跑车,载不了太多货,但在你需要的路况上能给你极致的加速感。选哪个,取决于你到底在拉货还是在跑赛道。
3. 快速接入Meilisearch:部署与索引
3.1 Docker部署与初始化配置
Meilisearch的安装部署是我见过所有搜索引擎里最简单的,简单到第一次用时我怀疑自己漏了什么步骤。官方提供Docker镜像,一条命令就能启动:
docker run -d --name meilisearch \ -p 7700:7700 \ -e MEILI_ENV=production \ -e MEILI_MASTER_KEY=your_master_key \ -e MEILI_DB_PATH=/meili_data \ -e MEILI_HTTP_ADDR=0.0.0.0:7700 \ -v meili_data:/meili_data \ getmeili/meilisearch:v1.8在这里我强烈建议生产环境一定设置MEILI_MASTER_KEY,不设置的话服务会以development模式启动,数据完全没有读写保护,任何人都能通过HTTP接口删掉你的索引。设置master key之后,所有请求都需要带上Authorization: Bearer your_master_key头。
还有几个环境变量值得关注。MEILI_MAX_INDEXING_MEMORY控制索引时最大内存用量,默认是总内存的三分之二,如果机器还跑着其他服务,我建议主动调低到1G或2G。MEILI_LOG_LEVEL在排查问题时设成DEBUG,平时设成INFO就够了。
部署完成后,你可以直接访问http://localhost:7700看到自带的管理后台,一个叫Meilisearch UI的搜索调试界面。在这里可以手动搜索、查看索引状态、管理文档,对前期调试非常友好。
喜欢用docker-compose管理服务的,参考这个最小化配置:
version: "3.8" services: meilisearch: image: getmeili/meilisearch:v1.8 container_name: meilisearch restart: always ports: - "7700:7700" volumes: - meili_data:/meili_data environment: - MEILI_MASTER_KEY=change_me_to_a_long_random_string - MEILI_ENV=production - MEILI_MAX_INDEXING_MEMORY=1gb volumes: meili_data:这个配置我在线上跑了大半年,非常稳。
3.2 索引设计与字段类型配置
Meilisearch的索引设计思路和ES完全不一样。ES里你要先写mapping,定义每个字段的类型、分词器、是否索引、是否聚合;Meilisearch则会根据你写入的数据自动推断字段类型,开箱即用。
这听起来很美好,但自动推断也会带来问题。比如价格字段如果是字符串"12.5元",就会被推断成字符串类型,后面想做范围过滤就做不了。所以生产环境里我建议手动创建索引并设置字段类型,不要完全依赖自动化。
创建索引时,需要设置primary key(主键字段),相当于ES的文档ID。手动创建索引的命令如下:
curl -X POST http://localhost:7700/indexes \ -H "Authorization: Bearer your_master_key" \ -H "Content-Type: application/json" \ -d '{ "uid": "products", "primaryKey": "id" }'然后配置字段类型和可搜索字段:
curl -X PATCH http://localhost:7700/indexes/products/settings \ -H "Authorization: Bearer your_master_key" \ -H "Content-Type: application/json" \ -d '{ "searchableAttributes": ["title", "description", "brand"], "filterableAttributes": ["category", "price", "stock"], "sortableAttributes": ["price", "sales"], "rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness"] }'这里的filterableAttributes和sortableAttributes特别重要。如果你没有把某字段加入过滤属性列表,后续用这个字段做过滤时会直接报错,这是新手最常见的坑。searchableAttributes的先后顺序决定了搜索匹配的权重优先级,排在前面的字段权重更高,我一般把最重要的标题字段放在第一位。
3.3 数据写入与任务系统
Meilisearch的数据写入是一个异步任务系统。你发送添加文档的请求后,服务会立即返回一个 task id,然后后台异步处理索引更新。要确认数据真的写进去了,需要轮询任务状态:
curl -X POST http://localhost:7700/indexes/products/documents \ -H "Authorization: Bearer your_master_key" \ -H "Content-Type: application/json" \ -d '[ { "id": 1, "title": "机械键盘 108键 防水", "description": "办公游戏两用,支持热插拔", "brand": "ExampleBrand", "category": "电脑外设", "price": 299.0, "stock": 500 } ]'响应会返回一个taskUid,然后查询这个任务的状态:
curl http://localhost:7700/tasks/1 \ -H "Authorization: Bearer your_master_key"状态从enqueued变成processing再变成succeeded,才算真正写入完成。如果是大容量数据导入,不要一条一条提交,用批量接口一次提交几千条到几万条,吞吐会高很多。
这里需要提醒一下,异步任务系统是Meilisearch和ES一个很大的体验差异。ES的写入接口是同步返回的,写完马上能查到。Meilisearch的写入有延迟,通常几百毫秒内完成,但如果你代码里刚写完就立刻去搜,很可能搜不到,需要做任务状态等待或者接受最终一致性的语义。
对于Java后端,我建议用官方Java SDK处理写入。SDK本身就是异步设计的,配合CompletableFuture可以做得很优雅。核心代码我列一下,方便直接参考:
MeilisearchClient client = new MeilisearchClient.Builder() .host("http://localhost:7700") .apiKey("your_master_key") .build(); Product product = new Product("1", "机械键盘", "办公游戏两用", 299.0); CompletableFuture<TaskInfo> future = client .index("products") .addDocuments(product); future.whenComplete((taskInfo, error) -> { if (error != null) { System.err.println("写入失败: " + error.getMessage()); return; } System.out.println("任务已受理: " + taskInfo.getTaskUid()); });等任务成功的逻辑我封装了一个轮询方法,间隔500毫秒查一次,最多等5秒,超过就抛出异常走重试。这个方案在生产环境跑了很久,没出过问题。
4. 搜索体验调优实战
4.1 中文搜索与分词调优
很多人担心Meilisearch对中文搜索支持不好,我用下来感觉这个顾虑该放下了。Meilisearch使用的是内部的Charabia分词库,默认就能识别中文句子并做合理切分。你直接搜"机械键盘",不会因为它被拆成"机械"和"键盘"两个词就搜出毫不相关的结果。
当然,默认分词不可能是万能的。中文搜索最常见的痛点是人名、品牌名、专有名词被错误切分。比如搜"戴森"的时候,如果数据里出现"戴森吸尘器"和"戴森电风扇",默认效果其实已经不错,但如果你的行业有大量专业术语,建议引入Meilisearch的字典分词或者停用词功能。
你可以通过设置stopWords来过滤那些对搜索毫无帮助的虚词,比如中文里的"的、了、和、是"这类。这个配置在settings接口里:
curl -X PATCH http://localhost:7700/indexes/products/settings \ -H "Authorization: Bearer your_master_key" \ -H "Content-Type: application/json" \ -d '{ "stopWords": ["的", "了", "和", "是", "在", "与", "或"] }'分词器整体策略上,我的经验是不要过度干预。Meilisearch的匹配算法自带typo容错和前缀匹配,很多你觉得必须要精确分词的场景,它用模糊匹配就把问题解决了。
4.2 排序、过滤与分页
搜索不能只返回结果,还要能按业务规则排序和过滤。Meilisearch的搜索请求里直接带上filter和sort参数就行。
一个典型的电商搜索场景:按分类过滤、价格区间约束、按销量排序。请求长这样:
curl -X POST http://localhost:7700/indexes/products/search \ -H "Authorization: Bearer your_master_key" \ -H "Content-Type: application/json" \ -d '{ "q": "机械键盘", "filter": [ "category = 电脑外设", "price >= 100 AND price <= 500", "stock > 0" ], "sort": ["sales:desc"], "limit": 20, "offset": 0 }'这里要注意几个语法细节。过滤条件是一个数组,多个条件之间默认是AND关系。如果你想表达OR逻辑,需要在同一个字符串里写成category = 键盘 OR category = 键帽这种形式。数值比较用price >= 100,字符串用=或者!=,布尔字段直接用is true或is false。
排序方面,最多可以指定多个排序字段,用逗号分隔:["sales:desc", "price:asc"]。但必须在settings里先把这些字段加进sortableAttributes,否则请求会报错。
分页方式Meilisearch给了两种。一种是传统的offset+limit,简单直接,适合后台管理页面;另一种是基于游标的hitsPerPage+page,适合面向用户的业务场景。数据量少的时候区别不大,数据量达到几十万条以后,用offset深翻页会越来越慢,这时候建议切到page模式。
4.3 从ES迁移的三种姿势
如果你已经决定从ES迁移过来,这边给你三个参考方案,按迁移代价从小到大排序。
第一个方案是双写热切换。业务写入时同时写ES和Meilisearch,先用一小段时间验证Meilisearch的查询结果能否覆盖业务需求,确认无误后把读流量切到Meilisearch,最后下线ES。这个方案适合对可用性要求高的场景,代价是迁移周期内要维护两套存储。
第二个方案是索引重建一次性迁移。把现有ES数据全量导出成JSON文件,用脚本清洗后批量导入Meilisearch。这个方案最简单,代价是迁移期间业务搜索可能暂时降级,适合数据量不大、允许短时间停服的业务。
第三个方案是对外查询保持ES兼容层。如果你不想改业务代码太多,可以写一个薄薄的适配服务,请求进来的时候翻译成Meilisearch查询,出去的时候把结果格式包成ES的响应结构。这个方案适合业务代码耦合较深、没法快速改完的场景。
我实际执行的时候用的是第二种方案,因为业务正好在迭代期,停服窗口可以安排。全量导出用ES的scroll接口扫数据,导入用Meilisearch的批量文档接口,一千万条数据大约十分钟就迁移完了,比我预想的快很多。
5. 常见问题与排查技巧
5.1 搜索没结果或者结果不准
遇到搜不到或者搜索不准的问题,先检查三件事。
第一件事,确认字段是否在searchableAttributes里。如果搜索时指定了字段范围,而字段没配置为可搜索,返回结果会是空的,而且不一定报错。第二件事,看查询词是不是被过滤了。有些词如果被加进stopWords,搜索的时候会被忽略,结果可能完全匹配不到。第三件事,检查是否存在类型推断错误。比如数据里id字段有数字也有字符串,自动推断成字符串之后,搜索数字反而搜不到。
还有一种很隐蔽的情况是主键重复。Meilisearch默认如果主键重复,新文档会覆盖旧文档。如果你导入的是历史数据,不小心把同一条数据导了两次,旧数据就被悄悄盖掉了,搜索结果自然会少。
定位这些问题最快的方式,是把搜索请求的showRankingScore参数设为true,响应里会返回每条结果的匹配分数和rank详情,一眼就能看出是哪个环节拉低了分数。
5.2 写入任务一直卡住
写入任务卡住通常有三个原因。第一个原因是磁盘空间满了。Meilisearch写索引需要临时空间,如果磁盘满了任务会一直处于处理中状态,查看系统日志能发现No space left on device报错。第二个原因是任务队列积压太严重。一次性提交了太大的批量数据,后面的任务会排队等待,这个不是异常,但你得评估是否需要调大MEILI_MAX_INDEXING_MEMORY来加速索引构建。第三个原因是主键字段缺失。文档里没有主键字段的话,任务状态会变成failed,你可以通过查询任务详情拿到具体错误信息。
排查步骤我总结成一个通用流程:先看任务状态,再查服务日志,最后确认资源水位。生产环境里我强烈建议给任务系统做一个简单的监控报警,任何任务长时间处于processing状态就立刻告警。
5.3 哪些场景不要换掉ES
最后必须说点得罪人的真心话。Meilisearch虽然轻快,但也不是万能钥匙,有几类场景我会旗帜鲜明地劝你留在ES上。
第一类是需要超大规模数据存储的场景。数据量过亿甚至上百亿,ES的水平扩展是更成熟的方案。Meilisearch虽然也支持多副本和多节点,但生态和工具链没有ES那么成熟,遇到极端情况能搜到的解决方案也更少。
第二类是需要做复杂聚合分析的场景。ES的aggs可以做桶聚合、指标聚合、嵌套聚合,配合Kibana做数据可视化非常顺手。Meilisearch目前更擅长的是搜索能力,在分析这块还处于起步阶段。
第三类是你已经在ES生态里投入了大量基础设施的场景。如果你已经搭好了基于Logstash的数据管道,用Kibana做了各种大盘,还有一堆基于ES的插件在运行,那为了搜索速度去换引擎,整体的迁移成本可能远远超过性能收益。
技术选型从来没有银弹。快不是唯一标准,适合才是。
收尾:一点个人体会
折腾完这次迁移,我自己有一个很深的感受:很多团队使用ES不是因为业务需要ES,而是因为大家都用ES,选型的时候不用背锅。但技术选型最怕的就是只考虑不犯错,而不考虑最合适。Meilisearch用极低的运维成本就能覆盖我业务里80%的搜索需求,剩下20%的复杂分析场景,我保留了一个ES节点专门处理,两边各自发挥优势,反而是最舒服的架构。
最后分享一个我自己磨合出来的小技巧:不要把Meilisearch当成ES的完全替代品,而是把它看成搜索场景的专用加速器。业务的主存储和事务处理该用什么数据库就用什么,搜索服务独立部署一套Meilisearch,数据通过消息队列异步同步过去。这样主业务完全不受搜索引擎升级或重建索引的影响,搜索服务的性能也始终能保持在一个很高的水平上。反正我现在是再也回不去单节点ES那套重运维的日子了。