从 ES 迁到 Typesense:一次搜索延迟从 1s 压到 120ms 的完整复盘
2026/9/19 17:44:56 网站建设 项目流程

三年前谁跟我说要把核心搜索从 ES 换掉,我大概会觉得他拿线上事故开玩笑。真正让我动换引擎念头的是去年 Q3 的一次排查:一个三节点的 ES 集群,单机 32G 内存,日志类文档大约 1.2 亿条,业务侧反馈"搜索框打字之后要等一下才出结果",我们拉监控一看,简单关键词查询 p99 稳定在 700ms 到 1.1s 之间,而那台机器 CPU 和内存都还没到瓶颈。扩容加了一个节点,延迟只降了两百来毫秒,成本却翻了一截。后来我把同一份数据导进另一个用 Rust 写的搜索引擎做对照,同样的查询、同样的机器规格,p99 掉到了 120ms 上下。

这篇文章就是把那次折腾的完整过程摊开讲:ES 慢在哪里、"快 5 倍"这个说法在什么口径下成立、换成谁、怎么落地、中文搜索怎么处理、以及从 ES 迁过来一定会踩到的那几个坑。适合正在被 ES 延迟和资源账单折磨的后端同学,也适合第一次要选搜索引擎、还不知道该按什么维度做判断的人。看完你至少能算清楚:你手上的数据量到底该用哪个引擎,以及换了之后能省下多少机器。

1. 把"快 5 倍"拆开看:ES 的耗时到底花在哪几层

先从结论倒推。一个查询请求进到 ES,延迟大致由这几块组成:协调节点的请求分发、各分片的查询执行、倒排索引的查找与打分、结果归并排序、以及 JVM 层面的 GC 抖动。业务同学看到的"慢",往往是这五块里某两三块叠加的结果,而不是 ES 本身"不行"。

1.1 JVM 堆和 GC:最容易被忽略的随机延迟来源

ES 跑在 JVM 上,这件事带来的第一个代价是内存模型的双轨制。你给机器配 64G 内存,官方建议堆只给一半(剩下留给 Lucene 的文件系统缓存),也就是 31G 左右。但堆内堆外的对象生命周期完全不一样:查询期间的聚合桶、排序缓冲、分片请求上下文全在堆里分配,请求量一上来,Young GC 频率立刻上去。

我印象最深的一次是压测时把并发从 50 拉到 200,平均延迟只涨了 30%,但 p99 直接翻了三倍。用_nodes/stats/jvm拉了一下 GC 数据,老年代回收从每分钟 0.2 次涨到每分钟 3 次,每次停顿 200ms 以上。这种停顿是全局的,跟你的查询复不复杂没关系,纯属被动挨打。

原生内存方案就绕开了这个问题。Rust、Go、C++ 写出来的搜索引擎大多没有托管堆,内存按结构体分配,生命周期在编译期就确定,运行时不存在"停下来回收"的动作。这是"快"的第一个来源,也是最难靠调参追平的一个。

注意:如果你的 ES 集群 p99 是 p50 的 5 倍以上,先别急着换引擎,优先去查 GC 日志和分片热点,有很大概率是配置问题而不是引擎问题。

1.2 分片扇出:分片数不是越多越好

ES 的查询模型是"广播 + 归并"。一个索引有 N 个主分片,每次查询要打到所有分片(有副本时按轮询选一个),每个分片各自查各自的、各自打分、各自返回 Top K,最后协调节点把 N × K 条结果归并再排序。

这就带来一个反直觉的结论:主分片数越多,单次查询的固定开销越大。业界常说的"单分片控制在 30GB 到 50GB",本质是为了控制扇出。假设你有 200GB 数据:

  • 5 个主分片 → 每分片 40GB,查询扇出 5
  • 20 个主分片 → 每分片 10GB,查询扇出 20

后者的索引更小、单分片查询更快,但固定开销(线程池调度、网络往返、结果归并)变成了 4 倍。我实测过一个纯关键词查询,在 5 分片和 20 分片下的 p50 差了将近 3 倍。很多人扩容时习惯"数据涨了就加分片",结果越加越慢,就是这个原因。

