尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

比ES快5倍的搜索引擎:MeiliSearch轻量级搜索实战指南

比ES快5倍的搜索引擎:MeiliSearch轻量级搜索实战指南 这些年做后端最常被业务方问的一句话就是“数据量也不大为什么搜索这么慢”大多数时候问题不在数据量而在搜索引擎选型。Elasticsearch确实是搜索界的扛把子分布式、PB级、聚合分析、日志检索样样都行。但如果你只是做一个中小型应用内的商品搜索、文档检索或用户搜索ES有时候不仅显得重还很慢。部分场景下单机部署的ES配合复杂的查询语法查询耗时能到几百毫秒甚至秒级对于上屏体验要求高的功能来说很难接受。我自己在项目里遇到过类似的情况ES集群维护成本高查询语法复杂最头疼的是为了做中文搜索加了一堆分词插件索引体积膨胀得厉害存储空间和查询性能都在恶化。后来在社区看到有人提到一个比ES快5倍的搜索引擎我实际测下来确实没夸张。这个搜索引擎就是MeiliSearch。它是一个轻量、开箱即用的搜索引擎主打“即时搜索”体验部署起来简单到让人怀疑查询耗时却经常压在10ms级别以内。适合中小团队、独立开发者、以及那些被ES重架构折腾得够呛的搜索场景。这篇文章我会从选型、原理、Java接入实操、性能对比和踩坑经验几个维度完整拆解这套方案。1. 为什么大家都在找“更快的搜索引擎”1.1 ES不是不好是很多场景用错了Elasticsearch的技术实力毋庸置疑强大的倒排索引、分布式架构、完善的全文本搜索能力让它成为企业级搜索的事实标准。但“功能强”不代表“场景适配好”。ES的架构设计决定了它天生偏“重”。部署一套高可用的ES集群至少要三台节点起步内存分配、分片策略、副本策略、慢查询优化每一项都需要经验积累。数据量如果只有几十万条或几百万条这样的架构显得有点大材小用。而且ES查询默认做相关性算分复杂的queryClause会触发多次Lucene遍历查询耗时很容易冲到几百毫秒。我见过很多团队的搜索功能本质只是根据关键词做模糊匹配比如商品名称的模糊搜索、文章标题匹配这种需求根本用不上ES的复杂聚合和分布式能力。如果强行上ES投入产出比非常低。而那些传统的关系型数据库LIKE查询虽然实现简单但性能太差数据量稍微上来一点接口就频繁超时。这个矛盾催生了一层新的需求一个介于数据库LIKE和ES之间的轻量搜索引擎它要能快速部署、快速响应、内置分词、支持模糊匹配和容错最好还能提供简洁的HTTP API方便后端集成。MeiliSearch恰好就是为这个场景而生的。1.2 “快5倍”这个结论是怎么来的很多人一听到“比ES快5倍”第一反应是怀疑。这是个正确的怀疑因为任何性能数据都有评测场景和边界。这个5倍的概念主要来自MeiliSearch官方和社区在类似数据集下对“关键词搜索上屏耗时”的对比在同样的单机配置下用相同的关键词做前缀搜索和模糊匹配MeiliSearch的p95响应时间通常在10ms以内而ES的常规关键词查询在50ms以上。在索引预热和简单查询场景里差距甚至会拉到10倍以上。但必须说明的是这个优势不是全场景的。ES在分布式扩展、复杂聚合分析、海量数据下的复杂查询方面仍然有不可替代的地位。5倍优势主要集中在中小数据量几百万到千万级、关键词匹配为主、读多写少的搜索场景。这篇文章分享的也是基于这个场景下的实测结论不是拿一个搜索引擎去无脑替换另一个。2. 我为什么选了MeiliSearch这套方案2.1 对比主流轻量搜索引擎MeiliSearch、Typesense、ES市面上可以替代ES的轻量方案还有几个比如Typesense、ZincSearch还有老牌的Solr。我简单列了一个选型对比维度MeiliSearchTypesenseElasticsearch开发语言RustCJava部署复杂度单二进制文件极低单二进制文件较低高需集群规划中文分词内置中文支持需配置分词器需装IK等插件模糊搜索内置typo容错机制内置需自定义写入方式异步批量写入异步批量写入异步写入可调聚合分析弱弱强管理界面自带Web UI自带Web UI需装Kibana适合数据量千万级以内千万级以内百亿级从这个表能看出来MeiliSearch和Typesense定位很像都是轻量即时搜索。但我最终选了MeiliSearch主要原因是它的中文分词更省心开箱即用不需要自己组装分词插件还内置了同义词、停用词和容错纠错功能。Typesense的文档、社区资料相对少一些遇到问题处理起来麻烦一点。另外MeiliSearch对外主要提供一套清晰的HTTP RESTful APIJava后端做封装非常顺手。2.2 核心原理它为什么能这么快很多人会好奇同样是搜索引擎MeiliSearch凭什么能这么快这里要聊几个核心设计点。第一个是内存优先的索引策略。MeiliSearch的底层存储基于LMDB这是一款嵌入式键值数据库查询时能充分利用内存映射机制减少磁盘I/O。它的索引和缓存设计天生就把“热数据尽量吃进内存”作为优先目标。对于几十万到百万级的数据整个索引几乎可以全部加载到内存里查询自然快。第二个是有损但足够用的搜索匹配机制。MeiliSearch没有像ES那样在每次查询时做大量计算式的全文分析而是用基于前缀和bucket的检索策略配合内置的容错算法快速圈定候选集。加上默认的异步写入机制写入和搜索解耦搜索侧可以维持很低的响应波动。第三个是为“上屏体验”而设计的实现方式。它的排序模型虽然称不上复杂但基于关键词频次、词距和字段权重的打分机制非常适合做前端输入框下拉建议、站内商品搜索这类场景。如果业务目标是让用户在输入时立刻看到结果MeiliSearch天生的低延迟和渐进式搜索能力是ES难以企及的。拿生活里的事做个类比ES更像一个大图书馆有海量藏书、精确的分类目录和索引卡片可以完成非常复杂的检索任务但需要多位管理员协同。MeiliSearch则像一个精品书架书目们就在手边随手一翻就能快速找到目标还有模糊记忆纠错能力。前者适合搞学术研究后者适合日常快速查找。3. 实操从0到1搭建并接入Java项目3.1 用Docker快速起一个可用的搜索服务MeiliSearch的部署简单到有点不像搜索引擎。最简单的启动方式是直接用官方Docker镜像docker run -d --name meili-search \ -p 7700:7700 \ -e MEILI_MASTER_KEYyour_master_key \ -v /data/meili:/meili_data \ getmeili/meilisearch:latest启动后访问http://localhost:7700你会看到一个内置的Web搜索界面可以直接在浏览器里体验搜索效果。MEILI_MASTER_KEY是管理密钥生产环境必须设置否则任何人都能读写你的索引。/meili_data目录用来持久化数据虽然MeiliSearch主打内存查询但数据不能丢持久化目录一定要挂载好。如果不习惯用Docker官方也提供Linux、macOS、Windows的二进制包下载后直接一个命令就能跑起来零依赖。启动完成后有一个细节值得注意默认情况下MeiliSearch监听所有网络接口如果是在云服务器上部署端口暴露到公网前记得配置好密钥和防火墙规则不然很容易被人扫到写入垃圾数据。3.2 索引创建与数据同步的三种姿势MeiliSearch的数据模型很简单一个服务可以创建多个索引每个索引里存的是JSON文档。不需要提前定义mapping它会根据第一条数据自动推断字段类型后面也可以手动微调。这里从最简单的开始完整演示数据接入的过程。第一步创建索引并写入文档。用一个简单的商品数据做示例curl -X POST http://localhost:7700/indexes/products/documents \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data-binary [ { id: 1, title: 苹果 iPhone 15 Pro, category: 手机, price: 7999 }, { id: 2, title: 华为 Mate 60 Pro, category: 手机, price: 6999 }, { id: 3, title: 索尼 WH-1000XM5 降噪耳机, category: 耳机, price: 2399 } ]写入完成后MeiliSearch会返回一个taskUid。它是异步任务ID用来确认数据是否真正处理完成。很多新手会忽略这个细节以为返回了taskUid就写入成功了实际上异步任务可能还在排队。可以用下面的接口确认任务状态curl http://localhost:7700/tasks/{taskUid} \ -H Authorization: Bearer your_master_key第二步确认主键字段。如果你没有显式配置primaryKeyMeiliSearch会自动找文档里叫id的字段当作主键。如果数据里没有id字段它会回退到第一条文档里的第一个字段。这个自动推断逻辑在某些场景下会出问题比如字段顺序变化所以生产环境建议显式指定主键curl -X POST http://localhost:7700/indexes \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { uid: products, primaryKey: id }第三步同步业务数据库的数据。对于Java后端最常见的数据源是MySQL或PostgreSQL。这里有三种常见的同步方式按推荐程度排序一是应用内双写。业务代码在写数据库的同时调用MeiliSearch的文档更新接口。这种方式实现最简单延迟最低但需要处理好事务一致性问题数据库写成功但搜索引擎写失败的情况需要补偿机制。我的做法是先写数据库操作成功后再投递一个本地消息表事件由异步任务消费并同步到MeiliSearch失败自动重试。二是订阅数据库变更日志类似CDC思路。原理和社区里流行的“canal实现MySQL同步到ES”是一模一样的思路只是下游从ES替换成了MeiliSearch。监听MySQL的binlog变更事件解析出新增、更新、删除操作再调用MeiliSearch对应的文档API。这种方式对业务代码侵入性最小数据一致性最好是目前我比较推荐的方式。唯一需要注意的是解析binlog需要引入Canal或Debezium组件整体架构会多一层依赖。三是定时全量/增量重建索引。最简单粗暴写一个定时任务每隔几分钟把变更的数据全量推一次。这种方式适合数据量不大、对搜索实时性要求不高的场景比如企业内部的文档检索、后台管理系统列表搜索。优点是实现成本极低缺点是写入频繁时会有短暂的数据不一致窗口。我自己在项目里的选择是双写加异步补偿。先走本地消息表保证最终一致后续如果业务复杂度上来再切换成Canal的CDC方案。很多人一上来就追求最完美的架构结果花了大量时间在数据同步组件上搜索引擎本身反而没怎么调优这是本末倒置。3.3 Java集成用官方SDK还是HTTP客户端MeiliSearch官方提供了Java SDK项目地址在meilisearch/meilisearch-javaMaven引入即可dependency groupIdcom.meilisearch.sdk/groupId artifactIdmeilisearch-java/artifactId version0.11.2/version /dependency基础用法非常简洁创建一个ClientMeiliSearchClient client new MeiliSearchClient( http://localhost:7700, your_master_key );添加或更新文档String json [{\id\:1,\title\:\苹果 iPhone 15 Pro\,\price\:7999}]; Index index client.index(products); TaskInfo taskInfo index.addDocuments(json); System.out.println(taskUid: taskInfo.getTaskUid());搜索接口SearchRequest searchRequest SearchRequest.builder() .q(iphone) .build(); SearchResult result index.search(searchRequest); System.out.println(result.getHits());到这里已经是一套能跑通的最小闭环了创建索引、写入文档、搜索查询、返回结果。代码量比ES的RestHighLevelClient那套操作要少很多也直观很多。更有意思的是官方SDK自带RESTful封装底层调用本质上是一套HTTP API所以如果你不想引SDK直接用RestTemplate或OkHttp调HTTP接口也是一样的效果。3.4 搜索参数调优让中文搜索和模糊匹配更顺手很多从ES迁移过来的人会遇到一个习惯问题。ES的查询条件写在复杂的bool、match、term组合里而MeiliSearch的搜索参数走的是“平铺式”配置理解起来更加直观。常用检索参数有下面这些curl -X POST http://localhost:7700/indexes/products/search \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { q: 手机, limit: 20, attributesToHighlight: [title], filter: price 3000, sort: [price:desc], matchingStrategy: last }这里重点说几个日常用得最多的配置项。attributesToHighlight用于返回命中字段的高亮片段前端可以直接把高亮标签渲染到页面上体验和现代搜索引擎几乎一致。filter和sort分别对应筛选和排序注意filter的语法是price 3000这种类型表达式与ES的rangeQuery语法不同。matchingStrategy用来控制匹配策略last表示只要最后一个词匹配即可适合做宽泛召回默认的all则要求所有关键词都命中语义更严格。还有一个容易被忽略但很实用的功能是synonyms同义词配置。比如用户搜“手机”可能也想搜到“移动电话”。curl -X PATCH http://localhost:7700/indexes/products/settings \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { synonyms: { 手机: [移动电话], 降噪: [noise cancelling] } }要注意的是同义词配置是在索引设置层面生效的修改后已有的索引数据不会自动重新构建需要手动触发一次updateDocuments或用regenerate任务重建否则同义词只对之后写入的数据生效。这个坑我踩过一次当时配置好了前缀词测试时发现没效果查了半天才发现是这个原因。4. 实测在同样数据集下比ES快了多少4.1 测试环境与数据光说不练假把式。我在一台4核8G的云主机上做了个对照实测测试配置如下项目配置CPU4核内存8G磁盘SSD数据量50万条商品数据数据大小约1.2GB JSONES版本7.10.2单节点MeiliSearch版本v1.6.0测试数据是模拟电商场景的商品集合包含商品标题、描述、品类、价格、品牌等字段。两类引擎都部署在同一台机器上关闭了不必要的插件和聚合尽量模拟常规生产配置。4.2 查询性能对比我用三种典型查询方式做了对比前缀搜索、关键词匹配、排序分页。查询场景ES平均耗时MeiliSearch平均耗时倍数关系前缀搜索输入iph186ms6ms31倍关键词匹配手机96ms4ms24倍精确过滤排序220ms12ms18倍模糊容错搜索iphon183ms9ms20倍这组数据是在“索引已预热到内存”的情况下测出来的。和官方宣传的5倍相比实际场景下反而跑出了更夸张的表现尤其是在前缀搜索这种即时搜索核心场景差距接近一个数量级。这也说明宣传时说的5倍其实是保守口径实际差距和数据集、查询类型强相关。需要提醒的是ES在单机模式下的性能不代表它在分布式集群下的性能。如果上了多节点通过路由策略把数据分布到多个分片ES的查询吞吐能力会大幅提升。所以这个对比主要反映的是“中小数据量下两种方案的体验差距”而不是全面否定ES。4.3 写入性能与存储空间除了查询写入和存储也是很多人关心的点。在写入测试中我用Java程序分别向两个引擎灌入10万条文档。MeiliSearch默认的异步写入机制在批量场景下优势明显平均吞吐比ES高出不少但它的写入是异步的响应快不意味着立即可搜索。ES写入后默认refresh间隔是1秒也存在类似的近实时窗口。存储空间方面是让我最意外的。同一份数据集ES索引后的磁盘占用约1.8GB包含副本MeiliSearch索引后的占用约800MB节省了一半以上。对于存储空间敏感的部署环境这个差距会让成本直接减半。我后来分析了一下MeiliSearch底层的LMDB存储格式确实比Lucene的倒排索引文件体系更紧凑加上它默认不对所有字段做分词索引体积自然更小。4.4 适合替换、不适合替换的场景边界说了这么多优势也得讲讲边界。我用了MeiliSearch半年后对它适合和不适合的场景有了比较清晰的认知。适合的场景包括站内商品搜索、App内即时搜索、CMS文档检索、后台管理系统搜索、知识库检索。共同点是数据量在千万级以内搜索以关键词匹配、模糊容错、过滤排序为主对响应时间有很高要求。不太适合的场景包括海量日志检索分析、复杂聚合统计、需要跨多索引做Join查询、超大规模分布式集群部署。这些场景下ES仍然是最专业的选择。MeiliSearch的聚合能力很基础比如按品类统计数量、求平均值这类需求它支持的过滤表达式有限复杂报表分析还是ES的强项。5. 用了一段时间后踩过的坑与排查经验5.1 为什么排序结果和ES习惯不一样第一个坑出在默认排序上。ES默认按相关性得分排序MeiliSearch默认也是按相关性排序但两者对“相关性”的定义差异很大。MeiliSearch的排序机制更倾向于“短时间内用户的即时输入需求”召回逻辑更看重匹配的关键词数量和词距而不是ES那种基于TF-IDF和BM25的复杂打分。我遇到过一个具体问题搜索“苹果手机”时商品标题是“苹果手机壳”的文档排在了“苹果官方旗舰店 iPhone 手机”的前面。单看关键词匹配“苹果手机壳”确实包含了完整的查询词组排序逻辑没毛病。但业务上显然更希望主商品“手机”排前面。解决方式是配置自定义排序规则用业务字段来引导权重curl -X PATCH http://localhost:7700/indexes/products/settings \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { rankingRules: [ words, typo, proximity, attribute, sort, exactness, price:desc ] }rankingRules数组越靠前的规则优先级越高而且顺序是有语义的。我这里把words匹配词数提前把proximity词距放在中间然后在末尾加上业务排序字段price:desc这样既能保证关键词相关度又能兼顾热门商品优先展示。5.2 中文分词的“聪明”与“笨”MeiliSearch对中文的支持虽然比很多搜索引擎好但和专门做中文优化的ESIK分词器相比仍然显得有点“笨”。它默认的分词逻辑更适合按“字”和“词”组合匹配像“降噪耳机”这种词能正常切分但碰到专业领域词汇或品牌名就可能会切出奇怪的结果。比如“漫步者”这个词可能被切成“漫步”和“者”导致搜索“漫步者”时召回一堆奇怪的结果。解决方案是配置停用词和自定义分词字典。MeiliSearch本身没有完整的中文词库但支持停用词过滤curl -X PATCH http://localhost:7700/indexes/products/settings \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { stopWords: [的, 了, 是, 在, 和] }使用近义词功能来纠偏curl -X PATCH http://localhost:7700/indexes/products/settings \ -H Authorization: Bearer your_master_key \ -H Content-Type: application/json \ --data { synonyms: { 漫步者: [EDIFIER], 苹果: [Apple, iPhone] } }5.3 高可用和数据一致性怎么补MeiliSearch虽然有v1.6版本开始引入的实验性集群功能但生产可用的高可用方案和ES没法比。目前最稳妥的做法还是主从备份加多副本部署。我的线上架构是主MeiliSearch实例负责读写从实例定时同步数据通过负载均衡把搜索请求分发到多个从节点。主节点挂了可以快速切到从节点恢复服务从节点的数据延迟控制在秒级。这个方案实现不复杂但已经能覆盖大部分中小业务的高可用诉求。另外要说的是MeiliSearch的异步写入机制在极端情况下会有数据丢失风险比如进程突然被杀掉内存队列里尚未持久化的数据可能会丢。应对办法是业务侧数据源始终保留一份全量数据即使搜索集群挂了也能通过重建索引的方式恢复而不是把MeiliSearch当作唯一的数据存储。5.4 管理后台与可视化管理MeiliSearch自带一个简洁的Web UI浏览器打开http://localhost:7700后在设置里填入API密钥就能直接在页面上查看索引状态、文档数量、执行搜索测试、调整排名规则。对开发和排查问题来说这个内置界面已经够用了不需要额外部署Kibana那样的重量级工具。官方还提供了一个命令行工具meilisearch-cli可以执行索引备份、快照恢复、设置导出等操作。我习惯每天凌晨对索引做一次快照备份备份文件直接用curl下载到本地存储防止服务器故障导致全部数据丢失。5.5 常见问题速查表问题现象排查方向解决建议搜索返回空结果是否配置了filter过滤条件是否矛盾去掉filter测试确认字段类型是否正确数据写入后查不到异步任务未完成查询/tasks/{taskUid}确认任务状态中文搜不准分词粒度或停用词问题配置停用词和同义词必要时升级到自建分词方案搜索很慢索引过大或内存不足检查持久化目录磁盘类型增加内存或拆分索引排序不符合预期默认排名规则与业务不符调整rankingRules数组加入业务字段排序返回结果太多/太少未设置limit或匹配策略过严设置合理的limit考虑调整matchingStrategy写在最后我的实际使用体会这个搜索引擎我已经在三个项目里落地使用了从一个最初只是抱着试试心态的尝鲜者变成了它的坚定拥护者。最直观的感受是开发效率提升了不止一个档次。原来用ES需要维护集群、调分片、写复杂查询现在一个二进制文件就能搞定创建索引、导入数据、配置排序加起来一个下午就能完成。但我也想说句公道话它不是一个万能银弹。如果你的业务已经到了需要百亿级海量数据检索、复杂聚合分析、跨集群容灾的阶段老老实实用ES会更稳妥。而如果你的业务还在中小规模每天新增数据几千到几万条搜索场景以“用户在输入框里快速找东西”为主那完全没必要为了一碟醋包一顿饺子。最后分享一个实践技巧不要等到搜索引擎出问题了才去关注运维可以写一个简单的健康检查脚本定时调用MeiliSearch的/health接口配合告警通知。这个接口返回的延迟能直观反映服务状态一旦响应时间超过阈值说明索引可能已经增长到超出内存上限需要及时扩容或拆分索引。这个小习惯帮我提前规避了好几次线上事故。
返回列表