
聊 Aviator 表达式执行模式之前我想先纠正一个很常见的误解很多人以为 Aviator 默认执行一次表达式就是“解析一次、执行一次”预编译只是锦上添花。但实际踩过线上问题的同学都知道这个认知的偏差足以让一个规则引擎从每天千万次请求稳稳跑变成CPU动不动飘红、接口RT忽高忽低的“定时炸弹”。Aviator 作为 Java 生态里以轻量和高效出圈的表情式引擎默认执行模式和预编译模式之间的差距本质上不是“快一点”的问题而是量级上的性能跃迁。这篇文章我会沿着表达式引擎的完整执行路径拆开默认 execute 后面到底藏了什么再说清楚预编译为什么能把高频场景延迟压下来。无论你是做规则引擎、动态配置、风控策略还是想把 Aviator 嵌进自己中间件里的开发者看完都能直接照着做选型和优化。1. Aviator表达式执行模式到底在讲什么1.1 表达式从字符串到结果走过了哪些路任何一个表达式引擎拿到a b * 2 - 1这样一个字符串都不会直接“心算”。它的内部有一套固定的流水线词法分析、语法分析、语义分析、代码生成、执行。词法分析把字符串切成一个个 Token比如a、、b、*、2、-、1语法分析把这些 Token 组装成一棵抽象语法树也就是 AST反映运算优先级和结合关系语义分析检查变量是否存在、类型是否匹配、函数调用是否正确代码生成阶段把 AST 变成可以直接运行的指令序列Aviator 这一步会做大量优化本质上是将表达式转换成类似字节码指令的执行体最后才是真正的求值执行。你可以把表达式想象成一道菜谱。默认执行模式等于每次做菜都重新读一遍菜谱、重新确认火候调料备料然后才开火预编译模式则是提前把菜谱研究透固化成一整套标准工序之后每来一个订单直接按照工序走一遍跳过“看菜谱”的过程。问题在于很多人只盯着“执行”两个字以为耗时大头在最终的计算上。实际上对于复杂表达式词法分析、语法树构建和优化阶段的开销会远超执行阶段。表达式越复杂前端解析和编译的占比就越大默认模式和预编译模式的差距也会被拉得越开。1.2 为什么说执行模式决定性能上限Aviator 的设计里AviatorEvaluator.execute()是一个面向“方便”的入口而AviatorEvaluator.compile()返回的Expression对象则是一个面向“性能复用”的入口。注意Aviator 并没有蠢到每次execute都从零开始编译一遍它的内部默认带了编译缓存同一个字符串第二次执行时可以直接命中缓存。但方便归方便execute(String)这条路径上仍然存在两个绕不开的开销第一每次调用都要拿表达式字符串去算哈希、做查找在全局缓存里匹配。这个动作很小但经不住高频调用和大量表达式积累。第二全局编译缓存有容量上限也有过期策略。当缓存被 LRU 淘汰或者过期下一次调用就必须重新走一遍完整的编译流水线这个时候产生的延迟尖刺在压测数据上会表现得非常难看。所以Aviator 表达式执行模式的核心命题不是“要不要优化”而是“能不能把编译代价从每次执行挪到初始化阶段”。预编译模式的本质就是把Expression对象提前捏在手里让核心链路里只剩参数绑定和求值执行。2. 默认执行模式便捷背后的隐藏开销2.1 你天天写的 execute底层是怎么跑的绝大多数人第一次用 Aviator 都是这样开始的MapString, Object env new HashMap(); env.put(a, 1); env.put(b, 2); Object result AviatorEvaluator.execute(a b * 2 - 1, env);这段代码看着清爽但 Aviator 内部实际上走了这样一条路径拿到字符串a b * 2 - 1先查编译缓存看这个字符串之前有没有编译过如果缓存命中直接拿到编译后的表达式对象去执行如果缓存没命中完整执行词法分析、语法分析、语义分析、代码生成然后把编译结果放进缓存最后一次环境下绑定变量输出结果。也就是说默认执行模式不是完全“每次编译”而是“每次查缓存偶尔完整编译”。问题在于这个缓存查询本身也是成本而且它是全局单例共享的。Aviator 默认提供的AviatorEvaluator是静态单例所有模块都共用这一份编译缓存。如果你们的系统里规则中心在跑 Aviator营销模块也在跑 Aviator两个模块的表达式互相挤占缓存池就很容易出现“本来很稳的规则接口隔段时间突然慢一下”的诡异现象。2.2 自动编译缓存帮你扛了但不是免费的为了方便理解我把默认执行模式的成本拆成三部分表达式字符串处理成本、全局缓存查找成本和可能的全量编译成本。环节每次调用是否发生主要耗时特征字符串哈希和规范化是线性扫描字符串表达式越长越明显全局缓存查找是哈希碰撞和 Map 并发竞争完整解析编译偶尔缓存未命中或过期时出现明显尖刺参数绑定是取决于 env 大小和数据结构表达式求值执行是真实业务计算通常很快这里的核心矛盾在于Aviator 自动缓存虽然帮你把“重复编译”省掉了但并没有省掉“缓存查找”这个动作。如果你的表达式比较短比如a 1缓存查找成本可能不敏感但如果是几百上千个不同规则比如动态拼接的条件表达式amount 100 status PAID channel in (A,B) score 80每次执行都要先在缓存里精确匹配一个长字符串这个成本很快就体现出来了。更麻烦的是缓存淘汰带来的不确定性。Aviator 的自动编译缓存默认有一个容量上限和过期时间一旦表达式数量超过阈值最久未用的表达式就会被清掉下一次再遇到它就得重新编译。性能表现从外部看就是不稳定的同一段规则上一秒命中了缓存跑得飞快这一秒被淘汰了重新编译耗时可能是前者的几十倍。2.3 什么情况下默认模式会拖垮你的接口我见过几类典型场景默认执行模式是硬生生把性能拖垮的第一类是表达式动态拼接。业务侧为了适配不同规则把变量直接拼进表达式字符串例如status code type type 。这样一来code和type的组合一旦很多表达式字符串几乎没有重复缓存全部失效Aviator 变成了“每次请求都在编译新表达式”比直接写 if-else 慢得多。第二类是一次请求内高频执行。比如在订单处理链路里同一个表达式要在循环中执行几十次每次都调用AviatorEvaluator.execute(expr, env)。即便是简单表达式循环叠加后缓存查找成本会被放大如果表达式里还嵌套了列表、函数开销更明显。第三类是并发压力大的公共服务。全局单例缓存是所有线程共享的当并发量上来缓存 Map 的访问竞争也会变成瓶颈。你会在压力测试里看到明明表达式很简单看起来也没有多少计算量但吞吐量就是上不去。3. 预编译模式把编译结果攥在手里3.1 核心改动从 execute(String) 到 compile 后 execute(Expression)改动其实只是一行但这一行背后的模式完全不同// 默认执行模式 AviatorEvaluator.execute(a b * 2 - 1, env); // 预编译模式 Expression compiled AviatorEvaluator.compile(a b * 2 - 1); compiled.execute(env);compile返回的Expression对象代表一个已经完成语法解析、语义分析和代码生成的可执行体。它不再关心原始字符串长什么样也不需要在每次执行时去全局缓存里“认领自己的编译结果”。只要表达式本身的模板不变化后续所有执行都能直接复用这个编译产物。我在实际项目里经常用一个比喻默认执行模式是你每次出门前都要找钥匙找钥匙的动作虽然快但每次都要翻一遍所有口袋预编译模式是把钥匙挂在大门边的挂钩上出门动作只剩一步。表达式越复杂这个“挂钥匙”的收益越大。预编译模式还有一层隐形价值它把表达式的生命周期管理从 Aviator 内部的全局缓存转移到了业务代码里。你可以控制什么时候编译、什么时候销毁、什么时候替换而不是被动等待一个公共缓存去自动淘汰。3.2 一次 compile无限次 execute代码这样写才对预编译的正确打开方式不是把compile放到每次执行前调一下而是把它放到初始化阶段。我在 Spring 项目里通常会这样封装Component public class ScoreCalculator { private final Expression scoreExpression; public ScoreCalculator() { this.scoreExpression AviatorEvaluator.compile( age * 2 score * 3 (level 5 ? 100 : 0) ); } public long calculate(int age, int score, int level) { MapString, Object env new HashMap(); env.put(age, age); env.put(score, score); env.put(level, level); return (Long) scoreExpression.execute(env); } }要点有三个构造函数里只编译一次Expression作为对象字段持有每次请求进来只构建一个轻量级env执行时不再触碰表达式字符串。这里很多人会犯一个错误把AviatorEvaluator.compile()写到了方法内部每次调用都编译一次。这样的话你等于绕过了默认执行模式的缓存却亲手把编译开销留在了核心链路里。预编译首先是一种思路然后才是一个 API。如果你写的是工具类想要更灵活一点可以维护一个本地表达式容器private final ConcurrentHashMapString, Expression expressionCache new ConcurrentHashMap(); public Object eval(String expression, MapString, Object env) { return expressionCache.computeIfAbsent(expression, AviatorEvaluator::compile) .execute(env); }这种写法在规则数量可控的前提下非常实用。它能保证同一段规则只会被编译一次又不会把全局缓存和业务缓存混在一起。3.3 性能跃迁到底有多明显先放一段典型压测代码方便你复现对比public class AviatorPerfTest { public static void main(String[] args) { String expression a b * 2 - c / 3 (d 10 ? d : 0); MapString, Object env new HashMap(); env.put(a, 1); env.put(b, 2); env.put(c, 3); env.put(d, 4); // JIT 预热 for (int i 0; i 10000; i) { AviatorEvaluator.execute(expression, env); } // 默认执行模式 long start System.nanoTime(); for (int i 0; i 100000; i) { AviatorEvaluator.execute(expression, env); } long defaultCost System.nanoTime() - start; // 预编译模式 Expression compiled AviatorEvaluator.compile(expression); for (int i 0; i 10000; i) { compiled.execute(env); } start System.nanoTime(); for (int i 0; i 100000; i) { compiled.execute(env); } long compileCost System.nanoTime() - start; System.out.println(default mode: defaultCost / 100000 ns/op); System.out.println(compile mode: compileCost / 100000 ns/op); } }具体数字会因为机器、JDK 版本、表达式复杂度而波动但规律非常稳定表达式类型默认执行相对成本预编译相对成本量级差异简单算术表达式低极低几倍到十几倍多变量条件组合中低一个数量级以上复杂嵌套规则、函数、集合操作高中可达数十倍注意简单表达式之间的差距可能只有几倍很多人会觉得“几倍无所谓”。但放在一天几千万次调用的高频接口里这个“几倍”就是几十台服务器和十几台服务器之间的差别。复杂业务规则场景下默认模式中解析和代码生成的占比更高预编译的收益会爆炸式增长。3.4 预编译的 Expression 线程安全吗这个问题一定会有人问。Aviator 编译出来的Expression对象只要不主动修改它内部的可变状态是可以在多线程之间安全共享的。换句话说你把同一个Expression挂在 Spring 单例 Bean 里让成百上千个线程同时拿不同的env去执行完全没有问题。不安全的地方在于env。execute(MapString, Object env)里的 Map 是执行上下文如果多个线程共用一个envMap同时往里塞或删变量就会出现诡异的数据覆盖问题。我在项目里踩过一次这样的坑预编译性能提升非常明显但上线后偶发计算出错误结果排查半天才发现是有个同事为了省对象创建把一个静态 HashMap 当 env 传给所有线程线程 A 往里面放了a1线程 B 又放了a2A 执行时拿到的 a 已经不是自己能确定的 a 了。正确姿势很简单每个线程或者每次执行使用局部新建的 Map如果是在单线程循环内高频执行可以复用一个局部 Map但千万别把它放到静态变量或跨线程共享的容器里。4. 实操怎么从默认切换到预编译以及避坑选型4.1 规则引擎场景表达式集合统一管理如果你用 Aviator 做规则中心通常不会只有一个表达式而是一整套规则集合。这种情况下我建议直接在业务层做表达式缓存public class RuleEngine { private final ConcurrentHashMapString, Expression compileCache new ConcurrentHashMap(); public Object evaluate(String rule, MapString, Object env) { Expression expression compileCache.computeIfAbsent(rule, AviatorEvaluator::compile); return expression.execute(env); } }这个做法的好处是规则字符串第一次进来时编译之后一直复用不同规则之间互不影响全局单例缓存不会被其它模块的表达式挤掉。但要注意一点computeIfAbsent虽然方便如果compile抛异常异常会直接抛给调用方。所以调试阶段建议先手动compile一遍确认表达式合法后再注册到缓存容器里。当规则数量非常大比如几万个动态规则无脑用 ConcurrentHashMap 会撑爆内存。此时应该增加容量上限或者换成一个带 LRU 淘汰的缓存容器。核心思路是预编译不等于无限缓存而是要结合命中率做淘汰。4.2 一次性求值场景别为了预编译而预编译预编译不是银弹。如果某个表达式只在启动阶段算一次或者用户手动触发一次那么默认 execute 完全够用不值得为它引入缓存生命周期管理。举个例子配置中心里有一个“当前活动折扣公式”运营每天改一次系统每次启动加载一次对这种低频场景你完全可以写double discount (Double) AviatorEvaluator.execute(base * 0.8 extra, env);预编译反而会让代码结构变复杂你要在类里维护一个 Expression 字段还要考虑配置变更时重新 compile。花十分钟引入的复杂度换不来任何性能收益。所以选型标准很简单看表达式是否高频复用。高频复用预编译是刚需低频离散默认执行就是最优解。4.3 场景选型速查表场景表达式特征推荐模式理由在线风控规则同一规则每个请求传入不同变量预编译核心链路高频执行编译成本必须前置用户自定义规则数量大、有重复触发业务层缓存或预编译容器避免每次动态拼接后全量编译配置公式每日生效低频执行每次变化不大默认 execute编译一次的成本可忽略数据分析批量任务循环内执行大量不同表达式预编译 命中率控制避免长期运行内存膨胀多模块共用 Aviator各模块表达式互相独立独立 AviatorEvaluatorInstance防止全局缓存互相挤占4.4 多模块隔离别让单例缓存全公司共用Aviator 默认提供AviatorEvaluator是静态单例好处是开箱即用坏处是大家都在一个缓存池里搅。如果你的系统里多个模块都要用 Aviator我强烈建议每个模块创建一个独立的实例AviatorEvaluatorInstance ruleEvaluator AviatorEvaluator.newInstance(); ruleEvaluator.setCompileCacheCapacity(500); Expression exp ruleEvaluator.compile(a b 100 ? high : low);独立实例之间互不干扰容量可以按模块单独设置规则数量。规则中心给 5000 个缓存营销计算给 200 个缓存彼此不会把对方的热点表达式踢掉。这个做法在大型系统里非常重要但文档里很少会主动告诉你。5. 常见问题与排查技巧实录5.1 已经预编译了为什么还是很慢如果代码里已经用了compile并持有Expression跑起来还是很慢第一件事不是怀疑 Aviator而是检查下面几个点是否在循环内误调了AviatorEvaluator.compile()导致每轮都在重新编译是否在方法内部每次都new HashMap塞入大量对象env 构造本身成了性能瓶颈是否把同一个Expression用在了序列化和反序列化场景里间接产生额外开销。有一次我排查一个接口发现预编译之后 RT 并没有显著下降逐行看代码才发现问题出在 env 构造调用方把几百个字段的对象塞进一个 Map再传给表达式执行。表达式本身只用了其中两个字段但 Map 构建成本被白白背了。优化方式不是放弃预编译而是精简 env。5.2 默认模式的缓存被顶掉怎么定位如果你坚持用默认 execute但发现某些表达式经常出现“突然变慢”的情况多半是编译缓存被淘汰了。排查思路是先确认系统里表达式总量再看全局缓存容量设置。Aviator 的缓存容量和过期时间都可以调整如果表达式数量确实很大可以调大容量延长过期时间但根本解法还是把高频表达式改为预编译让它们不依赖 Aviator 全局缓存。还可以加一点统计信息比如在表达式的执行入口打点记录每次执行耗时。当耗时出现明显尖刺去全局缓存配置里验证是否对应缓存淘汰的时机。5.3 表达式是动态拼接的还能预编译吗这是最多人问的问题。很多人写规则时习惯把变量直接拼进表达式字符串比如String expr status status amount amount;这种写法每次传入不同的值和不同的字符串预编译根本没效果。改正的思路是把常量写成参数把字符串模板固定下来String expr status s amount amt; Expression compiled AviatorEvaluator.compile(expr);执行时通过 env 传s和amt。这样表达式模板固定哪怕业务上状态和金额千变万化编译产物都能复用。如果整个模板的骨架都会变比如用户自由拖拽规则节点生成不同条件那就只能按“规则 ID”维度做编译缓存而不是按“字符串内容”做缓存。这里的关键是给每条规则定义一个稳定的唯一 ID缓存 Map 的 key 用规则 IDvalue 是Expression当规则内容变更时主动移除旧对象并重新 compile。5.4 踩坑实录预编译加上并发反而出了诡异数据最后分享一个真实的坑。我们把一个高频规则改成了预编译上线后性能数据非常漂亮但没过多久收到告警说部分请求计算出来的结果不符合业务预期。查下去的结论让人哭笑不得团队里有人在类加载时初始化了一个静态的HashMap作为环境变量容器每次执行前往里面 put 一批变量执行完也不清空。当并发量上来不同线程的变量互相覆盖结果自然随风摇摆。Aviator 的 Expression 本身是线程安全的但 env 不是。预编译模式把性能问题解决了也把并发问题暴露得更明显。安全做法是所有环境变量都放在方法内局部变量里不要让环境变量对象跨线程共享。如果你确实想提升性能最多在同一个线程的循环体里复用单个 Map每轮清空再放值绝不能放在静态字段里当公共容器。我在实际项目中养成的一个习惯是预编译 Expression 用单例env 永远 new。对象创建的成本对现代 JVM 来说非常低远低于并发写共享 Map 引发的各种诡异问题。规则模板的版本管理同样值得注意。配置中心里表达式一改你持有的 Expression 就过期了如果不做新旧切换线上会一直用旧规则跑。建议在表达式容器里增加版本号字段每次配置更新时 put 一个 key 里带上版本号或者干脆重新 compile 后替换容器中的引用确保预编译对象始终跟最新配置同步。