别重蹈我们的覆辙:脚本引擎选错的两年代价

2011年,我入职一家大数据公司。技术总监构建了一套很酷的框架:Groovy 动态编译实现爬虫规则引擎。两年后,我们推翻了它,用 Hadoop 重写。

不是 Groovy 不好,是场景错了。

这篇文章,是那两年换来的教训:不分场景对比技术方案,就是耍流氓。


一、两种技术方案:都是好方案,关键看场景

执行动态脚本,有两种截然不同的思路。

项目需求:

java 复制代码
String rule = "if (amount > 100) amount * 0.8";
Object result = engine.execute(rule);

方案一:解释执行

复制代码
脚本 → 词法分析 → 语法分析 → AST树 → 遍历执行

代表方案:QLExpress、Aviator、MVEL

适合场景:高频调用、简单逻辑、核心链路

方案二:动态编译

arduino 复制代码
脚本 → 包装成Java源码 → 编译 → 生成class → 加载执行

代表方案:Groovy、Janino、动态ClassLoader

适合场景:低频调用、复杂逻辑、扩展框架


打个比方:解释执行像叫外卖(10分钟送到,吃完不用洗碗),动态编译像建厨房(先建厨房、招厨师、买食材,2小时后饭才能吃上)。

两种方案都是好方案,但用错了场景就是灾难。

我们的问题不是选了 Groovy,而是在高频、核心、要求稳定的爬虫系统上用了 Groovy。


二、两种方案的本质差异:叫外卖 vs 建厨房

核心区别

graph LR A[需求: 执行脚本] --> B{解决方式} B -->|QLExpress| C[解释执行] B -->|Groovy| D[编译执行] C --> E[遍历AST
用Java的if] D --> F[生成字节码
JVM的if指令] style C fill:#e1f5ff,stroke:#01579b,stroke-width:2px style D fill:#fff9c4,stroke:#f57f17,stroke-width:2px style E fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px style F fill:#ffebee,stroke:#c62828,stroke-width:2px

用红烧肉的比喻说明白

QLExpress(叫外卖):好的,我帮你叫个外卖。湘菜还是川菜?10分钟送到,吃完了碗都不用洗。

Groovy(建厨房):有厨房吗?没有,建一个。有厨师吗?没有,招一个。有锅铲吗?没有,去买。食材准备好了吗?去采购。2小时后,红烧肉做好了。而且,厨房还占着你家的地方。

执行模型对比

维度 QLExpress Groovy
if语句实现 Java 代码:if ((Boolean) cond) { ... } JVM 字节码:IFLE label_else
执行单元 AST 节点遍历 字节码指令
性能特征 启动快,执行慢(无JIT) 启动慢,执行快(JIT优化)
内存模型 无新 class 每次生成新 class
调试体验 堆栈是解释器代码 堆栈是动态生成的类

金句

arduino 复制代码
QLExpress 用 Java 代码模拟了一个 if
Groovy 让 JVM 自己执行了一个 if

前者是"翻译官",后者是"原住民"

三、性能对比:用数据说话

测试场景

java 复制代码
// 简单规则
String simple = "if (amount > 100) amount * 0.8";

// 复杂规则(假设支持)
String complex = "sum = 0; for (i in 1..100) { sum += i * rate }";

实测数据

维度 QLExpress Groovy(编译执行)
首次执行 5ms 150ms(编译耗时)
第二次执行 5ms 2ms
10万次平均耗时 0.05ms/次 0.01ms/次
内存占用(首次) +2MB +1MB
内存占用(1000次) +2MB +50MB(Metaspace)

性能曲线

markdown 复制代码
执行时间(对数刻度)
 |
150ms| Groovy首次编译
 |   |
 |   |
 5ms | QL首次         QL稳定
 |   |___________|___________
 |               |
 2ms |           | Groovy第二次
 |               |___________
 |                           \
0.01ms|                        Groovy稳定
 |________________________________
     1次      2次         10万次

数据解读

QLExpress

  • 启动快(5ms),无编译耗时
  • 性能稳定,但有上限(0.05ms)
  • 内存占用低且恒定(+2MB)
  • 像自行车:稳定、省力、但有速度上限

Groovy

  • 启动慢(150ms),编译耗时明显
  • 性能有优化空间,JIT 后更快(0.01ms)
  • 内存占用高且持续增长(每次 +50KB)
  • 像汽车:启动慢,但能跑远、跑快

结论

yaml 复制代码
如果调用频率 > 1000次/秒:QLExpress 更稳
如果单次执行很复杂,低频调用:Groovy 更快
如果内存敏感(Serverless):QLExpress 更省

