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

资讯详情

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

从 ES 迁到 Typesense:一次搜索延迟从 1s 压到 120ms 的完整复盘

从 ES 迁到 Typesense:一次搜索延迟从 1s 压到 120ms 的完整复盘 三年前谁跟我说要把核心搜索从 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查询扇出 520 个主分片 → 每分片 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-keyYourRandomApiKey \ --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?actionupsert \ -H X-TYPESENSE-API-KEY: YourRandomApiKey \ --data-binary articles.ndjsonaction有四个取值行为差别很大选错会丢数据create文档 ID 已存在就报错适合首次全量导入upsert存在则整条覆盖不存在则新建最常用update只更新请求里带的字段其余保留适合增量更新emplace存在则合并字段不存在则创建等价于带部分更新的 upsert返回体是逐行的结果每行一个 JSON包含成功或失败原因。一定要解析这个返回体不能只看 HTTP 状态码。我踩过一次坑批量导入时部分文档因为字段类型不匹配被拒但 HTTP 返回 200我以为全成功了直到业务侧反馈少了三千多条数据才发现。批量大小方面我实测单批 1000 到 5000 条比较合适太小网络开销占比高太大单请求超时且失败重试代价高。导入并发控制在 4 到 8 路太多会因为写锁竞争反而变慢。如果是从 MySQL 同步思路和 canal 那套很像但实现要自己写用 binlog 捕获变更转成 NDJSON按update或emplace打到导入接口。区别在于 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 侧用 jiebaJava 侧用 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_raw和content_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.8GBfacet 索引 ≈ 300万 × (3×0.1 2×0.5) 字节 ≈ 约 0.5GB合计约 3.8GB加 30% 余量 ≈ 5GB配 8GB 内存的机器就够了。我实际导进去之后看 RSS稳定在 4.6GB 左右估算基本准。反过来说如果你有 5000 万条日志每条 2KB光原始数据就 100GB索引结构再加 150GB这台机器你根本买不起。这就是为什么日志场景我会推荐 Quickwit 那种落对象存储的方案。5.2 查询侧三个参数的取舍最影响延迟搜索接口的几个参数对延迟影响最大我的经验排序是prefixfacet_byquery_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, headersHEADERS, params{ q: q, query_by: title,content, prefix: true, per_page: 10 }, timeout5) 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_workersconcurrency) 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 是嵌套 JSONTypesense 是扁平参数这个转换不是一对一。我整理的对照关系如下ES 写法Typesense 写法需要留意的差异match: {title: 关键词}q关键词query_bytitle多字段合并成一个 qterm: {status: on}filter_bystatus:on字符串值要加反引号包裹range: {price: {gte: 10}}filter_byprice:10语法更紧凑bool.must[]filter_byA B用 连接bool.should[]多个 filter_by 用||连接语义不完全等价sort: [{price: asc}]sort_byprice:asc字段必须sort: trueaggsfacet_bycategory只支持计数无嵌套聚合最容易出错的是字符串过滤的引号规则。filter_bystatus: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_fields和exclude_fields用起来只返回前端真正需要的字段我因为这个把响应体从平均 8KB 压到 1.2KB网络传输时间省了一大截尤其是移动端效果明显。第二定期用GET /collections/文章名看一眼实际文档数和内存占用和你的估算对比一旦偏差超过 30% 就说明有字段在悄悄吃内存早点查出来比等报警强。如果后面数据量真的涨到单机装不下我的思路不是硬扛而是按业务维度做路由分片——比如按租户 ID 或地区把数据拆到多个 collection在应用层做路由。这个方案运维复杂度会上去但比在内存上无止境加钱要现实得多。
返回列表