分片数只能在建索引时指定,之后要靠_shrink_reindex或者 rollover 才能调整,代价非常高。所以这一步的规划做错,后面基本没救。

1.3 refresh、translog 与段合并:写入路径上的隐性成本

ES 的"近实时"是拿延迟换来的。默认refresh_interval是 1s,意味着文档写进去之后最多 1 秒才能被搜到。如果你做的是"提交后立刻要能搜到"的业务(比如工单、IM 消息),就得把 refresh 调成-1或者手动 refresh,而每次 refresh 都会生成新的 Lucene 段,段一多,查询要遍历的段数就多,性能雪崩。

更麻烦的是段合并。ES 在后台持续把小的段合并成大的段,这个过程吃 IO 和 CPU,且在合并期间新旧段同时存在,磁盘占用会短时间翻倍。我见过一个场景:单日新增 800 万条日志,段合并把磁盘 IO 打满,连带着查询延迟一起抖。

Rust 系搜索引擎在这块普遍做了简化。缓存层直接按文档 ID 维护位图,写入后立即可搜,没有 refresh 概念,也不需要合并段。这不是"优化得更好",而是"压根不走那条路"。这也是为什么它敢承诺写入后毫秒级可见,而 ES 必须讲"近实时"。

1.4 一个被忽略的事实:ES 本来是分析引擎

我觉得最根本的原因是定位。ES 的强项是聚合分析、复杂布尔查询、大规模日志检索,它继承了 Lucene 的完整打分能力(BM25、各种 function_score),还要支持 aggregation、pipeline、script 这些重功能。这些能力都要在查询路径上付出代价。

而"搜索框打字即时出结果"这件事,需要的能力其实很窄:前缀匹配、容错匹配、少量过滤、按相关性排序。用一个全能引擎干这件窄事,就像开卡车送外卖——能送,但每单都要付油钱。

把上面四条合起来看,"快 5 倍"在什么场景下成立就清楚了:

维度ES 的开销来源原生内存引擎的做法对延迟的影响量级
运行时JVM 堆 + GC 停顿无托管堆,无 GCp99 差 3-10 倍
查询模型分片扇出 + 结果归并单节点全量索引,无扇出p50 差 1.5-3 倍
可见性refresh 延迟 + 段合并写入即可见写入侧抖动明显
打分完整 BM25 + 脚本轻量相关度 + 位运算单次计算省 30%-60%

在"窄字段、高并发、强前缀、要求毫秒级可见"的场景里,5 倍是个保守说法;但如果你要做多字段加权聚合、地理围栏加脚本排序,ES 反而可能更快更稳。换引擎之前,先想清楚你的查询是不是那个"窄场景"。

2. 我最后选了谁:Typesense 的定位,以及它和几种常见方案的差别

说实话我对比过四五个方案,最后落地在 Typesense 上,主要原因是它在"即时搜索"这个窄场景里做得最纯粹,同时中文处理和向量检索都能兜住。但我不想把它说成万能答案,下面把当时对比的几个都摊开讲。

2.1 先排除几个常见误会

第一个误会是"它是个缩小版 ES"。不是。它没有 aggregation 体系,没有 pipeline 聚合,没有脚本排序,也没有 ES 那套_analyzeAPI。它的查询接口是一堆 URL 参数,schema 只能建成扁平的字段集合。你把它当 ES 用,第一天就会撞墙。

第二个误会是"数据可以比内存大"。不行。Typesense 的索引常驻内存,磁盘只是做持久化和重启恢复。这意味着你的数据集必须能装进单机可用内存。官方给的经验是 1GB 内存大致能承载 10 万到 100 万条中等大小文档,波动区间这么大是因为文档字段数和字符串长度影响极大。这一条是硬约束,也是它在选型阶段最容易被低估的门槛。