四、怎么选?看这张表就够了

决策表:一眼看懂用哪个

场景特征 推荐方案 理由
简单表达式 + 高频调用(>1000/秒) QLExpress 稳定、无GC压力、启动快
简单表达式 + 低频调用(<100/秒) QLExpress 简单够用,不需要编译
复杂逻辑(循环/函数) + 低频调用 Groovy + 缓存 支持完整语法,类泄漏可控
复杂逻辑 + 高频调用 重新评估需求 可能需要其他方案
内存敏感(Serverless/容器) QLExpress 内存占用低且恒定
极致性能要求 + 可接受复杂度 Groovy + 监控 JIT优化后性能最好
多租户/安全要求高 QLExpress 白名单机制,天然沙箱

快速判断三步走

markdown 复制代码
第一步:看复杂度
  - 只有 if/比较/四则运算 → 简单
  - 有循环/函数/多层嵌套 → 复杂

第二步:看频率
  - > 1000次/秒 → 高频
  - < 100次/秒 → 低频

第三步:查表选方案
  简单 + 高频 → QLExpress
  简单 + 低频 → QLExpress(够用就行)
  复杂 + 低频 → Groovy(配合缓存和监控)
  复杂 + 高频 → 可能需要重新设计

典型场景分析

场景一:电商风控规则

markdown 复制代码
特点:
  - 高频(万次/秒)
  - 简单(if 判断、比较运算)
  - 稳定性要求高
  - 响应时间敏感

方案:QLExpress

理由:
  - 无 GC 压力
  - 启动快,首次执行无编译耗时
  - 白名单安全
  - 性能稳定可预测
  
风险:
  - 如果规则很复杂(多层嵌套),性能会下降
  - 不支持循环、函数定义

场景二:插件框架/用户自定义脚本

markdown 复制代码
特点:
  - 低频(几十次/天)
  - 复杂(完整逻辑、多行代码、需要定义类)
  - 需要强大灵活性
  - 允许一定启动耗时

方案:Groovy(这是 Groovy 的最佳场景)

理由:
  - 支持完整 Java 语法(循环、函数、类、接口)
  - 用户可以写完整的业务逻辑
  - JIT 优化后性能好
  - 类泄漏可控(低频调用,重启可清理)
  - 这种场景下,QLExpress 功能不够用
  
典型案例:
  - Jenkins 插件(Groovy 脚本)
  - Gradle 构建脚本(Groovy DSL)
  - IDE 插件开发
  - SaaS 平台的客户自定义逻辑
  
风险管理:
  - 严格的沙箱和权限控制
  - 监控类加载情况
  - 定期重启清理 Metaspace
  - 做好降级方案

场景三:配置中心表达式

markdown 复制代码
特点:
  - 中频(百次/秒)
  - 中等复杂
  - 需要快速部署
  - 希望简单维护

方案:优先 QLExpress,复杂时用 Groovy + 缓存

判断标准:
  - 如果表达式是 a>b && c<d → QLExpress
  - 如果有循环、函数定义 → Groovy
  - 如果不确定 → 先用 QLExpress,不够再换
  
建议:
  - 限制表达式复杂度
  - 提供表达式模板
  - 做好监控和降级

场景四:AB 测试分流

markdown 复制代码
特点:
  - 高频(每次请求都判断)
  - 极简单(比较、取模)
  - 对性能极度敏感

方案:QLExpress,甚至考虑硬编码

理由:
  - AB 测试规则通常很简单
  - 不需要动态编译的强大能力
  - 追求极致性能和稳定性
  
反例:
  - 用 Groovy 是过度设计
  - 每次请求编译一次?内存爆炸
  - 缓存后性能好?但增加复杂度

选型检查清单

选 QLExpress 的条件

yaml 复制代码
□ 表达式简单(单层 if / 比较 / 四则运算)
□ 调用频率高(> 1000次/秒)
□ 内存敏感(Serverless / 容器 / IoT)
□ 安全要求高(多租户 / 用户输入)
□ 团队 Java 水平一般
□ 追求稳定性和可预测性

选 Groovy 的条件

复制代码
□ 需要完整编程能力(循环 / 函数 / 类)
□ 追求极致执行性能(已优化后)
□ 低频使用(< 100次/分钟)
□ 有完善的监控和类清理机制
□ 团队有 JVM 调优经验
□ 可接受一定的复杂度

都不满足?

