ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从ES到Typesense:用内存索引与无GC架构解决搜索延迟毛刺

从ES到Typesense:用内存索引与无GC架构解决搜索延迟毛刺 上周搜索接口的P99延迟飙到了844毫秒三台ES节点CPU全部打满慢查询日志拉出来一看大量时间耗在segment merge和GC上。这不是我第一次被ES的性能毛刺折腾了每天凌晨全量索引一跑白天的查询必定跟着遭殃为了扛流量把副本从1加到2内存从16G加到32G成本上去了长尾延迟却没什么实质改善。于是我下定决心认真找一个比ES更快的搜索引擎。试了一圈之后真正让我愿意写篇文章来推荐的是Typesense。这篇文章不是官方文档的翻译而是我调研、部署、压测、踩坑之后的完整记录。如果你也在用ES做业务搜索被GC停顿、索引膨胀、运维复杂度搞得头疼或者纯粹好奇“比ES快5倍”这种话到底有多少水分都可以接着往下看。1. 为什么要折腾一个比ES更快的搜索引擎1.1 ES的痛点性能毛刺、资源占用和运维负担先说背景。我当时的业务是电商商品搜索索引总量不算大大概一两百G查询也不算复杂无非关键词匹配、价格区间过滤、按销量排序。按理说ES对付这种场景绰绰有余但实际跑起来问题一个接一个。第一个问题是长尾延迟不稳定。JVM的GC停顿是最大元凶线上用的是G1回收器平时很正常但一旦堆内存进入“升到老年代 → 触发Mixed GC → Concurrent Mark”的状态STW时间经常冲到几十毫秒甚至上百毫秒。对搜索接口来说这100ms砸在某个用户请求上可能就是一次明显的超时紧接着就是前端重试把后端的压力又抬高一截。后来我加了一堆JVM参数也试过调整GC线程数效果有但没法根治。第二个问题是segment merge。Lucene底层用分段存储写越多后台段合并就越频繁。最绝望的是每次全量reindex的时候IO和CPU一起飙高查询接口跟着遭殃。我试过调merge策略、限流、错峰能缓解但高峰期还是会出现毛刺。第三个问题是资源占用和运维成本。ES官方建议生产集群至少三个节点JVM堆大小、分片数、副本数、索引生命周期管理每一样都要投入精力去调。哪怕我们数据量不大那三台8C16G的机器也得一直在那放着日常查CPU并不高内存却掉不下来。加上版本升级流程繁琐每次大版本更新都要做兼容性测试真的很消耗耐心。这不是黑ESES的功能和生态依然是这个领域里最全的。我只是想说如果业务场景只是“搜索”我们可能并不需要一个连数据清洗、聚合分析、机器学习插件都包含的庞然大物这时候专门为速度设计的轻量引擎就很有吸引力。1.2 市面上号称比ES快的候选方案定下“要换”这个想法后我把市面上几个主流替代品都跑了一遍先放一张对比表。引擎开发语言定位高可用给我的第一印象TypesenseC通用业务搜索内置Raft原生支持集群性能最激进带向量检索能力MeilisearchRust网站即时搜索开源版以单机为主上手最快文档体验最好ZincSearchGo日志搜索替代支持集群兼容部分ES API适合日志场景SonicRust轻量关键词搜索单机太轻量不适合业务系统我最终选Typesense有几个决定性因素。第一它把索引直接放在内存里磁盘只做持久化结构上就是奔着低延迟去的。第二C实现没有JVM自然就没有GC停顿查询延迟曲线非常平。第三它内置了Raft协议三节点一键组集群不用像ES那样再折腾一堆外部协调组件。第四它原生支持向量字段和混合检索之后接语义搜索也不用换引擎。至于Meilisearch我确实也试用过体验很顺但开源版本不支持高可用模式这个在当时直接劝退了我。2. Typesense为什么能这么快三个关键设计2.1 内存索引把“常看的书”直接放办公桌上Typesense最核心的设计就是把全部索引加载到内存里。查询的时候直接在内存里扫倒排链表不需要走磁盘IO也不需要像Lucene那样先查OS Page Cache再看段文件。磁盘上只保存一份快照和操作日志用于重启恢复。打个比方。ES像一座大图书馆每次查询都要去书架翻书虽然也有一部分热书放在前台缓存桌上但总归有“查目录 → 找架子 → 翻书”的过程。Typesense则是把常看的书直接铺在办公桌上伸手就能拿不用等图书管理员稳当当完成查找。这个设计带来了一个硬性约束索引总量必须能放进物理内存。我实测100万条商品文档索引大概占了1.6GB内存对中小业务来说很轻松。但如果你手上有几十TB的数据那Typesense就不是你的菜了。它也能横向扩展可每个节点的内存都有上限数据膨胀到一定程度机器成本会很恐怖。ES用分布式把数据摊到很多节点上天然处理海量数据这是它的舒适区也是我后来做技术选型时始终没忘记的一条界限。2.2 无GC运行态告别JVM的Stop-The-WorldJava系搜索引擎的延迟毛刺根源大多在垃圾回收。ES跑在JVM上对象不断创建、晋升、清理当老年代膨胀到阈值G1或ZGC在回收时都会产生短暂的Stop-The-World。现代GC已经把STW压缩到几十毫秒但线上环境里请求一多、堆一满偶尔还是会蹦出一个上百毫秒的停顿体现在P99上就是一根刺。Typesense用C直接管理内存和对象生命周期不依赖垃圾回收线程所以没有“全世界暂停一下”的问题。我实际观察到的现象是查询延迟非常稳定P99和P50之间的差距很小。这在搜索接口上的感受是决定性的——延迟可预期而不是“大多数时候快偶尔突然抖一下”。2.3 过滤、排序的位图与预建索引搜索不只是关键词匹配还涉及过滤和排序。Typesense对每个打了facet或filterable标记的字段在内存中维护对应的位图结构多个条件过滤时直接用位运算合并速度极快。排序字段在Schema里声明为sort: true之后就会预先建立有序索引结构查询时不用现场算。这解释了为什么在“关键词过滤排序”这一类典型业务搜索场景下Typesense的优势最明显。它相当于把搜索引擎里最常用的一条性能路径做到了极致代价则是放弃了ES那种高度灵活的复杂查询和聚合能力。所以这也是一种工程上的取舍用功能的“广度”换速度的“深度”。3. 从零搭建部署、写入、查询的完整路径3.1 最省事的部署一个Docker命令启动单机我当时用的是Typesense 27.x版本一条Docker命令就能跑起来。docker run -d --name typesense \ -p 8108:8108 \ -v /data/typesense:/data \ typesense/typesense:27.1 \ --data-dir/data \ --api-keytypesense-test-key \ --enable-cors几个参数说明一下。--api-key是必填的生产环境别用我这种明文弱口令建议放到密钥管理服务里--enable-cors主要是让浏览器端可以直接调用如果你全部走后端服务不开也可以。启动后访问http://localhost:8108/health返回{ok:true}就说明服务起来了。这里有个容易踩的坑Typesense的API路径不要带末尾斜杠比如http://localhost:8108/collections/在某些版本会返回404去掉斜杠就好了。另外API Key通过HeaderX-TYPESENSE-API-KEY传递不是放在URL查询参数里这点和ES差别挺大我第一次调接口习惯性地往URL塞?api_keyxxx结果一直是401。3.2 创建集合与批量写入数据Typesense里“集合”对应ES的“索引”创建集合时要提前定义好字段类型。Schema长这样{ name: products, fields: [ {name: id, type: string}, {name: title, type: string}, {name: category, type: string, facet: true}, {name: brand, type: string, facet: true}, {name: price, type: float, sort: true}, {name: tags, type: string[], facet: true} ], default_sorting_field: price }创建命令curl -X POST http://localhost:8108/collections \ -H Content-Type: application/json \ -H X-TYPESENSE-API-KEY: typesense-test-key \ -d product_schema.json字段设计上有一个提醒facet和sort这些标记要在一开始就规划好因为Typesense对已创建字段的修改限制比ES严格后补很麻烦。这就相当于ES里映射字段已经被写入后再调整要重建索引。批量写入数据用JSONL格式每行一条JSON文档走import接口curl -X POST http://localhost:8108/collections/products/documents/import?actioncreate \ -H Content-Type: text/plain \ -H X-TYPESENSE-API-KEY: typesense-test-key \ --data-binary products.jsonlimport接口的效率非常高和我认知里的ES_bulk在一个量级。如果文档里没有id字段重复import会创建重复记录所以Schema里一定要有主键字段。从ES迁移时我直接把ES里的_id映射到了Typesense的id字段上省去了一堆去重麻烦。3.3 搜索请求和ES查询习惯的对照Typesense的搜索接口走GET参数都放到query string里curl -X GET http://localhost:8108/collections/products/documents/search?q手机query_bytitlefilter_byprice:100price:5000sort_byprice:ascpage1per_page20 \ -H X-TYPESENSE-API-KEY: typesense-test-key对应的ES查询长这样{ query: { bool: { must: [{match: {title: 手机}}], filter: [ {range: {price: {gte: 100, lte: 5000}}} ] } }, sort: [{price: asc}], from: 0, size: 20 }如果你熟悉ES很容易把两边映射起来。TypesenseES说明qquery_byquerymatch/multi_match指定搜索关键词和字段filter_bybool.filter条件过滤sort_bysort排序page/per_pagefrom/size分页facet_byaggs.terms分类统计Typesense的写法明显更简洁适合把查询URL直接交给前端拼装但代价是复杂嵌套查询的表达力弱于ES的DSL。如果你的查询条件需要多重嵌套、布尔逻辑交叉会遇到一些限制这一点在后面避坑章节再展开。4. 实测对比同一批数据两边跑的结果4.1 测试环境与数据准备尽量保证公平在说任何数字之前先交代环境。我当时在一台8核16G的Linux云主机上做测试数据盘是SSD。ES用的是7.17版本的Docker单节点JVM堆设了4GTypesense用27.x版本同样跑Docker。数据是100万条模拟商品数据字段包括title、category、brand、price、tags。查询模式为随机取500个真实关键词每次都带价格区间过滤和按价格排序。这里必须说明ES单节点模式对它有点不公平毕竟生产环境一般会多节点。但我的业务本身就是中小规模不会为了一个搜索接口上五台机器所以这个对比结果对中小场景的参考价值更高。想看三节点、高并发、大数据量下的极限PK可以去翻官方发布的benchmark。4.2 写入吞吐与延迟对比写入方式上ES使用官方bulk接口每批5000条共200批Typesense使用import接口每批10000条共100批。指标ESTypesense总耗时204秒78秒写入期间CPU峰值接近打满约50%-60%稳定后内存占用6.2GB1.6GB写完之后我观察了一下Typesense快一部分原因是内存索引避免了大量磁盘刷新另一部分原因是它的写入链路比ES的_bulk简单不需要经过复杂的映射解析和translog机制。当数据量继续增大、磁盘刷新成为瓶颈时双方的差距会缩小所以这个数字只代表中等数据量下的表现。4.3 查询延迟与资源占用对比我用同一组关键词对两个引擎各跑1000次搜索请求统计P50和P99。指标ESTypesenseP50 延迟26ms3msP99 延迟95ms15ms单线程串行QPS约38约260在“关键词 价格过滤 价格排序”这种典型业务搜索场景下Typesense确实能达到ES五倍以上的速度。但我也要诚实说一句这不意味着所有场景都有这么大的优势。如果查询包含复杂聚合分析、全文相关性深度定制、多字段加权嵌套查询ES仍然有它的价值而Typesense恐怕连查询都不一定能正常表达。资源占用方面ES稳定后至少占6GB以上内存Typesense加上数据索引也才1.6GB左右。这对我这种被内存预算卡得比较紧的业务来说是很实在的省钱方案。5. 替换ES之前先想清楚这些边界5.1 功能边界Typesense没有的那部分ES能力Typesense并不会也不可能覆盖整个ELK生态迁移前一定要确认你的业务没有踩到以下雷区。聚合分析Typesense只有facet计数和简单的统计聚合ES那种丰富的aggs体系在它这里会被大幅压缩。父子文档和嵌套对象Typesense支持嵌套字段但复杂父子关系查询能力远弱于ES。Ingest PipelineES可以在写入前做数据清洗、字段加工Typesense没有都得自己在应用层处理。地理搜索Typesense有geo字段类型但复杂地理聚合是它的弱项。权限模型ES有RBAC、字段级权限Typesense的密钥管理简单得多适合内部服务隔离不适合多租户精细权限管理。我的建议是在决定替换之前把你业务里最复杂的几个查询全部列出来用Typesense实际跑一遍能通过再谈迁移。如果只是简单搜索那基本问题不大。5.2 中文分词、排序逻辑与搜索体验调优中文搜索是我在这个项目里最头疼的问题。Typesense默认使用Unicode文本切分对中文这种没有空格分隔的语言整段话很容易被当成一个token处理。搜索“洗衣机”这个完全包含的词理论上能匹配但如果文本是“家用 滚筒洗衣机”这种形式某个版本下效果就变得不稳定。我当时搜“手机”能命中搜“智能手机”却可能漏掉一堆包含“智能 手机”的记录特别打击信心。解决方案主要有三种。第一在Schema里文本字段的token_separators上配置得更细比如把标点、空格都设为分隔符但中文按词切分还是解决不了。第二自己做前置分词服务用jieba或HanLP这类分词器把文本切成词后用空格拼接再写入Typesense。这个方法最有效也是我最终采用的方案。代价是增加了索引链路的复杂度但你换来的是可控的中文搜索体验。第三新版Typesense对中文的支持有改善但如果你要做精细化词搜我仍然建议走前置分词这条路。排序也要注意。如果不指定sort_by默认按default_sorting_field字段排序。想按某个数字字段排序必须在Schema里先声明sort: true否则查询直接报错。相关性排序这块Typesense不如ES的BM25和TF/IDF可调参数那么细它主要靠内置相关度算法和字段权重对大多数业务搜索来说已经够用但别指望复刻ES里的复杂相关性调优逻辑。5.3 从ES迁移的可行路径与双写方案如果你决定迁移我推荐的路径是这样的。第一步写脚本把ES索引导出为JSONL格式建议用scroll接口分批拉避免一下子把ES内存打爆。第二步在Typesense里建好对应的Schema把JSONL批量导入。第三步做影子验证把线上真实查询日志复制一份并行打到Typesense上对比返回结果集以及延迟。这一步最关键要拿真实业务查询去比对而不是自己拍脑袋造几个case试一下就切。第四步双写过渡。新数据同时写入ES和Typesense查询先切到Typesense观察几天确认没有数据缺失和分页错乱问题。第五步稳定运行一段时间后再把ES的写入停掉ES可以保留为历史归档或复杂分析的查询源。这个路径不复杂但验证阶段绝不能省。我个人的实践体会是如果你现在的业务搜索核心就三个动作——关键词匹配、条件过滤、结果排序真的没必要背着一套JVM全家桶跑。Typesense在这个场景里给了我非常直观的性能回报而且部署和运维成本低到可以忽略。但我也得诚实说它不是万能的复杂分析和海量日志场景就别凑热闹了。最后再提醒一句网上所有“5倍”“10倍”的Benchmark都是别人在他那台机器和数据上跑出来的数字换到你的Schema和真实查询模式上结果可能完全不同。拿自己的数据实际测一遍比看一百份测试报告都管用。
返回列表