
如果你还在用ES扛所有的搜索场景我建议你先看完这篇再做决定。过去两年我一直在维护一整套ES集群日均搜索请求量几百万集群规模也不算小但真正让我开始反思的是一件小事一个只需要在3万条商品数据里做关键字搜索的内部工具ES从部署到调优花了两天最后查询的P95延迟还在80毫秒上下徘徊。后来我把同样一个需求切到一个叫Typesense的搜索引擎上从部署到上线只用了不到半天P95延迟压到了10毫秒以内。这个差距让我开始认真研究为什么会有比ES快5倍的搜索引擎它是靠什么做到的以及我们到底什么时候应该换掉ES这篇文章不是要鼓吹“ES已死”而是想把我对Typesense的完整测评、上线实操步骤、压测数据以及替换过程中踩过的坑全部摊开来讲。如果你正在做站内搜索、垂直搜索或者被ES的运维成本和查询延迟折磨这篇应该能帮你省下不少时间。1. 为什么我会去关注ES之外的搜索引擎1.1 一个被ES托住的小需求先说那个让我“破防”的内部工具背景。当时业务方提了一个很普通的需求运营后台需要一个搜索框从3万条商品资料里按名称、品类、品牌关键字搜索打开商品详情要求是结果秒出最好还要支持拼音首字母缩写搜索比如输入“jm”能匹配“洁面仪”这种。当时团队里ES是现成的大家理所当然就往ES上放。结果发现即使数据量只有3万条ES依然需要创建索引、设计mapping、考虑分片和副本一次普通的搜索请求要经过HTTP层、查询解析、分布式协调、Lucene检索再合并分片结果。在没怎么调优的情况下接口P95延迟能冲到80到100毫秒。这个性能对内部的低频工具来说不算不能用但这个响应速度背后消耗的资源却一点也不低两个ES节点常驻内存里跑着光JVM堆就给了8GB。我后来想明白一件事ES其实是被设计来解决更复杂的分布式全文检索和分析问题的我用它来做一个3万条数据的关键词匹配本质上就是“杀鸡用了牛刀”。而这个场景恰恰是Typesense这类轻量级实时搜索引擎最擅长的地方。1.2 ES性能瓶颈到底藏在哪不是ES搜索引擎本身不行而是ES的架构决定了它在很多场景下就是快不起来。我总结下来ES的性能开销主要来自四个地方。第一个是Lucene的段合并机制。ES底层的Lucene会不断把小段合并成大段合并期间会有CPU和磁盘IO的开销如果并发写入量高频繁的段合并会直接影响查询延迟。第二个是JVM的GC停顿和堆外内存管理。ES跑在JVM上堆内存和操作系统页缓存之间隔着一层GC一旦进入Full GC整个集群的查询响应就会肉眼可见地卡顿。你明明给ES留了32GB内存却没法保证所有数据都在热缓存里。第三个是查询链路的复杂度。一条ES请求从协调节点进来要解析DSL、重写query、分发到多个分片、每个分片各自检索、再汇总排序最后取回top N。在数据量不大的场景下这套分布式协议的开销占了大头真正花在Lucene检索上的时间反而很少。第四个是ES默认的字段类型映射很保守。很多字段默认是text加keyword双重映射倒排索引、正排doc_values都会生成写入放大和存储放大都相当可观。这些瓶颈不是ES的bug而是它为了支持复杂场景付出的代价。但问题在于当你根本用不到那些复杂场景时这些代价就成了纯损耗。1.3 “快5倍”的结论从哪来“Typesense比ES快5倍”这个说法最早出现在一些国外技术团队的性能对比博客里后来Typesense官方文档也引用了这类数据。它不是官方编造的数字而是有前提的对比在典型产品内搜索场景、数据量千万级以下、内存能放得下索引、查询模式以关键字搜索加轻量过滤排序为主时Typesense确实能跑出5到10倍的性能差距。我自己的实测放在第4章数据量是200万条商品记录在同一台物理机上对比简单关键词搜索场景下Typesense的QPS大概是ES的5.2倍P95延迟只有ES的五分之一左右。不过我必须先把话说明白这个数字不是普适的。如果你拿ES去做复杂的聚合分析或者数据量大到索引完全没法放进内存差距会迅速缩小甚至反过来被ES吊打。所以更准确的说法是在“支持实时写入、快速检索、低延迟返回”的这类场景里Typesense快5倍不是一句空话。2. Typesense跑得快的内核逻辑2.1 用Rust和C写一个专门干搜索活的服务Typesense的底层不是Lucene而是用C和Rust开发的。这件事本身就带来两个天然优势。第一没有JVM这一层就没有GC停顿。ES的高延迟毛刺很大一部分来自JVM GC而Typesense的内存管理是自己控制的它的索引结构大部分是连续内存块分配的代价极低访问模式对CPU缓存非常友好。第二编译型语言启动后没有“预热”的过程。ES刚重启完缓存是空的热数据要一点点加载进OS页缓存前几分钟的查询性能是很差的。Typesense启动时直接把索引加载到内存服务一起来查询性能就直接是满状态。我做替换测试时有一个很直观的体感容器重启之后ES至少需要几分钟来“热身”而Typesense从进程启动到能扛住高并发几乎不需要等待。2.2 内存优先磁盘兜底Typesense的设计哲学很直白——默认把索引整个放进内存。它官方给的数据是索引大概占原始JSON数据量的40%到50%也就是1GB的数据索引大小大概在400到500MB。一台32GB内存的机器可以支撑几十GB级别的数据量做全内存检索。这和ES有着本质区别。ES的索引很大部分依赖操作系统页缓存只有热数据常驻内存访问冷数据就要去读磁盘。而Typesense因为是设计为内存优先默认所有数据就在内存里没有“冷热”的区分每次查询的耗时都保持稳定。当然纯内存方案会让人担心数据持久化。Typesense的做法是异步写磁盘写入请求先进内存的写缓冲区同时追加到预写日志和索引中然后按批次刷盘。掉电最坏情况会丢极少量最后写入的数据这一点和很多内存型数据库类似。生产环境建议至少跑一个副本节点主节点挂掉时从副本恢复这个我们在第5章展开。2.3 查询路径比ES短得多我把ES和Typesense处理一次请求的路径画出来对比你就能理解差距在哪了。ES一次搜索请求的完整路径大概是客户端 - HTTP入口 - 解析DSL - 查询改写 - 协调节点路由 - 分片查询 - Lucene检索 - 分片结果聚合 - 全局排序 - 返回。这里面光是分片协调和结果合并就占了不小的耗时。Typesense的路径是客户端 - HTTP入口 - 解析query_by字段 - 直接走内存里的倒排索引和排序结构 - 返回结果。它默认就是单节点全量索引根本没有分片也不需要跨节点归并查询链路短了一大截。有人会说这种“单分片”设计在高并发下不会成为瓶颈吗实际上单节点单副本的Typesense在普通商品搜索场景下轻松扛住每秒几千次查询瓶颈往往在网络层而不是搜索引擎本身。如果你的查询量真的大到单节点扛不住Typesense也支持横向扩展但一定要先理解它的集群模式到底能做什么、不能做什么这点后面专门讲。2.4 默认参数就能跑出不错的效果用ES最大的心累之处是默认配置没法直接达到最优。你得手动调分片数、副本数、refresh间隔、translog刷盘策略、字段mapping、分词器还要在写入性能和查询延迟之间做权衡。我已经不止一次看到有团队上线一两个月后因为没调好bulk批量参数导致段数爆炸查询延迟翻倍。Typesense这边默认配置跑出来的效果就相当能打。它的schema设计得非常简单字段主要是string、int32、float、bool、string[]等几类每个字段可以单独声明是否要搜索、是否要排序、是否要作为过滤条件。没有复杂的映射链没有嵌套类型的花式玩法。索引结构在创建collection时就确定了所有标注了可搜索的字段自动进倒排索引可过滤的字段自动进正排索引。这种“少即是多”的设计意味着团队里不需要专门养一个ES专家才能跑好搜索。一个后端工程师看一遍官方文档基本就能在一天内把一个搜索服务完整搭出来。3. 从零搭建一个站内搜索服务3.1 部署不折腾一个容器就起来Typesense的部署相当轻量官方提供了静态二进制和Docker镜像两种方式。我建议你直接用Docker省去编译环境这一步。下面这个命令在任意一台2核4G的Linux服务器上就能跑起来docker run -d \ --name typesense \ -p 8108:8108 \ -v /opt/typesense-data:/data \ -e TYPESENSE_API_KEYyour-secure-api-key \ -e TYPESENSE_DATA_DIR/data \ -e TYPESENSE_ENABLE_CORS1 \ typesense/typesense:27.1几个参数我解释一下-p 8108:8108默认的HTTP端口是8108客户端SDK和curl请求都走这个端口。TYPESENSE_API_KEY这是你所有请求的身份凭证Typesense没有内置用户体系对外的请求全靠这个Key鉴权。生产环境一定要设一个足够长的随机字符串千万不要用默认值。/opt/typesense-data索引持久化目录必须用外部存储卷挂载否则容器销毁索引就全没了。启动之后可以验证一下服务状态curl -s http://localhost:8108/health返回{ok:true}就说明服务正常了。3.2 设计集合并导入第一批数据Typesense里和ES的index对应的概念叫collection翻译过来是“集合”。创建集合之前你要先想清楚哪些字段要参与搜索、哪些要用来过滤、哪些要用来排序。我以电商商品搜索为例设计一个简单但完整的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: sales, type: int32, sort: true}, {name: tags, type: string[], facet: true, optional: true} ], default_sorting_field: sales }说几个容易踩坑的细节如果你希望某个字段能在返回结果里作为聚合用的facet比如按品牌筛选、按分类分组那就要加上facet: true否则前端展示不出来。需要按价格排序或按销量排序的字段一定要加sort: trueTypesense对排序字段有单独的索引预计算。default_sorting_field是必填项它决定了用户不指定排序时的默认顺序建议选一个数字类型的字段。创建集合curl -X POST http://localhost:8108/collections \ -H X-TYPESENSE-API-KEY: your-secure-api-key \ -H Content-Type: application/json \ -d schema.json然后准备一份JSONL格式的数据文件每行一条商品记录{id: p001, title: iPhone 15 Pro 256G 原色钛金属, category: 手机, brand: Apple, price: 8999.00, sales: 23000, tags: [5G, 旗舰]} {id: p002, title: 小米14 Ultra 512G 黑色, category: 手机, brand: 小米, price: 6499.00, sales: 15000, tags: [5G, 影像]}批量导入curl -X POST http://localhost:8108/collections/products/documents/import?actioncreate \ -H X-TYPESENSE-API-KEY: your-secure-api-key \ --data-binary products.jsonl \ -H Content-Type: text/plain导入接口支持批量实测单批次建议控制在1000条到5000条之间一条JSONL一行最后不需要加额外的分隔符。批量导入的速度非常快200万条数据在这种方式下大概几分钟就能进完这还是单线程curl的结果换成并发导入会更快。3.3 搜索API的使用要点Typesense的搜索接口非常简单全部走HTTP GET请求参数都在query string里。一个基础搜索示例curl -s http://localhost:8108/multi_search \ -H X-TYPESENSE-API-KEY: your-secure-api-key \ -H Content-Type: application/json \ -d { searches: [ { collection: products, q: 手机, query_by: title,tags, filter_by: price:1000 price:8000, sort_by: sales:desc, facet_by: category,brand, page: 1, per_page: 20 } ] }我来逐个拆解这些参数q查询关键字支持普通文本、前缀匹配、错字容忍等能力。query_by指定在哪些字段里搜索多个字段用逗号分隔。filter_by过滤条件语法直观支持数值范围、字符串匹配、布尔逻辑组合。上面的条件表示价格在1000到8000元之间。sort_by排序方式默认按sales降序。注意如果你用了文本相关性排序可以写_text_match:desc它会把文本匹配度作为第一排序条件。facet_by聚合统计字段返回结果里会带上每个类别的商品数量和每类别的品牌分布。响应结构也很简化{ facet_counts: [ { field_name: category, counts: [ {value: 手机, count: 123}, {value: 耳机, count: 45} ] } ], found: 168, hits: [ { document: { id: p001, title: iPhone 15 Pro 256G 原色钛金属, price: 8999.00, sales: 23000 }, highlight: { title: mark手机/mark } } ], search_time_ms: 3 }search_time_ms字段直接告诉你这一次搜索在引擎内部实际消耗的毫秒数。我实测最简单的单字段搜索经常是1到2毫秒这在ES里是很罕见的。3.4 实时写入与异步消费场景“ES异步写入”之前是一个比较火的技术话题核心是因为ES的Java原生客户端比较重而且批量写入有refresh间隔、内存堆积、反压处理等讲究。Typesense在这方面要省心很多因为它没有Java SDK的依赖负担交互就是普通的REST API。比如你的上游服务产生一条商品变更记录发到消息队列后面挂一个消费worker去更新Typesense里的文档。worker只需要把变更数据组装成JSON文档调用导入接口即可。一个Java端的异步写入示例大致是这样HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); String body String.format( {\id\:\%s\,\title\:\%s\,\price\:%s}, product.getId(), product.getTitle(), product.getPrice() ); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(http://localhost:8108/collections/products/documents/import?actionupsert)) .timeout(Duration.ofSeconds(5)) .header(X-TYPESENSE-API-KEY, your-secure-api-key) .header(Content-Type, text/plain) .POST(BodyPublishers.ofString(body)) .build(); client.sendAsync(request, BodyHandlers.ofString()) .thenAccept(resp - { if (resp.statusCode() ! 200) { // 记录失败日志投递回消息队列 } });注意这里用了actionupsert而不是actioncreate这样文档如果已存在就覆盖更新不存在就插入不需要额外先走一遍查询判断。我个人的建议是不要一条一条地发HTTP请求最好在worker里攒一批再发。具体可以把100条文档拼成一个JSONL格式的大字符串一次POST上去。这样吞吐量会明显提升对引擎的压力也小很多。3.5 一个完整的Java异步写入示例如果你用的是Spring Boot结合WebClient可以做得很顺手。这里给出一个批量异步写入的简化版Service public class ProductIndexWriter { private final WebClient webClient; public ProductIndexWriter(Value(${typesense.url}) String url, Value(${typesense.api-key}) String apiKey) { this.webClient WebClient.builder() .baseUrl(url) .defaultHeader(X-TYPESENSE-API-KEY, apiKey) .defaultHeader(Content-Type, text/plain) .build(); } public MonoString bulkUpsert(ListProduct products) { String body products.stream() .map(this::toJsonl) .collect(Collectors.joining(\n)); return webClient.post() .uri(/collections/products/documents/import?actionupsert) .bodyValue(body) .retrieve() .bodyToMono(String.class); } private String toJsonl(Product p) { // 这里使用Jackson把Product转成紧凑JSON字符串 } }更新操作对应/documents/import?actionupsert删除操作更简单curl -X DELETE http://localhost:8108/collections/products/documents/p001 \ -H X-TYPESENSE-API-KEY: your-secure-api-key这种纯REST接口带来的最大好处是无论你是Java、Go、Python还是Node只要会用HTTP接Typesense基本没有学习成本。相比ES必须维护一套重量级客户端SDK、跟踪版本兼容性这个体验确实舒服太多。4. 实测数据同一批商品数据下ES和Typesense差多少4.1 测试环境说明为了尽可能公平我把ES和Typesense跑在同一台物理机上避免跨机器网络延迟差异。测试机器配置是4核8GB内存固态硬盘CentOS 7系统。这个配置在中小型团队里很常见。数据集是我生成的一批模拟电商数据总共200万条商品记录字段包括商品ID、标题、品牌、分类、价格、销量、标签。标题是中文和英文混合的带有一定的重复前缀模拟真实商品的命名风格。ES部署的是7.10版本1个节点默认配置索引3个主分片1个副本副本在同一台机器上其实不参与读但保留常规生产配置refresh_interval保持默认的1秒。Typesense是27.1版本单节点所有索引载入内存。4.2 查询场景和压测方式压测工具用wrk它会用多线程模拟并发请求。每种工具先做1分钟预热然后压测5分钟取稳定区间的数据。我设计了三种有代表性的查询场景场景A基础关键词搜索。搜索词是“手机”只做文本匹配不加过滤不加排序返回20条结果。场景B关键词加过滤加排序。搜索“耳机”过滤条件价格在100到2000元之间按销量降序。场景C带错字的搜索。搜索“sumsung”被人为拼错希望引擎的错字容忍能力能把它纠正为“samsung”。这三种场景基本覆盖了站内搜索最核心的诉求。4.3 结果表格压测数据整理后是这样的查询场景ES QPSES P95延迟(ms)Typesense QPSTypesense P95延迟(ms)场景A 关键词搜索51246.227808.1场景B 过滤排序32078.5164312.3场景C 错字纠错188131.6104019.7这里说明一下ES的数据是在这个低配单节点上跑出来的你要是上了三节点集群和SSD配上完美的分片策略数字会好看不少。但反过来看Typesense是在单节点、零参数调优的情况下跑出来的它用的是完全不同的性能基线。最夸张的场景ATypesense的QPS是ES的5.4倍P95延迟是ES的六分之一。场景B加了过滤和排序之后差距缩小到5倍左右。场景C因为错字纠错是个高CPU操作两边都慢下来但Typesense依然保持了数量级的优势。4.4 资源占用对比性能之外资源占用也是我关注的重点。ES启动后进程常驻内存是3.2GB其中JVM堆给了2GB另外还有不少堆外内存和Lucene段缓存。打满压测时系统内存占用最高到5.8GB。Typesense加载完200万数据后索引文件大概是原始JSON数据量的45%约670MB进程常驻内存约800MB。打满压测时内存占用也才1.1GB左右。同样的数据规模Typesense吃掉的资源只有ES的五分之一上下。这就带来了一个很实际的好处很多场景下你不再需要给搜索服务准备一台高配机器一台普通的2核4G云主机就能扛住线上流量。运维成本一降整个人都轻松不少。5. 踩坑换来的经验哪些场景不建议无脑替换5.1 中文分词的坑别指望开箱即用Typesense自带的分词器是基于字符的语言无关切分对英文和拼音场景效果很好但遇到中文这种需要语义分词的场景默认行为是把连续中文字符按二元组切分。什么意思呢“人工智能”会被拆成“人工”“工智”“智能”看起来好像没问题但遇到“重庆火锅”这类容易产生歧义的词或者“中华人民共和国”这种长词默认分词的召回率和准确率都不太理想。我踩过最大的坑是搜索“华为手机壳”结果是先按“华为”匹配了一堆手机再按“手机壳”匹配了一堆壳能同时命中“华为”“手机壳”两个词的商品排序反而不靠前。这就是二元切分没有词义边界导致的。解决方法有两个。第一个是在写入前把中文文本预处理成扩展字段把业务已知的关键词以标签形式存进去比如title_keywords字段里写入“华为 手机壳 官方 超薄”搜索时把query_by指向这个扩展字段。第二个是如果前端搜索允许可以考虑引入外部的分词服务或IK分词器把分词结果作为一个独立的字符串数组字段写入然后在查询时用query_by匹配这个字段。总之中文场景不要拿Typesense默认配置直接上生产一定要先验证召回和排序效果。5.2 高可用和横向扩展的真实边界Typesense的集群模式是raft协议一个leader节点多个follower节点。写请求只能发到leader读请求可以分散到follower。这意味着它的写扩展性是有上限的所有写入都会汇聚到单点。ES的写扩展性则要好得多可以分片写分散到整个集群。如果你的业务是日志数据这种每天几亿条的写入流Typesense并不合适。它的定位是产品内搜索、站内搜索这类场景写QPS通常不高但读QPS很高所以单leader写不是一个问题。另外Typesense的节点数建议控制在5个以内太多节点会让raft协议本身成为新的可维护性负担。ES在几十个节点规模下依然管理得井井有条两者面向的集群复杂度完全不同。5.3 生态和可视化能力的缺失ES之所以是国内搜索的事实标准一半功劳要给它的生态。Kibana提供了一整套可视化监控、索引管理、查询调试界面Logstash、Filebeat能直接对接各种数据源官方还提供了各种语言的成熟SDK。而Typesense这边官方只有一个简单的Web管理界面和几套SDK没有可视化查询工具没有丰富的报表能力没有配套的数据导入管道。这意味着如果你需要深度诊断一次查询为什么召回异常基本要靠自己去看响应里的debug信息或者直接对索引文件做检查。习惯了Kibana“点几下就能看到查询计划”的人切换到Typesense初期会有些难受。不过Typesense暴露了Prometheus的metrics接口我个人的做法是把索引大小、查询延迟、内存使用这些指标接到Grafana上自建监控面板效果比Kibana的监控页更贴近自己业务。5.4 复杂聚合分析能力有限ES的aggregation框架非常强大可以做到多维下钻、日期直方图、嵌套聚合、百分位统计等等。如果你需要从搜索数据里提取业务分析结果比如“按品牌分组的近30天销售走势”ES的date_histogram加terms聚合几行DSL就能搞定。Typesense目前只提供基础的facet统计和group_by聚合能做到“按品牌分组统计数量”和“按分类下钻”但跨多维度、嵌套组合、时序运算这些场景基本无能为力。它会返回每个facet的count但不会帮你做复杂的分析运算。所以在选型时要想清楚一件事你的搜索服务是纯搜索还是“搜索加分析”的组合体如果是后者不要轻易把ES整体替换掉更合理的方案是让ES继续承担分析职责把高频低延迟检索的流量切给Typesense。5.5 什么时候不推荐替换我把不适合替换的场景列一个清单数据量超过内存可承载上限且不想花大量成本买内存。需要频繁做多字段嵌套聚合分析。数据模型复杂需要ES的nested object或parent-child关联。团队已经深度依赖Kibana做日常运维和查询调试。写入吞吐要求极高比如每秒上万条日志持续灌入。遇到这五类情况我建议还是继续用ES硬换Typesense只会给自己挖坑。6. 要不要换我的最终选型判断6.1 适合替换的信号在我实际经历和见过的案例里下面这几种场景是最适合切到Typesense的产品内的站内搜索数据量在几百万到几千万级别索引能放进内存。对响应延迟非常敏感要求P95稳定在20毫秒以内。查询模式相对固定主要是关键词加过滤加排序不需要复杂聚合。团队里没有专职ES DBA需要一个人搞定搜索服务的全部维护。预算有限不想为了一个搜索服务常年养几台高配机器。简单说只要你的索引能放进一台机器的内存且查询模式不需要重度分析换到Typesense后不但性能会明显提升维护成本也会大幅下降。6.2 渐进替换路径我不建议一次性把线上ES集群整个下线。稳妥的做法是走双跑灰度路径。第一步把数据从业务库双写到ES和Typesense。可以用消息队列做双发也可以先写ES再通过一个同步job把增量文档转发到Typesense。这样两边数据保持一致。第二步在测试环境对比两边搜索结果。重点是查漏确认Typesense的召回结果和ES没有明显差异排序规则也能对齐。一旦发现某个查询在Typesense里结果不对优先检查分词字段和过滤条件是否配置正确。第三步把线上读流量切成灰度比例先切10%。观察几天确认没有实时问题或搜索异常后再逐步提升到50%、100%。同时在灰度期间把搜索质量指标、延迟指标、错误率统一接到监控看板一旦异常随时回滚。6.3 最后分享一个小技巧如果你最终决定用Typesense在数据导入阶段有个细节值得留意批量导入时不要一次性把所有文档全丢进去建议按业务维度分组一批批地导。这样做的好处是如果某批次数据格式有问题你能立即定位到是哪个文件里哪一行的问题而不用在几百万条数据里大海捞针。另外线上环境建议在Typesense前面加一个简单的Nginx或CDN缓存层把热门的搜索词缓存5到10秒。这样即使瞬时流量翻倍上游搜索引擎的压力也不会跟着翻倍。我用这个方案把搜索接口的峰值QPS扛到了Typesense单机极限的三倍以上后端毫无压力。我在几次实际项目中体会最深的一点是搜索场景很多时候不需要ES那种重型武器Typesense的价值不是“干掉ES”而是让团队用十分之一的维护成本去承接九成常见的搜索需求。技术选型永远不是越强大越好而是越匹配越好。希望这篇实测能帮你在做搜索引擎选型时多一个可靠的选择。