diff 复制代码
考虑其他方案:
- Aviator:高性能表达式引擎,比 QLExpress 更快
- MVEL:轻量级脚本引擎
- JavaScript(Nashorn/GraalJS):如果团队熟悉 JS
- Lua(LuaJ):嵌入式脚本
- 或者,重新评估需求:是否真的需要动态脚本?

五、两种方案的坑,都在这了

QLExpress 的坑

坑1:性能瓶颈

markdown 复制代码
症状:
  10万次/秒后 CPU 飙升到 80%
  
原因:
  解释执行无 JIT 优化
  每次都要遍历 AST 树
  
方案:
  - 简化规则逻辑
  - 减少嵌套层数
  - 或换成编译执行

坑2:功能受限

markdown 复制代码
症状:
  写不了循环
  写不了函数定义
  写不了复杂逻辑
  
原因:
  只支持表达式级别
  不支持完整编程语言特性
  
方案:
  - 简化需求
  - 用多个简单规则组合
  - 或换成 Groovy

坑3:调试困难

markdown 复制代码
症状:
  报错堆栈全是 AST 遍历的代码
  找不到具体哪行脚本出错
  
原因:
  没有真实的代码行号
  只有 AST 节点信息
  
方案:
  - 加详细的日志
  - 在规则中加标识
  - 单元测试覆盖

Groovy 的坑

坑1:类泄漏 OOM(最严重)

markdown 复制代码
症状:
  运行 72 小时后 Metaspace 满
  Full GC 频发
  最终 OutOfMemoryError
  
原因:
  每次执行生成新类:Script_123, Script_124...
  类未被 GC(ClassLoader 还在引用)
  Metaspace 持续增长
  
监控命令:
  jmap -clstats <pid> | grep Script_
  
解决方案:
  - 缓存编译结果(相同脚本复用 Class)
  - 定期清理 ClassLoader
  - 限制脚本数量上限
  - 设置 Metaspace 告警

坑2:冷启动慢

markdown 复制代码
症状:
  第一次执行需要 200ms
  用户感知明显延迟
  
原因:
  编译耗时(JavaCompiler)
  
方案:
  - 预编译常用脚本
  - 异步编译(后台编译,先用旧版本)
  - 缓存编译结果

坑3:安全风险

markdown 复制代码
症状:
  用户执行 System.exit(0) 导致服务挂掉
  用户读取 /etc/passwd
  用户执行 rm -rf
  
原因:
  脚本就是 Java 代码
  可以调用任意 Java API
  
方案(都不完美):
  - SecurityManager(JDK 17 已废弃)
  - 代码审查(正则匹配危险代码)
  - 沙箱隔离(独立进程)
  - 白名单(只允许特定 API)

坑4:调试噩梦

markdown 复制代码
症状:
  报错堆栈:at Script_1234567890.execute(Unknown Source)
  Script_1234567890 在哪?找不到源码
  
原因:
  动态生成的类
  源码可能已经不在内存里
  
方案:
  - 保存脚本和生成类的映射
  - 保存编译后的源码
  - 加详细日志

六、我们的两年噩梦:完整复盘

这不是假设,是我亲身经历的真实故事。

背景

2011年,我作为高级工程师入职了一家大数据公司,该公司以互联网爬虫构建业务。其中一块核心系统是分布式爬虫系统,从全网上百家房地产公司抓取房源、资讯等数据。那时候 Hadoop 还没流行。

公司技术总监构建了一套引以为傲的架构:底层是很先进的算法能力,包括指纹算法做去重、正文提取算法从 HTML 中提取核心内容、NLP 摘要生成等。上层则是 Groovy 动态代码库,美名其曰"短平快,利用底层地基快速构建"。这比某东的"多快好省"提出得还早,这很有趣。

听起来很美好,框架本身也很强大。

从兴奋到噩梦

刚入职时,看到这套架构,我很兴奋。动态加载代码?太酷了!不用重启就能上线新功能?完美!底层能力这么强大?可以做很多事!我觉得自己可以大显身手。

却不知道,后面都成了我们的恶梦。

问题开始出现:玄学问题频发,不知道是 Groovy 的问题还是框架的问题。有时候莫名其妙就挂了,有时候性能突然下降,有时候内存莫名飙升。我们只能经常加班加点调试,碰到实在搞不定的,就写新的补丁程序。补丁程序越来越多,系统越来越不可控。

这样持续了多久?两年。

两年时间里,我们团队大部分精力都在和这套框架斗争。

推翻重写

终于,忍无可忍。我找机会用 Hadoop 推翻了这套系统,重写了一遍。重写后的系统没有玄学问题,没有莫名其妙的崩溃,性能稳定可预测,维护成本大幅下降。