第三个误会是"它不能替代 ES 的日志场景"。这句话对一半。纯日志检索、追求低成本、数据量上 T,那我会推荐 Quickwit 这种面向对象存储的方案,而不是 Typesense。Typesense 更适合"用户会去搜索、且对延迟敏感"的业务数据。

2.2 几个引擎放在一起看是什么样

下面这张表是我自己跑下来加官方文档核对整理的,不是官方对比表,仅供参考:

引擎语言数据驻留强项明显短板
ElasticsearchJavaJVM 堆 + 磁盘聚合分析、生态、海量数据资源占用高、GC 抖动、调参复杂
OpenSearchJava同上同上,社区分支同上
TypesenseRust/C++全内存 + 磁盘持久化即时搜索、低延迟、内置向量检索数据集受内存限制、无聚合
MeilisearchRust内存映射 + 磁盘前缀搜索体验好、上手快复杂过滤与向量能力稍弱
QuickwitRust对象存储日志检索成本低、支持聚合面向批式检索,非强实时小文档
ZincSearchGo磁盘部署轻、资源占用小生态与更新节奏偏慢

几个补充说明。Meilisearch 的前缀搜索体验确实做得好,"打字即出"很顺,但它的过滤表达能力(尤其是多条件组合和数值范围)比 Typesense 差一档,我当时有个业务需要按时间段加标签做复合过滤,Meilisearch 写起来很别扭。Quickwit 我拿来做过日志归档检索,成本优势非常明显,但它的写入到可搜延迟是秒级,不适合搜索框场景。ZincSearch 胜在轻,但社区更新节奏让我不太敢放在核心链路上。

2.3 内存换速度:这套模型的代价与收益

Typesense 选择全内存索引,本质上是把"磁盘随机读"这个最不可控的环节彻底删掉了。搜索路径上所有操作——倒排链取交集、位图过滤、前缀扫描——全在内存里完成,没有 page cache 命中率这个变量。

收益是延迟极其稳定。我压测时 p50 和 p99 的比值基本在 1.5 以内,这在 ES 上很难做到。代价是内存成本,而内存通常比 SSD 贵。所以这笔账要算清楚:

假设数据集 400 万条文档,平均每条 600 字节原始 JSON 原始数据约 2.4GB 索引开销按原始数据 1.5~2.5 倍估算(取决于字段数和 facet 数量) 实际内存占用约 6~9GB 建议预留 1.3 倍余量用于导入峰值和查询缓冲 单节点建议配 16GB 内存

这个估算方法和实际值能对得上,误差主要来自 facet 字段数量——每加一个 facet 字段,会额外维护一份带计数的倒排结构,占用增长很快。

提示:如果你打算把所有字段都设成 facet,内存会以肉眼可见的速度膨胀。facet 只留给真正要做分组统计和筛选的字段。

2.4 什么情况下我会劝你别换

有三类场景我会直接劝退。数据量已经超过单机内存上限,又不想做分片路由的;重度依赖聚合分析和复杂 DSL 的;团队没人愿意维护一套新的运维链路、也拿不到额外机器做双跑的。

尤其是第三条。迁移期间的常规做法是双写加灰度,意味着有一段时间你要同时维护两套引擎,机器成本和人力成本都是双份。如果这套搜索不是你业务的核心竞争力,只是"差不多能用就行",那优化 ES 参数可能比换引擎划算得多。

3. 从零到跑通:部署顺序和我踩过的配置坑

假设你已经决定动手试试,下面是我认为比较省事的推进顺序:单机跑通 → 导入真实数据 → 压测确认 → 再考虑集群。别一上来就搭集群,Typesense 的集群模型和 ES 差别很大,先搞清楚单机行为再上规模。

3.1 单机起步:容器方式最快,但挂载目录别写错

最快的起步方式是容器:

docker run -d --name typesense \ -p 8108:8108 \ -v /data/typesense:/data \ typesense/typesense:<你拉取的稳定tag> \ --data-dir /data \ --api-key=YourRandomApiKey \ --enable-cors

