
做搜索这个行当的多多少少都和 Elasticsearch 打过交道。我这边有个内部项目早期索引用户行为数据、做站内标签检索用的就是 ES集群规模不算大三台 32C128G 的机器数据量从千万级慢慢涨到一亿多各种调优手段都试过最后还是在高峰期频繁出现 CPU 毛刺和长尾延迟。有一次线上大促QPS 刚过 1800p99 直接飙到 150ms监控告警响了一晚上那个滋味相信很多运维和开发都懂。后来我们做了一个大胆的决定自己写一个专门针对“精确匹配 过滤 TopN 排序”场景的迷你搜索引擎不再让 ES 背着那些我们根本用不上的全家桶功能。从原型到上线前后大概花了两周时间最终线上稳定承载的 QPS 是原来 ES 的 4 到 5 倍p99 延迟降到 20ms 以内内存占用还降了一大截。不少同行看到性能数据第一反应是不信我完全可以理解所以这篇文章不吹不黑把整个思路、数据结构、构建流程和实战中的坑都捋一遍想自己复现的可以照着试至少能少走我们踩过的弯路。1. 内容整体设计与思路拆解1.1 为什么 ES 扛不住我们的场景先说需求我们的业务 95% 是站内搜索和标签筛选本质上就是“词条精确匹配 几个条件过滤 按某个字段排序取 Top 50”。这类查询在 ES 里写起来很简单一个 bool query 套 filter 上下文就搞定了而且 filter 的结果还能走 ES 的 query cache单看查询效率并不差。但问题在于ES 的查询链路过长从 HTTP 层进来要解析 JSON生成 QueryBuilder经过 query 重写、rewrite再进入 Lucene 的 IndexSearcher倒排链求交之后还有打分计算、 collector 归集、聚合辅助结构最后再走一层 response 序列化。这一整套流程里真正对我们这个业务有意义的只有倒排链求交和排序两小步剩下的大半 CPU 都被浪费掉了。更难受的是 JVM 层的开销。ES 在堆内维护了大量数据结构堆外还有 page cache、段缓存数据量一大GC 停顿就肉眼可见地变频繁。我们用过 forcemerge 合并段用过冷热分离用过调整 refresh interval但每到凌晨批量写入或者段合并高峰期查询延迟就有明显毛刺。说白了ES 是个通用搜索引擎它支持全文打分、聚合分析、向量检索、复杂 DSL这些能力都是好东西但对于我们这种又窄又专的场景这些能力就是沉重的包袱。1.2 换 Meilisearch 或者 Typesense 不行吗说实话我们也认真评估过 Meilisearch、Typesense 这些轻量搜索引擎。它们确实在简单搜索场景下表现得很好配置简单、内存占用比 ES 小得多。但深入测下来我们遇到几个绕不开的问题第一业务上有大量的动态过滤条件比如“品牌 价格区间 上架时间 状态”这些过滤规则经常变化需要灵活的查询接口Meilisearch 的 filterable attributes 虽然能做但表达复杂逻辑时还是很别扭。第二我们有一些自定义的排序权重公式比如“热度 * 0.6 新品系数 * 0.3 转化率 * 0.1”这些逻辑放进开源引擎里往往需要改造源码或者绕很多弯子。第三我们的数据管道是 Kafka 驱动需要深度控制异步写入、索引切换、灰度发布这些运维细节外部引擎需要适配我们的节奏而不是我们去适配引擎的节奏。于是我们决定自己做。团队里没人指望写一个能和 ES 比拼通用能力的搜索引擎但每个人都清楚如果只做当前这些查询模式我们可以把链路砍到最短把每一步的数据结构用到最极端。目标就是三个搜得准、扛得住、省资源。1.3 设计原则窄接口、大并发、低延迟mira 引擎的设计原则总结起来就三句话索引全量常驻内存不走磁盘随机读所有核心结构都是紧凑的 C 数组或者位集。查询路径上只留下“必须”的步骤解析简单查询、查词典、倒排链求交/求并、过滤条件下推、TopN 排序。写入不追求秒级实时采用异步批量合并通过双缓冲切换索引版本。你可能已经看出来了这三条正好和 ES 的架构形成鲜明对比。ES 为了保证实时可见性和通用性把写入设计的极其复杂而我们把实时性放宽到百毫秒级别换来了吞吐和稳定性的巨大提升。这种取舍只适合“搜索实时性要求不苛刻、但对延迟和成本敏感”的业务如果你的产品需要用户搜完立刻能看到刚写入的数据那这个方案不一定合适。2. 核心细节解析与实操要点2.1 底层数据结构怎么选词典、倒排链、位集自研搜索引擎最底层的三块基石是词典、倒排表和辅助过滤结构。mira 的词典用的是双数组 TrieDouble-Array Trie这个结构本质上是一种高效的确定性有限状态自动机查询耗时只和词长度有关和数据总量无关。一亿文档去重后的词条可能有几千万但双数组 Trie 一次完整查找通常只需要十几次内存访问比 hash map 的缓存友好度更高。倒排表我们分为两种存储形态短倒排链直接用紧凑的 uint32 数组长倒排链用跳表分段存储。为什么跳表会有用因为求交的时候最怕遇到两条都很长的链如果老老实实双指针一个个比复杂度是 O(nm)。用跳表后每次可以大步跳过一段不可能匹配的区间平均复杂度能降不少。跳表在这里不是用来做“单点查找”的而是用来做“区间跳跃”的这是很多文章没讲清楚的地方。位集则是为过滤服务的。比如“status在线”、“price100”这种条件我们在索引构建时就为每个字段值生成一个 bit 数组1 代表该文档满足条件0 代表不满足。查询时提前用位集对倒排链做裁剪把不满足条件的文档一次性过滤掉后续的排序压力就小很多。位集的核心操作是 bitwise AND这一点 SIMD 指令集非常擅长我们用 C 扩展实现后几百万个 bit 做 AND 只要几微秒。2.2 索引构建流程并行分片 多路归并索引构建分全量构建和增量更新。全量构建用于启动时的冷启动或者数据重建我们一般从 HDFS 或者 Kafka replay 拉数据。单机跑 1.2 亿文档如果一条条插入速度极慢且内存碎片严重。我们的做法是并行分片构建把文档按照 term hash 映射到 64 个分片每个分片独立构建局部词典和倒排链然后用一个多路归并操作合并成全局词典和全局倒排链。有几个优化细节很关键。分片数取 2 的幂这样取 hash 模可以变成位运算省一次整数除法。归并的时候因为每个分片内部的倒排链已经按照 doc_id 有序排列所以合并两个有序链只需要类似 merge sort 的线性扫描不需要重新全局排序。整个构建过程里最大的瓶颈其实是磁盘 IO我们尽量让中间结果顺序写减少随机写。增量更新则是消费 Kafka 上的数据变更事件。主索引一旦构建完成就变成只读的新数据先进一个小的增量索引比如 10 万文档一个小分区。查询的时候同时查主索引和增量索引最后合并结果去重。当增量索引达到阈值就触发一次合并。合并时我们使用双缓冲机制合并期间查询继续走旧索引等新索引完全 ready 后才做指针切换避免线上业务出现停顿。2.3 查询链路的每一步都做了什么优化一条查询进来大致会走这么几步解析极简 JSON 查询描述。因为字段模式和操作符都是固定的我们不做通用的 query DSL 解析只用了一个非常轻量的解析器把must、filter、sort三类信息提取出来。查词典。传入词条“苹果”在双数组 Trie 中找到对应的 term_id。读取倒排链。通过 term_id 定位到 PostingList。倒排链求交/求并。根据是多个 must 还是 should 决定操作。我们内部对“两个长链求交”做了专门优化用跳表跳跃式推进。位集过滤。加载过滤条件的 bitset对候选 doc_id 逐位做 AND。这一步把大量不满足业务规则的文档剔除。排序取 TopN。对幸存候选集只维护一个大小为 KK 通常为 50 或 100的堆绝不全局排序。每一步优化本身都不算高深但组合在一起效果极其惊人。最典型的收益来自位集过滤前置假设“苹果”这个词命中了 120 万文档其中满足“价格大于 100”的只有 30 万那我们在求交之前就把范围缩小到 30 万后面无论排序还是合并都轻松得多。这个下推思想在数据库领域很常见但在搜索系统里很多人会忽略。2.4 排序逻辑和自定义权重怎么做因为没有 ES 那种完整的打分机制我们走的完全是业务自定义排序。索引构建时对需要可排序的字段单独存一个排序列比如“热度”、“上架时间”都是紧凑的整型数组通过 doc_id 直接索引取值。查询时可以指定按照某个字段升序或降序也可以传一个权重表达式在排序阶段计算每个候选文档的业务分。为了让排序更灵活我们还设计了一个简单的可插拔打分函数注册机制。团队内部如果需要上线新的排序策略不修改核心代码只需要实现一个函数输入 doc_id输出浮点分数然后在配置里指定即可。这种设计很像插件系统对于业务迭代特别友好。但需要注意的是如果打分函数要做复杂计算排序阶段会成为瓶颈所以打分函数必须尽量轻量或者对结果做缓存。2.5 异步写入的取舍百毫秒延迟换吞吐这里单独说说写入因为这是很多自研搜索最容易踩坑的地方。ES 的默认行为是写入后 1 秒左右 refresh 才能被搜索到而我们的 mira 做的是写入后最多 100ms 内可见。听起来好像是实时性变差了但好处是写入吞吐大大提升。具体实现是客户端写入请求先到写入服务写入服务把一个批次的数据攒到内存队列里然后顺序追加到 WAL预写日志再批量更新增量索引。WAL 只做顺序写速度非常快即使进程崩溃重启后也能通过 replay WAL 恢复数据。我们当时考虑过用 Kafka 直接当 WAL后来觉得还是自己实现一个更轻量免得引入额外的运维依赖。这里有一个取舍必须说清楚如果你需要“写入即可查”比如电商后台商品一上架立刻要在前台搜到那你不能接受 100ms 的延迟。但对于我们的业务100ms 内可见完全没问题系统设计上就放宽了这个条件换来了写入链路的大幅简化。3. 实操过程与核心环节实现3.1 先搭一个最小原型验证可行性我在很多场合都强调过一个观点不要一上来就写引擎先搭最小原型。我们当时用 Python 写了一个极其简陋的倒排索引几千条文档测试基于字典存储 term 到 doc_id 列表的映射查询时取交集过滤时逐条遍历。这个原型跑起来慢得离谱但好处是验证了两个核心假设第一我们 95% 的查询都能用这种简单方式表达第二在数据量小的时候响应时间确实能到微秒级。这给了团队很大的信心才决定继续往下做 Cython 版本。原型阶段还需要做数据统计分析。我们把线上 1000 条真实查询日志拉出来分析词条数量分布、过滤条件类型、排序字段权重甚至统计每类查询命中的文档数量这些数据直接决定了后续的数据结构选型。比如我们发现“两个词条求交 一个过滤 按热度降序”的组合占到了 70% 以上于是我们把优化重心放在这条路径上做了很多针对性优化而不是平均用力。3.2 用 Cython 重写核心热路径Python 原型只适合验证逻辑真正要扛并发必须用 C/C 级别的实现。我们选了 Cython因为它可以让你渐进式优化先保持调用方便再把性能敏感的内层循环替换成 C 类型。我们的倒排链求交模块最初用 Python list 存储后来换成 Cython 的uint32_t*指针循环里直接用内存遍历性能直接提升了 20 倍以上。还有一个经验是一次只替换一个模块。比如先替换 PostingList 数据结构和求交函数编译后跑一遍算法正确性测试和性能回归测试。确认无问题后再替换词典查询模块。不要试图一次性把整个引擎都翻译成 Cython那样调试成本会爆炸。Cython 开发环境下有个麻烦就是出问题不容易定位尤其遇到段错误基本只能靠 gdb。所以我们给核心模块写了非常详细的边界条件测试确保每个基础函数都稳定后才往上叠加逻辑。3.3 并行构建索引的调参经验全量构建索引这一步如果你需要处理上亿文档并行策略就是性命攸关的事。我们最初单机单线程构建1.2 亿文档耗时 50 多分钟这个速度在需要频繁重建索引的场景下完全不可接受。后来改为 64 分片并行构建每个分片 8 个线程跑再归并总时长压到 9 分钟左右。这里有两个关键参数分片数一般取 CPU 核心数的 2 到 4 倍。分片太少了CPU 利用率不够分片太多中间文件数量暴增归并时的排序和多路合并反而成为瓶颈。归并线程数取决于内存大小。每个归并任务都会申请 buffer内存不够会被操作系统 swap 到磁盘反而更慢。另一个容易忽略的点是磁盘 IO 模型。尽量让每个分片写独立的文件归并完成后再合并成一个大文件而不是多个线程共享一个文件句柄乱序写。乱序写会导致大量磁盘寻道顺序写才能接近 NVMe 盘的真实吞吐。3.4 和 ES 的对比压测到底怎么测才可信对比压测是最容易“做出假数据”的环节大家看任何搜索引擎性能评测都要多留一个心眼。我们的做法是抓取线上真实的 1000 条查询日志回放给两个系统保证查询模式和参数完全一致。测试机器是同一批物理机并用相同的并发模型压测记录 QPS、p50、p95、p99 以及内存占用。最终数据如下指标Elasticsearchmira稳定 QPS18007800p99 延迟稳定负载145ms22ms1 亿文档内存占用约 62GB约 18GB平均每条查询 CPU 消耗18%5%需要说明的是我们的查询集几乎全部是“精确匹配 过滤 排序”没有包含全文模糊检索。ES 的优势场景是全文打分和复杂聚合这两类查询我们没有测因为业务用不到。所以这个对比不代表 ES 不行只代表在“窄场景”下自研方案可以做到极致的性能和资源效率。如果你拿全文搜索、同义词扩展、拼音纠错这些场景来压那我们是绝对比不过 ES 的。3.5 异步写入模块容易踩的坑写入模块是看似简单但最容易线上出问题的部分。我们早期把 WAL 设计成“每个批次一个文件”结果因为频繁创建和删除文件IO 队列抖动非常厉害。后来改成固定大小环形文件比如 1GB 一个文件写满自动切换保留最近 20 个文件彻底解决了这个问题。WAL 刷盘策略也需要根据场景调节。如果每次都 fsync吞吐肯定上不去。我们采用“1000 条或者 10ms 内至少刷一次”的混合策略实测断电丢数据的窗口最多 10ms 左右对业务完全可接受。这里一定要想清楚你的“数据安全等级”如果一条都不能丢那必须每次请求都 fsync吞吐自然会下降不存在既要又要的方案。另一个细节是增量索引的合并时机。如果不加控制频繁合并会导致 CPU 峰值。我们设置了一个阈值增量索引超过 20 万条才允许合并同时最小合并间隔不低于 5 分钟这样把合并操作变成低频次、大批量对查询的影响降到最低。4. 常见问题与排查技巧实录4.1 大候选集排序慢问题不一定在排序算法我们线上出过这么一个问题某个查询命中 50 万文档过滤后还剩 20 万取 Top 50耗时 80ms。直觉肯定认为 20 万里取 50 个用堆排序就够了为什么还这么慢后来一步步定位发现慢的不是排序本身而是“把 20 万条 doc_id 从倒排链复制出来”的过程。候选集过大时光从内存数组里面筛一遍再复制也是一次不小的开销。解决方法是把过滤条件尽量下推到倒排链读取阶段。具体做法是把位集过滤变成倒排链遍历时的内联判断遍历每一个 doc_id 时先检查位集里对应 bit 是否满足条件满足才加入候选结果。这样就避免了先把所有 doc_id 复制出来再逐条过滤的两段式做法。这个改动看似只是调整了代码组织顺序性能却有翻倍提升。4.2 合并索引时查询抖动增量索引合并到主索引的过程中如果处理不好查询延迟会出现周期性尖刺。我们的旧实现是在合并期间加锁查询必须等待合并完成在百万级文档合并时几十毫秒的停顿足以让高并发场景的 p99 飙红。后来参考 Java 并发里的双缓冲思路持有两个索引版本合并时新数据先写入“影子索引”等合并完成后通过原子指针切换让查询无感地走到新版本。代价是合并期间内存占用会短暂增加一个增量索引的大小但对我们的数据规模完全可控。这套机制上线后查询延迟曲线变得非常平滑。4.3 词典内存占用过高双数组 Trie 在查询效率上很优秀但内存占用是个短板。我们一亿文档去重后 term 数约 3000 万双数组 Trie 初始构筑需要很大的数组空间直接放内存容易爆。我们后来做了个妥协把高频词比如前 10 万个词用 hash map 存倒排链指针其余低频词用 Trie 存。因为日常查询绝大多数命中高频词Trie 在这种混合模式下内存占用降低了 40%而查询性能几乎没有下降。类似的二八分层思路在自研搜索引擎里非常实用。4.4 问题排查速查表现象可能原因排查方法解决建议查询耗时普遍高于 50ms过滤条件没有 bitset 化导致大量逐条判断开启慢查询日志看耗时分布是否都在过滤环节为所有常用过滤字段建 bitset 索引内存持续上涨增量索引未及时合并或 WAL 文件未清理检查索引版本数量观察 WAL 每日增长量设置合理合并阈值与 WAL 保留策略高并发下 CPU 毛刺超大候选集触发全量排序或复制抓 CPU profile看热点是否在排序函数改为 TopN 堆排序 倒排链内联过滤写入延迟忽高忽低WAL 刷盘频率和合并策略冲突查看 IO 队列长度和写入线程 blocked 状态用批量刷盘 固定时间间隔双触发策略GC 或内存碎片导致崩溃大量动态分配小对象用内存统计工具查看分配次数改用内存池或预分配数组避免频繁 realloc4.5 我的几条独家实操心得最后分享几个很难从文档里看到的经验。第一位集的 bit 长度设计不要盲目等于文档总数。如果文档 id 是稀疏的比如删除了大量数据导致最大 id 很大你按最大 id 去申请 bitset 会浪费海量内存。更好的做法是定期做一次 id 重排让 doc_id 尽量连续然后把 bitset 长度对齐到最大 id 所在的区间。第二Cython 调试要留后路。我们在纯 Python 层保留了一套功能等价的慢速实现专门用来对比验证 Cython 版本的正确性。每改一次核心逻辑先跑 Python 版本再跑 Cython 版本比对输出是否一致。这个习惯救了我们很多次有一次 Cython 指针越界就是通过输出不一致才定位到的。第三多词查询时如果全部是高频词求交链路照样可能成为瓶颈。我们专门给高频词做了倒排链缓存把热点词对应的倒排链预加载成位集查询时直接用位集求交省掉跳表遍历的开销。这个优化在“双十一”这类大促场景里效果显著能用很小的内存换很高的并发稳定性。写在最后其实做这个自研搜索引擎的初衷很简单在特定场景下通用引擎过于厚重而我们需要的只是一个刚好够用、但性能极其能打的窄口引擎。整个项目从零开始我们没有用什么神秘的高新算法就是老老实实把倒排链、跳表、位集这些经典结构组合起来每一步都问自己“这一步对于我的查询模式是不是必要的”然后把不必要的全部砍掉。我个人在实际迭代中最大的感受是性能优化不是靠某个单独的数据结构一锤定音而是靠整条链路的精细化打磨。从词典查到倒排链求交从位集过滤到 TopN 排序任何一环出现大候选集复制或者全排序都会把前面省下来的性能瞬间吃回去。如果你也在经历 ES 的性能瓶颈我建议先不要急着去调整 ES 的几百个配置项而是好好分析自己的查询模式看看是不是大部分精力都被浪费在了用不到的功能特性上。也许一套轻量、定制的方案才是你真正需要的答案。