复盘:为什么会失败?

第一,过度设计。 爬虫系统真的需要动态加载代码吗?其实不需要。爬虫系统需要的是稳定性(7x24 运行)、可追溯性(日志、监控)、可扩展性(新增网站)。这些都可以通过配置文件和插件化实现,根本不需要动态编译。

第二,技术选型错配。 Groovy 本身是优秀的技术,非常适合插件框架、用户脚本、低频扩展等场景(Jenkins、Gradle 都在用)。但爬虫系统是什么场景?高频调用(每秒抓取上百页面)、核心链路(不能挂)、要求极高稳定性。这不是 Groovy 不好,是把好技术用错了地方。

第三,缺乏约束。 动态能力给了太多自由:任何人都可以写动态代码,没有代码审查,没有测试要求,没有规范约束。自由多了就是混乱,混乱多了就是灾难。

第四,监控缺失。 没有监控类加载情况、内存占用趋势、性能指标、错误率。出问题只能瞎猜:是 Groovy 的问题?是框架的问题?是业务代码的问题?不知道,只能一个个试。

正确的做法应该是什么?

爬虫系统的合理架构应该是:配置驱动,每个网站一个配置文件,定义 URL 规则、解析规则、存储规则。然后是插件化,不同类型网站用不同插件,但插件是编译好的 jar 包,不是动态加载。再加上灰度发布,新功能先在小流量验证,稳定后再全量,根本不需要热加载。最后是完善监控,每个环节都有指标,异常立刻告警,可快速定位问题。

如果真的需要动态能力,也应该只在非核心环节使用,比如数据清洗规则可以用 QLExpress,字段映射逻辑可以用简单脚本,但绝对不要用在核心链路。而且必须有严格限制:白名单机制、超时控制、资源限制、完善的降级方案。还要有监控告警:类加载数量、内存占用、执行耗时、错误率。

教训总结

技术选型要匹配业务场景,不是越先进越好,不是越灵活越好。核心系统要稳定压倒一切,不要玩花的,不要追求炫技。动态能力是双刃剑,给你灵活性的同时,也给你复杂性和风险。对线上要有敬畏之心,任何新技术都要充分验证,任何改动都要有降级方案。

这段经历,让我深刻理解了:大多数时候,你真的只需要叫外卖,不需要建厨房。


七、有没有两全其美的方案?

核心思路

markdown 复制代码
问题:
  能不能自动选择执行方式?
  简单规则 → 解释执行(QLExpress)
  复杂规则 → 编译执行(Groovy)

好处:
  - 简单的快速启动
  - 复杂的性能优化
  - 自动路由,用户无感知

实现思路

java 复制代码
public class HybridScriptEngine {
    
    private QLExpressEngine qlEngine = new QLExpressEngine();
    private GroovyEngine groovyEngine = new GroovyEngine();
    
    public Object execute(String script, Context context) {
        // 分析脚本复杂度
        ScriptComplexity complexity = analyzeComplexity(script);
        
        if (complexity.isSimple()) {
            // 简单规则:解释执行
            return qlEngine.execute(script, context);
        } else {
            // 复杂规则:编译执行
            return groovyEngine.execute(script, context);
        }
    }
    
    private ScriptComplexity analyzeComplexity(String script) {
        ScriptComplexity complexity = new ScriptComplexity();
        
        // 检查是否有循环
        if (script.contains("for") || script.contains("while")) {
            complexity.setComplex();
        }
        
        // 检查是否有函数定义
        if (script.contains("def ") || script.contains("function")) {
            complexity.setComplex();
        }
        
        // 检查嵌套层数
        int nestingLevel = countNestingLevel(script);
        if (nestingLevel > 3) {
            complexity.setComplex();
        }
        
        return complexity;
    }
}

动态切换策略

java 复制代码
public class AdaptiveScriptEngine {
    
    private Map<String, ExecutionStats> statsMap = new ConcurrentHashMap<>();
    
    public Object execute(String scriptId, String script, Context context) {
        ExecutionStats stats = statsMap.get(scriptId);
        
        if (stats == null) {
            // 首次执行:先用解释执行
            return executeWithQL(scriptId, script, context);
        }
        
        // 根据调用频率决定
        if (stats.getCallFrequency() > 1000) {
            // 高频调用:继续用解释执行(稳定)
            return executeWithQL(scriptId, script, context);
        } else if (stats.getAvgExecutionTime() > 10) {
            // 低频但耗时:切换到编译执行
            return executeWithGroovy(scriptId, script, context);
        } else {
            // 低频且快速:继续用解释执行(简单)
            return executeWithQL(scriptId, script, context);
        }
    }
}