几个坑必须提前说。--data-dir必须和挂载路径一致,否则数据写在容器里,重启就没了。--api-key换成一个高熵随机串,这个 key 是管理员权限,可以直接删库。--enable-cors只在你有浏览器端直连需求时才开,且一定要配合搜索专用 key(只能搜不能写),千万别把管理 key 塞进前端代码。

裸机二进制方式也简单,下载对应平台的压缩包解压后直接执行,它是个静态链接的单文件,不带任何运行时依赖。这一点我很喜欢——ES 要配 JDK 版本、要调jvm.options、要管 heap size,这里全都不用管。

另外提一句,官方的健康检查接口是GET /health,返回里带ok: true就算起来了。我第一次部署时把端口映射写错,容器日志显示启动成功但外部访问不通,排查了十几分钟才发现是端口的事。

3.2 Collection 的 Schema:一开始就要想清楚的三件事

Typesense 里没有"索引"概念,对应的是 collection。建 collection 就是定 schema,接口是POST /collections

{ "name": "articles", "fields": [ { "name": "title", "type": "string" }, { "name": "content", "type": "string" }, { "name": "author", "type": "string", "facet": true }, { "name": "tags", "type": "string[]", "facet": true }, { "name": "published_at", "type": "int64", "sort": true }, { "name": "view_count", "type": "int32", "sort": true }, { "name": "embedding", "type": "float[]", "num_dim": 768, "optional": true } ], "default_sorting_field": "published_at" }

三个需要提前想清楚的点:

字段类型一旦定义,改起来必须重建 collection。这点和 ES 的 mapping 一样讨厌。int32 和 int64 别混,时间戳统一用 int64 存秒或毫秒。

sort: true只给真正要排序的数值字段开。每开一个,就要额外维护一份按该字段排好的索引结构,占内存。文本字段不能直接排序。

facet: true决定能不能做分组统计和筛选。需要filter_by的字段一般也要开 facet,否则过滤能力受限。

还有一个index: false的选项,用于"要存但不需要搜"的字段,比如原始 URL、内部 ID。开了能省不少内存,我一开始没注意,所有字段都默认索引,内存直接多用了 40%。

3.3 数据导入:NDJSON 格式和四种 action 的区别

导入接口收的是 NDJSON(每行一个 JSON 对象,行间不加逗号):

curl -X POST 'http://localhost:8108/collections/articles/documents/import?action=upsert' \ -H 'X-TYPESENSE-API-KEY: YourRandomApiKey' \ --data-binary @articles.ndjson

action有四个取值,行为差别很大,选错会丢数据:

  • create:文档 ID 已存在就报错,适合首次全量导入
  • upsert:存在则整条覆盖,不存在则新建,最常用
  • update:只更新请求里带的字段,其余保留,适合增量更新
  • emplace:存在则合并字段,不存在则创建,等价于带部分更新的 upsert

返回体是逐行的结果,每行一个 JSON,包含成功或失败原因。一定要解析这个返回体,不能只看 HTTP 状态码。我踩过一次坑:批量导入时部分文档因为字段类型不匹配被拒,但 HTTP 返回 200,我以为全成功了,直到业务侧反馈少了三千多条数据才发现。

批量大小方面,我实测单批 1000 到 5000 条比较合适,太小网络开销占比高,太大单请求超时且失败重试代价高。导入并发控制在 4 到 8 路,太多会因为写锁竞争反而变慢。

如果是从 MySQL 同步,思路和 canal 那套很像但实现要自己写:用 binlog 捕获变更,转成 NDJSON,按updateemplace打到导入接口。区别在于 Typesense 不需要维护"同步延迟"这个指标——写进去立刻能搜到,比 ES 省心。

3.4 集群模型:节点数决定的是冗余,不是容量

这一点必须单独强调,因为它和 ES 的直觉完全相反。

ES 里加节点可以水平扩展存储容量(分片会分到不同节点)。Typesense 里每个节点持有完整数据副本,加节点只能提高读并发和容错能力,不能让你的数据集变大。集群协调基于 Raft,写操作需要多数节点确认,所以节点数越多,写入延迟越高。

