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 建厨房
核心区别
用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?受限。
选对场景,比选对技术更重要。
如果这篇文章能帮你避开我们踩过的坑,这两年的代价就值了。
前两篇技术拆解:
记住:架构的艺术,不在于用了多少高级技术,而在于在合适的场景用了合适的方案。
选对了,就是神器;选错了,就是灾难。