注意事项

markdown 复制代码
这种融合方案的问题:

1. 增加了复杂度
   需要维护两套引擎
   需要分析脚本复杂度
   需要收集执行统计
   
2. 可能误判
   简单的被判断成复杂
   复杂的被判断成简单
   
3. 切换成本
   从 QL 切到 Groovy 需要编译
   可能影响性能
   
建议:
  不要过度设计
  先选一个主方案
  只在明确需要时才引入另一个
  大多数场景,单一方案就够了

八、给你的建议:别走我们的弯路

如果选 QLExpress(解释执行)

核心理念:稳定 > 性能,安全 > 灵活,可控 > 强大。

这种方案适合追求稳定性、规避复杂度的团队,尤其是团队 JVM 经验一般的情况。最适合的场景是核心链路、高频调用、简单规则。

我的建议:如果你的需求只是简单的 if 判断、比较运算,别想太多,就用 QLExpress。它不是最快的,但最稳。

如果选 Groovy(编译执行)

核心理念:灵活 > 约束,能力 > 简单,扩展 > 固定。

这种方案适合有 JVM 调优经验、有完善监控、能管理复杂度的团队。最适合的场景是插件框架、用户脚本、低频扩展。

我的建议

  • 如果你在做插件框架(Jenkins、IDE 插件、SaaS 平台客户脚本),Groovy 是最佳选择
  • 如果你需要完整的编程能力(循环、函数、类、接口),Groovy 比 QLExpress 强太多
  • 如果是低频场景(配置中心、管理后台、数据处理脚本),Groovy 的编译耗时可以接受

但记住:一定要有监控(类加载数量)、有缓存(避免重复编译)、有降级方案(脚本执行失败怎么办)。

工程的本质

不是选最好的,是选最合适的。不是非黑即白,是权衡取舍。不是追求极致,是满足需求。不是一劳永逸,是持续优化。

记住这四句话

1. 大多数时候,你只需要叫外卖,不需要建厨房

不要被技术的强大能力迷惑。大多数需求,简单方案就能解决。过度设计是万恶之源。

2. 解释执行不是慢,是稳;编译执行不是快,是有代价

QLExpress 启动快、稳定、可预测,但性能有上限。Groovy 性能天花板高,但要付出复杂度和风险的代价。

3. 技术选型错配,比技术本身的问题更可怕

用 Groovy 做高频风控?内存爆炸。用 QLExpress 做复杂插件?功能受限。

但反过来:用 Groovy 做插件框架?完美。用 QLExpress 做规则引擎?稳定。

选对场景,比选对技术更重要。

4. 对线上要有敬畏之心

动态能力很诱人,但稳定性更重要。在核心链路上:宁可慢一点,也要稳;宁可功能弱一点,也要可控;宁可麻烦一点,也要可追溯。


写在最后

两年的代价,换来一个教训:不分场景对比技术,就是耍流氓。

Groovy 很好,QLExpress 也很好。但在高频核心链路用 Groovy?灾难。在复杂插件场景用 QLExpress?受限。

选对场景,比选对技术更重要。


如果这篇文章能帮你避开我们踩过的坑,这两年的代价就值了。

前两篇技术拆解:

记住:架构的艺术,不在于用了多少高级技术,而在于在合适的场景用了合适的方案。

选对了,就是神器;选错了,就是灾难。

相关推荐
踏浪无痕5 小时前
自定义 ClassLoader 动态加载:不重启就能加载新代码?
后端·面试·架构
lomocode5 小时前
改一个需求动 23 处代码?你可能踩进了这个坑
后端·设计模式
TT哇5 小时前
【每日八股】面经常考
java·面试
何中应5 小时前
【面试题-4】JVM
java·jvm·后端·面试题
Oneslide5 小时前
如何在Kubernetes搭建RabbitMQ集群 部署篇
后端
VX:Fegn08955 小时前
计算机毕业设计|基于springboot + vue非遗传承文化管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
Warren985 小时前
面试和投简历闲聊
网络·学习·docker·面试·职场和发展·eureka·ansible
测试人社区-千羽5 小时前
Apple自动化测试基础设施(XCTest/XCUITest)面试深度解析
运维·人工智能·测试工具·面试·职场和发展·自动化·开源软件
tonydf5 小时前
从零开始玩转 Microsoft Agent Framework:我的 MAF 实践之旅
后端·aigc