这意味着两个直接后果:第一,容量上限由单机内存决定,规划时必须按最大单节点内存来倒推数据规模;第二,集群节点数建议奇数(3 或 5),别搞 4 个,Raft 在偶数节点下没有额外收益还增加写延迟。

我用 3 节点跑过一轮压测,读吞吐大约是单机的 2.6 倍,写入延迟比单机高了 15%-20%。这个数字不算惊艳,但对读多写少的搜索场景够用了。

4. 中文搜索这块最容易被低估:分词、召回和调参顺序

从 ES 迁过来的人,第一反应往往是"中文怎么办"。ES 有 IK、jieba 这些成熟插件,装完就完事。Typesense 没有内置的中文分词器,官方提供的是按字符切分加前缀匹配的方式,直接用来搜中文,效果会让人失望。

4.1 默认分词对中文不友好的具体表现

Typesense 默认按非字母数字字符和空格切词。中文一句话没有空格,它可能会把整句当成一个 token。虽然它对 CJK 有一定支持(会把中文按字符处理,配合前缀匹配能出结果),但问题很明显:

搜"搜索引擎优化",如果文档里写的是"搜索引擎的优化策略",默认处理下"优化"这个词能匹配上,但"引擎优"这种跨词边界的组合会导致大量噪声结果,相关度排序也会乱。更糟的是同义词——"手机"和"移动电话"完全无法关联。

我实测过一版纯默认配置,搜索结果里有将近三成是明显不相关的,业务方当场就否了。

4.2 应用层切词,是当前最实用的方案

我的做法是在写入前用中文分词库(Python 侧用 jieba,Java 侧用 HanLP 或 ansj)把文本切成空格分隔的词串,同时把原文也存一份用于展示:

