资讯详情

资讯详情

Percolate Query性能优化:从2000ms到500ms的实战拆解

Elasticsearch 的 Percolate Query 是我这几年用得最“冷门”但也最值钱的一个能力别人是拿条件去找文档它是拿文档来找条件。最近我刚把一个实时规则匹配服务做了一轮完整优化percolate 单次调用耗时从 2000ms 级别降到了 500ms 左右没有碰什么黑科技全是结构、拆解、参数上的取舍。如果你正在用 percolate 做规则告警、实时风控、内容订阅或者数据路由或者你只是被“现有接口 2000ms 的响应”折磨过那这篇文章应该能帮你省下不少排查时间。我会把这次优化的背景、原因拆解、实际操作步骤以及踩过的坑完整记录下来所有方法都可以直接抄到自己的场景里。1. 业务背景与问题定界1.1 我们用它做了什么我们的场景是一个实时业务规则引擎系统里维护着几万条动态规则比如“当订单金额超过 5000 且城市为上海时触发一级告警”“当用户风险标签为 R01 且连续登录失败 3 次时拦截请求”。业务方会随时增删改这些规则而每秒钟都有新事件从 Kafka 进来需要判断“这些新事件到底命中了哪些规则”。第一版其实是用普通搜索硬做的把规则存到 Elasticsearch 索引里然后针对每个事件把可能的规则查出来再用内存去逐条 match。这个方案维护成本很高规则一旦复杂一点查询条件和内存逻辑就很难对齐开发效率也很低。后来切到了 Percolate Query思路反过来了先把规则作为“查询文档”注册到 Elasticsearch来一个新事件时把事件当成一份普通文档提交给 percolate 接口Elasticsearch 会告诉我们“这个事件命中了哪些已注册的查询”。这个模型和规则引擎的诉求天然匹配特别是规则数量多、事件维度杂的场景开发侧只要维护规则本身就好了。1.2 “2000 毫秒”是怎么来的上线前后的功能验证非常顺利麻烦是从压测开始的。我们的集群环境是三台 16C32G 的数据节点ES 版本是 7.17规则总量大约 9.6 万条全部放在一个主分片数为 3 的索引里。事件文档一开始直接取了 Kafka 里的原始报文包含几十个字段有的 ext_info 下面还挂着大段 JSON 数组单条事件大小从 2KB 到 30KB 不等。在这种状态下percolate 请求的中位耗时已经到了 1900msP95 超过 2400msP99 一度逼近 3 秒。业务方给这个接口的 SLA 是 800ms所以基本属于不可用状态。更难受的是这还是在 50QPS、并发 10 的压测条件下不是极端流量。我一开始怀疑是集群规格不够直接提了一轮扩容申请结果数据节点加到 6 台之后耗时只降了 10% 左右。这时候才意识到问题大概率不在整机资源而在数据模型和查询结构本身。2. 原理复盘Percolate 到底慢在哪2.1 它是“反着”跑的检索想优化一个东西先得知道它的底层执行逻辑。Percolate 的官方定义很长但说白了就是普通搜索引擎把文档写入索引然后拿查询去匹配percolate 把查询写成文档并特殊索引然后拿新来的文档当“查询条件”反过去匹配已注册的查询。在 Elasticsearch 内部percolate 查询在执行时会把传入的文档在内存中变成一个临时的“迷你索引”用这个临时索引去比对每个候选的注册查询。这一步听起来不复杂但代价在于临时索引的构建精度越高、文档里的字段越多构建成本就越大。还有一个容易忽略的点percolator 类型的字段在存储注册查询时Elasticsearch 会做额外的预处理想办法把 term、range 这类能提前索引的查询转换成倒排结构。正是因为多了这层转换percolate 在某些查询类型上能做到飞快但换到另一些查询类型上就可能退化成一个一个注册规则硬跑。2.2 耗时被哪些因素放大从我们当时的现象反推能把 percolate 拖慢到秒级的因素主要是下面几个。第一是参与匹配的注册查询规模。如果 9.6 万条规则全部在一个分片上无差别参与评估即使每个规则本身的 query 很简单累加起来的 CPU 消耗也非常可观。更麻烦的是每一条事件都要重复评估一遍量一起来直接扛不住。第二是传入文档的复杂程度。percolate 要把文档解析成临时索引字段数量越多、嵌套层级越深、数组越长解析和构建临时索引的开销就越大。我们当时传了完整原始报文进去等于每来一条事件就拿大量无关字段构建一份临时索引等于每来一条事件就在高射炮打蚊子。第三是规则本身用了多少“贵”操作。规则里如果包含 wildcard、regexp、fuzzy、script 这类查询Elasticsearch 很难提前把它们索引成高效的候选结构只能在运行时对候选集合做更暴力的匹配耗时成倍上涨。2.3 实测确认瓶颈在数据“太胖”和规则“太大”为了不靠猜我先用 profile API 对单次慢请求做了拆解。ES 的 profile 输出能告诉我们时间到底花在哪个 query 阶段。当时结果很清晰搜索框架本身耗时只有几十毫秒真正的大头在 percolate 这个 query 内部而 percolate 内部又主要消耗在解析传入文档和遍历候选规则上。再配合慢日志里抓到的实际请求样本基本可以确认两个问题我们传进去的文档太胖规则集合太大太杂。之后的优化就是围绕这两条主线去做的。3. 第一刀收缩规则集规模与参与范围3.1 废弃规则清理先把存量瘦下来我们当时的规则中心后台只允许手工停用规则没有自动清理机制导致索引里躺了大量已经处于 disabled 状态的历史规则。这些规则虽然业务上不生效但只要文档还在索引里percolate 扫描时它们同样会参与计算。所以第一步很粗暴把enabled: false的规则单独筛出来跟业务方确认后归档到冷索引热索引只保留实际启用的规则。顺手把创建时间超过一年、且半年内零命中的规则也做了下架。这一刀执行完规则总量从 9.6 万降到了 6.1 万左右percolate 的 P95 从 2400ms 降到了 1900ms 上下差不多提升了 20%。虽然还没到目标但至少证明方向是对的——先把没用的东西清出去再谈优化。3.2 给规则打标签用外层 filter 减少候选清完存量之后我开始思考另一个问题6 万条规则虽然少了但每次事件进来真的需要把所有规则都比一遍吗答案显然是否定的。我们的业务天然带分类属性比如订单域规则只关心订单字段风控域规则只关心用户行为字段。于是我在规则索引里增加了一个biz字段写入规则时把这条规则归属的业务线写进去然后在查询时用外层 bool 查询加 filter先按biz缩小候选范围。具体写法类似这样GET /rule-center/_search { size: 100, query: { bool: { filter: [ { term: { biz: order } } ], must: [ { percolate: { field: rule_query, document: { order_id: 20240113001, amount: 2399, city: shanghai, category: [ digital, phone ] } } } ] } } }这里 filter 的意义不仅是让结果更精确更关键的是能让 Elasticsearch 在扫描候选规则时优先排除掉不匹配biz的文档真正进入 percolate 匹配阶段的候选集合会大幅缩小。实际压测下来只在最常用的订单场景加了这样一个 filter整体 P95 就从 1900ms 降到了 1300ms 左右。因为订单 biz 下的规则只有 8000 多条其余 5 万多条规则在扫描早期就被拦截了。3.3 必要时按业务拆索引而不是只靠一个索引filter 能解决一部分问题但还没到最理想的状态。因为 filter 是在分片内扫描时逐条判断的如果同一个 shard 里堆积了太多不同 biz 的规则前期过滤仍然要消耗一次磁盘和 CPU。所以后来我把单一的大索引按业务拆成了几个独立索引rule-order、rule-risk、rule-user等等。查询端根据当前事件的业务类型只请求对应的索引。这样一个订单事件过来实际面对的候选规则可能就只有几千条而不是清完存量后的 6 万条。拆索引之后还顺手做了路由设计写入规则时指定routing为业务 ID同一业务的规则尽量落在同一组分片上。查询时也带同样的 routing这样请求只会发送到包含目标规则的分片而不是波浪式地扫全集群。拆完索引的压测结果又降了一截中位耗时到了 850ms 左右但此时离 500ms 还有一段距离主要矛盾已经转移到传入文档本身。4. 第二刀给 percolate 的输入文档“减负”4.1 为什么文档越大越慢percolate 在内存里为传入文档构建临时索引时字段的解析成本是不能忽略的。这个临时索引需要把文档中的每个字段都识别出来并且和规则查询里引用的字段做映射匹配。如果文档里有 text 类型的长文本还会涉及分词如果有数组还要展开成多值如果有嵌套对象还要维护对象之间的关系。我们最初把 Kafka 里的原始报文完整交给 percolate里面大量字段在规则里根本不出现纯属陪跑。这就像你请了一个翻译结果他花了一半时间在翻译一本不相干的杂志。4.2 实践只传规则需要的字段瘦身方案做起来很直接在调用 percolate 之前先把事件里和规则相关的字段投影出来组成一个精简文档。规则可能关心amount、city、category、user_level、risk_tag这几个字段那就只传这几个其他字段一律不传。瘦身后的请求长这样GET /rule-order/_search { size: 100, query: { bool: { filter: [ { term: { biz: order } } ], must: [ { percolate: { field: rule_query, document: { amount: 11888, city: shanghai, category: digital, user_level: 3 } } } ] } } }原始文档从十几个字段直接降到了四五个关键字段。就这么一个改动耗时从 850ms 降到了 600ms 上下收益非常可观。要注意的是不要只在调用侧改还要在规则的 query 侧做约束。我们专门整理了一份“可参与 percolate 的字段字典”新规则上线前做校验禁止在规则里引用字典之外的字段这样前端怎么投影、后端怎么设计临时索引都有了确定性。4.3 大字段怎么处理有人可能会问如果某条规则确实需要包含大段文本做匹配怎么办我的建议是分两层处理。第一层percolate 只做粗筛用那些能快速过滤的精确字段把候选规则缩小到很小范围第二层如果需要匹配的文本内容很重把这类特殊规则单独放到独立索引独立评估避免它们拖累主链路的其他规则。举个例子我们有一条规则需要检查“邮件正文是否包含特定产品词”正文可能是几 KB 的文本。如果这个字段跟着主索引一起走每次事件进来都要做文本分词和匹配代价很高。最后我们是把这几个极少数文本规则拆到一个单独的rule-nlp索引保持低频率、低并发地单独调用主路径完全不受影响。5. 第三刀规则本身和索引参数调优5.1 把“贵”的规则单独摘出来前面提到percolate 对有倒排优化空间的 query 类型最友好比如 term、range、bool 组合。但对 wildcard、regexp、fuzzy、script 这类查询Elasticsearch 难以预处理只能对候选文档进行更暴力的运行时匹配。我们当时做了个统计6 万活跃规则里带 regexp 和 script 的规则占比不到 5%但这 5% 贡献了超过 60% 的平均执行时间。他们被散布在常规规则里每次 percolation 都会拖慢整体速度。我们的处理方式不是粗暴禁止正则而是把这部分规则从主规则索引里剥离出去单独放到一个“重规则索引”在业务上降低它们的匹配频率或者改成异步匹配。比如原来每条事件都实时匹配正则规则调整后改成每 30 秒批量处理一次对实时性要求没那么高的场景完全够用。如果确实没法异步至少别把重规则和轻规则混在一个分片上否则重规则会不断抢占 CPU影响轻规则的处理效率。5.2 分片数、返回量和 refresh 间隔的正确设置分片数不是越多越好但当时 3 个主分片对 6 万条规则来说确实偏少。我根据数据量和查询并发做了调整每个规则索引拆出 6 到 8 个主分片让每个分片上的候选规则数量更均衡查询时可以多分片并行跑充分利用多核 CPU。返回量也是容易被忽略的点。percolate 查询默认情况下同样受size控制默认返回 10 条。如果业务命中的规则数会超过 10一定记得显式设置合理的size。我们系统里单条事件最多可能命中几十条规则所以把size调到了 100避免规则被截断。refresh 间隔方面因为规则中心本身的写入量不算大实时性要求不高我把refresh_interval从默认的 1s 调成了 30s减少不必要的段刷新和资源争抢。经过这波调整P95 稳定在了 500ms 左右中位耗时在 300ms 以内压测通过。5.3 建立一套可持续写入规范优化做完后我专门把规则注册阶段的检查逻辑加上了限制。这些限制可以作为日常准入控制规则 query 中禁用的字段必须在字段字典内避免索引动态映射出奇怪类型。规则 query 尽量使用 term、range、bool、exists 等低成本类型regexp、script、wildcard 需要走审批并单独拆索引。每个业务规则必须打上biz标签和enabled状态禁止无业务归属的“裸规则”写入。规则不使用时必须有下线流程避免停用规则继续占用 percolate 扫描资源。这套规范看起来是流程上的事但它直接决定了后续索引里的规则“质量”是长时间保持性能的关键。6. 优化结果与复盘哪些调整收益最大6.1 阶段性压测数据整个优化过程分四步走每步之间都做了压测数据如下阶段规则总量每次传入字段数分片配置P50P95初始状态9.6万40字段含大ext3主分片1850ms2400ms清理无效规则6.1万同上3主分片1350ms1900ms按业务拆索引外层过滤每索引0.8-1.2万大字段已投影到10单索引6主分片850ms1220ms输入文档瘦身同上5-8个关键字段同上420ms690ms重规则拆离分片参数调整主路径每索引0.8万5-8个关键字段8主分片240ms480ms可以看到“输入文档瘦身”和“按业务拆索引”是收益最大的两步分别带来了约 40% 和 30% 的降幅。而清理无效规则、调整分片参数属于基础性动作收益相对温和但如果不做后面的优化效果也会打折扣。6.2 哪些改动被证明最有效如果只让我保留三项投入产出比最高的优化我会选规则集收缩、输入文档瘦身、重查询拆离。规则集收缩解决的是“无脑扫全量”的问题让每一条事件只需要面对和自己相关的规则集合。输入文档瘦身解决的是“临时索引构建太贵”的问题让 Elasticsearch 把精力放在真正需要匹配的字段上。重查询拆离解决的是“个别老鼠屎拖垮全链路”的问题让异构的高成本规则不再影响主路径。至于加机器、加副本可以放在最后做甚至很多时候不做也够用。6.3 建议长期保留的监控手段优化不是一锤子买卖。规则中心这种业务有个典型特点规则数量会随着业务增长不断膨胀今天优化的结果可能过几个月又回弹。所以我们保留了几个基础监控index.search.slowlog设置 query 慢日志阈值比如 300ms把超过阈值的 percolate 请求抓下来。profile 抽样每周对慢请求做一次 profile 分析定位是否有新增的“贵”查询混入规则索引。定期规则健康检查脚本扫描全量规则统计 regexp、script、wildcard 使用占比以及停用规则数量及时预警。7. 实际操作中遇到的坑位与速查表7.1 几个容易忽视的坑先说一个印象最深的坑percolate 查询只能在must上下文里用不能直接塞进filter上下文。我一开始想在 filter 里写 percolate 让它不参与打分结果 Elasticsearch 直接报错。后来才意识到 percolate 本身不是一个无评分查询想要过滤必须在外面包 bool再把普通过滤项放到 filter 中。第二个坑是字段映射不一致。规则 query 里写的是user_level但事件文档里同一个字段一会儿传字符串“3”一会儿传数字 3导致临时索引做动态映射时出现类型冲突。现在我们的做法是在索引 mapping 中把所有参与匹配的字段都预先定义好类型业务写入前做一次字段类型校验。第三个坑是 size 的语义。很多人以为 percolate 会默认返回所有命中规则实际上它同样受size限制默认只返回得分最高的 10 条。想拿到全部命中规则要么把 size 调大要么在设计规则时就控制粒度保证单条事件的命中数在一个合理范围内。7.2 常见问题速查表现象可能原因解决方案查询结果比预期少size 太小默认只返回10条显式设置更大的 size 或优化规则粒度耗时从几百ms暴涨到几秒规则里出现了 regexp/script/wildcard将高开销查询拆到独立索引或改为异步同一事件不同时间结果不一致规则索引执行了 refresh新规则刚写入未生效确认规则写入后是否需要等待 refresh必要时手动 refreshpercolate 报字段映射冲突事件文档字段类型和规则query里不一致预定义参与匹配字段的 mapping统一类型加了外层 filter 后没明显提升filter 的字段在规则文档上不是 keyword确保用于过滤的 biz、enabled 等字段是 keyword 类型索引数据量不大但节点CPU打满规则query结构复杂且全部混在同一个分片按规则成本分层拆索引/拆分片隔离传入大文本字段拖慢查询文本字段需要分词和构建索引只传规则需要的投影字段大文本场景单独索引最后再分享一个我自己的判断方法遇到 percolate 性能问题先别急着动集群从索引里的规则数量、传入文档字段数、规则 query 类型这三张表格查起通常很快就能找到方向。等这些结构性问题解决了再去看内存、GC、分片数这些资源层面的参数顺序反了容易钱花了事没办。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →