这是2026年的第13篇文章
( 本文阅读时间:15分钟 )
前言/问题的起点
使用过 Elasticsearch 的同学大概都有类似的体感:集群规模小的时候一切都好,但随着数据量和索引数量的增长,集群会变得越来越“脆弱”。
这种脆弱主要来自两方面:
第一个是长尾查询。ES 的查询可以粗略分为两类:短查询和中长尾查询。
短查询通常只涉及少量文档的匹配和返回,耗时在个位数毫秒,这类查询在任何引擎上都不会有太大问题。真正棘手的是中长尾查询,它们往往涉及大量文档的文档扫描、聚合或排序。
举几个典型场景:一个日志分析平台需要对过去 24 小时的日志按时间窗口聚合,涉及上百万条文档;一个电商搜索需要在几百万个商品中按多个字段排序取Top N;一个题库软件需要在数百万道长文本题目中去找到和用户题目最相关相似的题目,并且按照相关性,或分类做匹配;一个监控系统需要对高基数字段做terms聚合统计。
这些查询单次可能只需要几十到上百毫秒,看起来不算慢,但问题在于它们会长时间占用查询线程。ES 的查询线程池大小是有限的,机器总资源也是有限的,当这类查询密集出现时,线程池很快就会被占满。
一旦线程池饱和,后续所有查询,包括那些本来只需要几毫秒的短查询,都会排队等待,整个集群的响应时间急剧恶化。而且,这种恶化往往是突发的、难以预测的,因为它取决于某个时间窗口内中长尾查询的并发量。
第二个是GC抖动。JVM的垃圾回收是不可预测的。
在查询密集的场景下,大量的中间对象(迭代器、打分器、收集器等)被频繁创建和销毁,给 GC 带来很大压力。一次Young GC可能让查询延迟增加几毫秒,而一次Full GC则可能让本来稳定在个位数毫秒的查询突然飙到几百毫秒甚至秒级。
我们在生产环境中观察到,即使集群负载不高,也会周期性地出现延迟毛刺,排查下来几乎都和 GC 有关。对于延迟敏感的在线业务来说,这种延迟的不确定性是很难接受的,因为你没办法向业务方承诺一个稳定的 SLA,GC 什么时候来、停顿多久,不完全在你的控制范围内。
这两个问题还会互相放大:GC 停顿会让正在执行的查询变慢,变慢的查询又会更长时间地占用线程池,进一步加剧排队。
面对这些问题,常规的做法是优化 JVM 参数、调整 GC 策略、限制查询并发、拆分集群。这些手段我们都尝试过,确实能在一定程度上缓解症状,但本质上是在一个有天花板的空间里做优化。
GC 的问题根源在 JVM,只要还在 JVM 上跑,就无法彻底消除;而长尾查询的性能瓶颈,很大程度上来自 Lucene 查询引擎本身的实现方式:逐条处理文档的执行模型在大数据量下效率不高,但这是 Lucene 架构层面的设计,不是调几个参数就能改变的。
这让我们开始思考一个更根本的问题:能不能绕过 JVM,直接在 native 层做查询?
阿里智能引擎团队有多年的 C++ 搜索引擎开发经验,对 Lucene 的索引格式和查询流程也有深入的理解。经过评估,我们认为用 C++ 重写 ES 的查询内核是可行的——不是替换整个 Elasticsearch,而是把最核心、最耗性能的查询执行路径用 C++ 重新实现。这样既能从源头上消除 GC 的影响,又能利用 C++ 在计算密集型场景下的性能优势。
elasticpp 就是在这个背景下启动的。
01
怎么让用户无感知
让用户无感知是我们面临的第一个问题。
比起技术方案,我们首先要想清楚是产品形态。我们希望用户在不改变任何东西的前提下就能享受到加速,也就是用户可以在不改查询 DSL,不迁移数据,甚至不需要知道 elasticpp 的存在情况下,就能享受到elasticpp带来的性能和稳定性的提升。
这个目标直接决定了技术方案:elasticpp 做成 ES 的插件,通过 JNI 调用 C++ 动态库,嵌入到现有的 ES 进程中。查询触发时,Java 层将 DSL 转发到 C++ 层执行。对于尚未支持的查询类型,通过 fallback 机制自动回退到原生 ES 查询路径,保证结果的正确性。
整体架构如下:
在 C++ 侧,我们完整实现了 Lucene 索引格式的读取能力,覆盖了多个版本的 Codec(Lucene90、Lucene99、Lucene101 及 ES 自定义编码),支持 bool、term、range、match、wildcard、nested 等主流查询以及 terms、date_histogram、composite、cardinality、percentiles 等常用聚合。
02
性能从哪里来
仅仅把 Java 换成 C++ 就够了吗?答案肯定是不够的。
单纯的语言替换能消除 GC 的影响,但对中长尾查询的性能提升有限,真正的收益来自对查询执行路径的重新设计。
中长尾查询往往涉及几十万甚至上百万文档,瓶颈集中在三个地方:文档迭代和收集的函数调用开销、排序比较时的随机内存访问延迟、索引数据的解码和解压开销。因此我们针对性地做了三个优化。
批处理:原生 Lucene 逐条处理文档,每个文档都要经历完整的迭代→打分→收集→比较调用链。我们改为批量处理模式,将函数调用次数降低了数量级,并通过编译期模板特化消除热路径上因c++缺少JIT功能而带来的虚函数调用。
预取:排序时需要读取文档的 DocValue,这些数据分散在不同内存位置,逐条读取缓存命中率很低。我们在比较之前先把一批文档的 DocValue 批量加载到连续内存中,让数据布局对 CPU 缓存更友好。
零拷贝与解压缓存:将原本分开的解码和处理步骤合并为单次执行,减少数据拷贝。对频繁访问的压缩数据块实现解压缓存,避免重复解压。
这些优化单独来看每个都只是节省了一点点开销,但叠加在数十万次的文档处理上,效果非常明显。
03
一个印象深刻的坑
批处理改造的过程并非一帆风顺。其中有一个问题让我们排查了很长时间,值得单独拿出来说。
将文档收集从逐条改为批处理后,我们需要对一批文档统一进行打分。逻辑看起来很直观:批量迭代,批量打分,批量收集。上线测试时,大部分查询的结果都是正确的,但我们发现有一些查询的排序结果和 ES 原生引擎对不上。
这个问题很诡异——不是所有查询都会出错,只有特定的查询组合才会触发。我们一开始怀疑是排序逻辑的问题,但反复检查后发现排序本身没有错,错的是输入给排序的分数。
经过进一步排查,我们发现问题出在 Lucene 的查询体系中一个容易被忽视的细节:有一些查询类型会在初始打分之后对分数进行二次改写。比如某些查询会把所有文档的分数替换为一个常量,另一些会对分数乘以一个权重系数。
在逐条处理模式下,这些改写是自然发生的:每个文档打完分后立即被改写,然后被收集,整个过程是串行的;但在批处理模式下,一批文档的分数是先统一计算好的,如果后续的改写逻辑没有正确地作用到整个批次上,就会出现分数不一致。
定位到这个方向后,具体的排查过程仍然很痛苦。因为问题只在特定查询组合下触发,我们没办法通过简单的单元测试复现。
最终的做法是把线上的数据和索引捞出来,在 C++ 侧用 GDB 打断点,在 Java 侧用 IntelliJ IDEA 打断点,两边一步步对比执行流程,才最终确认了问题的根因并修复。
这个经历给我们一个很深的教训:批处理不是简单地把“处理一个”改成“处理一批”。原有的逐条处理逻辑中,可能隐含着各种顺序依赖和状态改写,这些在批量化之后都需要被重新审视。
性能优化永远不能以正确性为代价。
04
优化效果
在上述优化的加持下,我们用 ES Rally 官方基准测试框架,在 http_logs、big5、pmc 等多个公开数据集上进行了测试。在聚合和排序类的长尾查询中,elasticpp 相比原生 ES 有明显的性能提升:
在线上真实业务场景中,针对数十毫秒级别的长文本查询和日志类查询,同样观察到了明显的改善:
目前,elasticpp 已覆盖线上数十 TB 的索引规模,在生产环境中稳定运行。
05
未来展望
性能优化只是个开始。随着业务的发展,我们看到了更多可以在 elasticpp 上扩展的方向。
一是存储计算分离。当索引量持续增长,单机磁盘容量会成为瓶颈。我们计划在 elasticpp 上支持远程存储的索引读取,让计算和存储可以独立扩展。
二是异步查询。在混部调度和弹性伸缩的场景下,查询的执行需要更灵活的资源管理。异步化可以让 elasticpp 更好地适配这些场景。