import jieba def build_index_doc(raw): seg_title = " ".join(jieba.cut_for_search(raw["title"])) seg_content = " ".join(jieba.cut_for_search(raw["content"])) return { "id": raw["id"], "title": seg_title, # 切词后,参与搜索 "content": seg_content, # 切词后,参与搜索 "title_raw": raw["title"], # 原文,不索引,仅展示 "content_raw": raw["content"], "published_at": raw["published_at"], }

Schema 里title_rawcontent_raw设成index: false,只存不搜。

查询侧也要做同样的处理,把用户输入的查询串切词后拼成空格分隔的形式再传给接口。这里有个细节:查询用cut还是cut_for_search,建议和写入侧保持一致,否则词边界对不上会影响召回。我最初写入用cut_for_search、查询用cut,导致长词拆不开,召回率低了将近 15%。

提示:切词词典要支持热更新。业务侧经常会出现新品名、新术语,词典跟不上就会出现"新词搜不到"的问题。我现在的做法是词典文件放配置中心,变更后触发一次词典重载。

4.3 召回率和准确率的调参顺序

Typesense 的搜索参数很多,我建议按这个顺序调,不要一次全开:

参数作用我建议的值什么时候改
query_by指定搜哪些字段主字段在前一开始就定
query_by_weights字段权重标题 3,正文 1相关度不满意时
num_typos允许的错字数中文场景建议 1 或不启用中文下错字容错收益低
prefix末词前缀匹配搜索框场景给 true打字即时搜索必开
drop_tokens_threshold丢词兜底阈值默认即可结果数为 0 时兜底
prioritize_exact_match精确匹配优先true追求准确率时

关于num_typos,这点中文和英文差别很大。英文拼写错误常见,容错一两个字符很有价值;中文用户打错字通常整句语义就变了,开容错反而引入噪声。我最后给中文场景设成 0 或 1,英文标题字段设成 2。

相关度排序上,Typesense 会返回一个text_match分数,默认按它排序。如果想要"精确匹配排最前",开prioritize_exact_match效果很明显。我调完这一项,首页首条结果的点击率涨了差不多 20%。

4.4 向量检索兜底:什么时候值得上

热词里提到向量检索慢,这点我深有体会。ES 上做向量召回,尤其在大索引上,延迟经常是普通关键词查询的好几倍。Typesense 内置了向量字段,用 HNSW 类的近似最近邻,实测延迟可控。

我的用法是把它当作召回兜底而不是主检索通道:先走关键词,如果结果少于阈值,再用向量补一批。流程大致是:

{ "q": "搜索引擎优化方法", "query_by": "title,content", "vector_query": "embedding:([0.12, -0.03, ...], k:10)", "prefix": true, "num_typos": 1 }

vector_query的参数里k是取前 K 个近邻。实际用下来,关键词和向量同时开启时,延迟会比纯关键词高 30%-60%,所以不建议默认全开,而是按需触发。

向量维度要和字段的num_dim严格一致,我因为换了 embedding 模型但忘了改 schema,导入时直接被拒,返回的错误信息还挺模糊,排查了一阵子。换模型的时候,记得同步重建 collection。

5. 把延迟压到目标:内存估算、查询取舍和一份压测方案

前面讲的是能不能跑通,这一节讲怎么跑得快。我把当时调优的几条主线整理出来,你可以照着核对一遍自己的场景。

5.1 先把内存账算清楚,再谈性能

内存估算是选型阶段最关键的一步,因为估错了后面全是白干。我的算法是分四块:

总量 = 原始数据 + 索引结构 + facet 索引 + 运行余量 原始数据 = 文档数 × 平均文档字节数 索引结构 ≈ 原始数据 × 1.0 ~ 1.5(字段多则偏高) facet 索引 ≈ facet 字段数 × 文档数 × 基数系数(低基数 0.1,高基数 1.0) 运行余量 = 前三项之和 × 0.3(导入峰值 + 查询缓冲)

举个具体例子。300 万条商品数据,平均每条 500 字节,含 5 个 facet 字段(类目、品牌、地区三个低基数,价格区间、库存状态两个中基数):

  • 原始数据 ≈ 1.5GB
  • 索引结构 ≈ 1.8GB
  • facet 索引 ≈ 300万 × (3×0.1 + 2×0.5) 字节 ≈ 约 0.5GB
  • 合计约 3.8GB,加 30% 余量 ≈ 5GB

配 8GB 内存的机器就够了。我实际导进去之后看 RSS,稳定在 4.6GB 左右,估算基本准。

反过来说,如果你有 5000 万条日志,每条 2KB,光原始数据就 100GB,索引结构再加 150GB,这台机器你根本买不起。这就是为什么日志场景我会推荐 Quickwit 那种落对象存储的方案。

5.2 查询侧:三个参数的取舍最影响延迟

搜索接口的几个参数对延迟影响最大,我的经验排序是prefix>facet_by>query_by字段数。

prefix: true在搜索框自动补全场景是必须的,但它会让查询从"精确匹配倒排链"变成"前缀扫描",字段基数大时开销明显。如果只是点搜索按钮才发起请求,可以关掉,延迟能降 20%-40%。

facet_by每次都会遍历对应字段的完整倒排结构来计算计数值,字段基数越高越慢。我建议限制max_facet_values,比如设成 20,避免它去算全量分布。当时我没设这个值,一个高基数标签字段让单次查询多了 80ms。

query_by里字段越多,需要扫描的倒排链越多。我一般把参与搜索的字段控制在三到四个,其余字段设index: false。多字段加权虽然能提升召回,但要付性能账。

另外,per_page最大 250,深分页(比如翻到第 100 页)需要靠游标方式或者业务侧降级。别做真实的深分页,这一点和 ES 的from + size限制是一个道理。

5.3 写入侧:批大小和并发的最佳组合

写入侧的调优空间没查询侧大,但有两个点值得说。

批大小我测出来的甜点区是单批 2000 到 3000 条,配合 6 路并发。这个组合下导入 100 万条文档大约耗时 90 秒,换算下来每秒一万多条。批太小(100 条)会因为请求开销导致吞吐掉到三分之一,批太大(10000 条)则容易触发单请求处理超时。

第二个点是导入期间不要跑后台重建任务。我遇到过一次:一边在导入增量数据,一边重建整个 collection 做 schema 变更,两边抢写锁,导入速度掉到平时的十分之一,重建也慢了一倍。全量重建建议放在低峰期,或者用别名切换的方式——先建一个新 collection 导完数据,再用 alias 指过去,切换瞬间完成。

5.4 一份可以照抄的压测脚本

验证"到底快了多少"必须自己压,不能信任何人的数字,包括我上面写的。我用的是 Python 并发压测,核心逻辑这样:

import time, random, statistics from concurrent.futures import ThreadPoolExecutor import requests API_KEY = "YourRandomApiKey" URL = "http://localhost:8108/collections/articles/documents/search" HEADERS = {"X-TYPESENSE-API-KEY": API_KEY} QUERIES = ["搜索引擎", "性能优化", "向量检索", "中文分词", "索引重建"] def one_request(q): t0 = time.perf_counter() r = requests.get(URL, headers=HEADERS, params={ "q": q, "query_by": "title,content", "prefix": "true", "per_page": 10 }, timeout=5) r.raise_for_status() return (time.perf_counter() - t0) * 1000 def run(concurrency, total): latencies = [] def worker(n): local = [] for _ in range(n): local.append(one_request(random.choice(QUERIES))) return local start = time.perf_counter() with ThreadPoolExecutor(max_workers=concurrency) as ex: for part in ex.map(worker, [total // concurrency] * concurrency): latencies.extend(part) wall = time.perf_counter() - start latencies.sort() p = lambda x: latencies[int(len(latencies) * x)] return { "qps": round(total / wall, 1), "p50": round(p(0.50), 1), "p95": round(p(0.95), 1), "p99": round(p(0.99), 1), } for c in (10, 50, 100): print(c, run(c, 2000))

压测前必须做三件事,否则数据没意义:预热(先跑几百次让缓存热起来)、关掉其他干扰进程、保证查询串有真实命中(用返回结果为 0 的查询压测等于压了个空)。我第一版跑出来的数字漂亮得不真实,后来发现是我用的查询词在数据里根本不存在,走的是"零结果快速路径"。

关于"5 倍"的口径,我的建议是明确三件事:在多少 QPS 下对比、比的是 p50 还是 p99、查询复杂度是否一致。我当时的口径是并发 50、对比 p99、单字段前缀加过滤,结果是 120ms 对 780ms,约 6.5 倍。换成复杂聚合查询,这个比值立刻会缩到 1 倍以内甚至反转。

6. 迁移路上真正会绊住你的那几个坑

跑通和压测都不难,难的是把线上的东西搬过去还能稳住。这一节记录几个我实际撞到的问题,以及排查过程。

6.1 查询语法映射:DSL 转 REST 参数的血泪史

ES 的查询 DSL 是嵌套 JSON,Typesense 是扁平参数,这个转换不是一对一。我整理的对照关系如下:

ES 写法Typesense 写法需要留意的差异
match: {title: "关键词"}q=关键词&query_by=title多字段合并成一个 q
term: {status: "on"}filter_by=status:=on字符串值要加反引号包裹
range: {price: {gte: 10}}filter_by=price:>=10语法更紧凑
bool.must[]filter_by=A && B用 && 连接
bool.should[]多个 filter_by 用||连接语义不完全等价
sort: [{price: "asc"}]sort_by=price:asc字段必须sort: true
aggsfacet_by=category只支持计数,无嵌套聚合

最容易出错的是字符串过滤的引号规则。filter_by=status:=on里如果值是字符串且含特殊字符,必须写成status:=`on`。我因为漏了反引号,导致过滤条件被当成字段名解析,接口直接返回 400,错误信息也不够明确,找了好一会儿。

还有布尔连接的优先级。ES 的 must/should/must_not 有明确的布尔语义,Typesense 用&&||拼接时,复杂表达式建议加括号,否则会因为解析顺序导致结果不符预期。我做过一个"类目是 A 且(品牌是 B 或品牌是 C)"的查询,一开始没加括号,返回的结果把类目条件也带进 or 里了。

6.2 聚合能力的缺失,怎么绕

Typesense 的 facet 只能做计数,没有 avg、sum、percentile、嵌套聚合。如果你的业务依赖这些,有两个绕法:

一是把聚合放到业务库去做。搜索只负责"筛出符合条件的 ID 列表",拿到 ID 之后回 MySQL 或者数仓做统计。我现在的做法是搜索结果前 500 条拿 ID,然后按 ID 去数据库聚合。这个方案在"结果集不大"的场景完全够用。

二是接受降级。比如从"精确的销量分布统计"降级成"分页浏览 + 前端本地聚合"。这需要和产品侧对齐预期,但很多时候业务方对聚合精度的要求没想象中高。

如果这两条都走不通,那这个引擎就不适合你,别硬上。

6.3 三个真实故障的排查链路

故障一:导入跑了一半天,内存报警。排查顺序是:先看导入脚本的批大小,发现我设成了 20000;再看有没有同时跑别的任务,发现索引重建也在跑。两个任务同时占用内存,加上文档本身就比估算的大(有个字段存了完整 HTML)。改法是把批降到 2000,重建任务错峰,HTML 字段改成只存经过清洗的纯文本。改完内存曲线立刻平稳。

故障二:搜索突然变慢,p99 从 130ms 涨到 900ms。这一步排查最关键的是先看是不是数据量变了。我拉了一下 collection 的文档数,发现比前一天多了 40%,是上游同步任务重复写入导致的。因为用的是upsert,重复写入不会报错,只是覆盖,但重复的写入请求把 CPU 打满了。查到根因后,在同步侧加了去重和限流。

故障三:某个查询返回空,但数据明明存在。这个最隐蔽。排查路径是:先用最简查询确认文档在不在 → 在;再逐步加参数,发现是filter_by里的时间戳字段有问题。原来写入时存的是秒级时间戳,但查询传的是毫秒,差了一千倍。时间戳单位一定要在建 schema 时就定死,并写进文档。

6.4 灰度切换的顺序,我建议这样排

别搞一刀切。我的顺序是:先双写(业务库变更同时打到 ES 和新引擎),新引擎只承接内部流量,跑两周对比两边结果一致性;然后放 5% 真实流量,重点看延迟和零结果率;稳定后逐步放到 50%、100%。全量之后 ES 集群别急着下线,保留只读状态再观察一个月,出问题可以秒回滚。

双写期间最需要注意的是写入顺序不能颠倒。如果先写新引擎再写业务库,业务库失败会导致两边不一致。稳妥做法是先写业务库,成功后异步写两个搜索引擎,失败进重试队列。

7. 最后说几句掏心窝的话

从 ES 换到内存型搜索引擎这件事,我的整体感受是:它不是一次"升级",更像是一次"重新选型"。你放弃的是分析能力和海量数据的从容,换来的是延迟的确定性和运维的轻松。这笔交易划不划算,完全取决于你的业务到底在搜什么。

我踩过的最大的坑其实不在技术层面,而在心态上——一开始我总想把它调成"和 ES 一样全能",给它加各种过滤、各种排序、各种组合查询,结果延迟越来越差,内存越来越紧。后来想通了,把能力边界收窄到"就做即时搜索",把聚合和复杂统计全部推回业务库,性能立刻就上来了。任何引擎都有自己的舒适区,硬把它往舒适区外面拽,最后都是自己难受。

再分享两个小技巧。第一,include_fieldsexclude_fields用起来,只返回前端真正需要的字段,我因为这个把响应体从平均 8KB 压到 1.2KB,网络传输时间省了一大截,尤其是移动端效果明显。第二,定期用GET /collections/文章名看一眼实际文档数和内存占用,和你的估算对比,一旦偏差超过 30% 就说明有字段在悄悄吃内存,早点查出来比等报警强。

如果后面数据量真的涨到单机装不下,我的思路不是硬扛,而是按业务维度做路由分片——比如按租户 ID 或地区把数据拆到多个 collection,在应用层做路由。这个方案运维复杂度会上去,但比在内存上无止境加钱要现实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询