1 业务现状
2 调研分析
2.1 为什么选择表达式引擎
2.2 表达式引擎的对比
2.3 最终选择
3 业务应用
3.1 整体设计
3.2 表结构设计
3.3 公式运算主代码流程
3.4 编译模式下的缓存策略
3.5 业务指标迁移
4 总结
5 参考资料
随着门店结算业务的不断扩展,我们面临了日益增长的复杂性。目前,需要聚合计算的结算指标数量庞大,每个指标都依托于一套复杂的公式,而这些公式又是由众多业务配置参数构成的。业务的复杂化导致需要维护的公式数量急剧增加,带来了一系列挑战:
Apollo
配置中心以及数据库中,这种分散性使得维护工作变得繁琐且低效。基于这些问题,我们的优化方案是建立一个公式管理中心,将所有的这些指标运算进行收拢。同时引入了强大的表达式引擎来处理这些运算,本文就如何使用表达式引擎解决这些问题展开分析。
在门店结算业务的核心环节,我们专注于对关键指标的公式进行精确计算,并有效管理不同版本的公式。通过引入表达式引擎,我们能够将计算逻辑从业务代码中解耦,实现业务逻辑与计算逻辑的分离。这种方法不仅集中化了指标公式的管理,而且由于许多表达式引擎原生支持高精度的BigDecimal
类型,它还确保了金融级精度的货币计算需求得到满足。
此外,表达式引擎的动态执行特性允许我们在不重新部署的情况下实时更新公式,这样的灵活性对于快速响应业务需求变化至关重要,大大提升了业务调整的敏捷性和系统的可维护性。
本文主要对几种常见的表达式引擎AviatorScript
MVEL
QLExpress
OGNL
进行对比分析。
AviatorScript
: 是一款高性能、轻量级的Java语言实现的表达式求值引擎,Aviator可直接将表达式编译成Java字节码,交给JVM去执行。MVEL(MVFLEX Expression Language)
: 是一种动态/静态的可嵌入的表达式语言和为Java平台提供Runtime(运行时)的语言,在很大程度上受到了Java语法的启发,支持解释模式执行,也支持编译模式执行。QLExpress(Quick Language Express)
: 是阿里巴巴开源的一门动态脚本引擎解析工具,起源于阿里巴巴的电商业务,旨在解决业务规则、表达式、数学计算等动态脚本的解析问题。具有线程安全、高效执行、代码依赖小等特性。OGNL(Object-Graph Navigation Language)
: 即对象图导航语言,是一种功能强大的开源表达式语言,通过简单一致的表达式语法,可以存取对象的任意属性,调用对象的方法,遍历整个对象的结构图,并实现字段类型的转化等功能,常用于Java中。
性能测试工具使用JMH(Java Microbenchmark Harness)
,是由 OpenJDK/Oracle 官方发布的工具,他们对JIT和JVM对于基准测试影响非常了解,能得到一个更好的结果。
在当前的业务场景中,主要对带有变量和条件判断的表达式进行高精度的求值,测试表达式:(cate==101&&brand==1276)?((a*18 +b*3)*x/y)-c%3+99.64 : a*18
在本机环境下,执行五次,AviatorScript
的性能要略优于 OGNL
优于 MVEL
,前三者的性能远远优于QLExpress
。
社区活跃度主要看这几个项目在GitHub
上的 Star
、Fork
、Watch
、Last Commit
来进行分析,截止到发稿时间的对比如下:
项目 | Star | Fork | watch | Last Commit |
---|---|---|---|---|
AviatorScript | 4.4K | 821 | 171 | Jun 11, 2024 |
MVEL | 1.1K | 305 | 78 | May 16, 2024 |
OGNL | 215 | 77 | 19 | Jul 21, 2024 |
QLExpress | 4.7K | 1.1K | 215 | Jul 16, 2024 |
通过对比可以看到 AviatorScript
、MVEL
、QLExpress
的 Star、Fork、Watch 更高,说明他们的影响力更高,更受欢迎。
通过以上的对比分析,最终选择使用 MVEL
,因为在性能、社区活跃度上都有很大的优势,在语法上更加的接近Java语法,更容易上手。在一些开源项目中都有使用如:Drools
、Quartz Scheduler
、JBPM
等。MVEL
的执行流程:
每次执行都要去解析,编译,再执行表达式,这种在表达式执行比较频繁的场景下会很消耗性能。MVEL
提供了两种执行模式来应对不同的需求:
解释模式:这种模式在每次执行时都会重新编译表达式,虽然提供了动态执行的能力,但频繁的编译过程会显著影响性能。
编译模式:编译模式通过将表达式预先编译成字节码,然后在后续执行中直接运行这些字节码,从而避免了每次执行时的编译开销。这种方法显著提高了执行效率,但需要一种机制来处理在系统运行期间对公式的实时更新。
通过这两种模式,我们可以根据实际需求选择最合适的执行策略,以实现性能和灵活性的最佳平衡。
抽取出三个模块,配置中心、公式管理中心、公式运算中心。配置中心维护指标配置数据,公式中心维护指标公式,公式运算中心在前两者维护好的基础上运算获取结果。
新的指标运算流程需要两张表来存储配置数据,业务指标配置和计算公式配置。
CREATE TABLE `business_config` (
`id` bigint(20) NOT NULL DEFAULT '0' COMMENT '主键',
`indicator_key` varchar(255) NOT NULL DEFAULT '' COMMENT '指标key',
`attribute_key` varchar(255) NOT NULL DEFAULT '' COMMENT '属性key',
`attribute_desc` varchar(255) NOT NULL DEFAULT '' COMMENT '属性详细描述',
`attribute_value` varchar(255) NOT NULL DEFAULT '' COMMENT '属性值',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_by` varchar(32) NOT NULL DEFAULT '' COMMENT '创建人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`update_by` varchar(32) NOT NULL DEFAULT '' COMMENT '更新人',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_indicator` (`indicator_key`) USING BTREE COMMENT '指标唯一索引'
) ENGINE=InnoDB COMMENT='业务指标配置表';
CREATE TABLE `formula_config` (
`id` bigint NOT NULL DEFAULT 0 COMMENT '主键',
`indicator_key` varchar(255) NOT NULL DEFAULT '' COMMENT '指标key',
`formula` varchar(500) NOT NULL DEFAULT '' COMMENT '公式',
`effective_timestamp` bigint NOT NULL DEFAULT 0 COMMENT '生效的时间戳',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_by` varchar(32) NOT NULL DEFAULT '' COMMENT '创建人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`update_by` varchar(32) NOT NULL DEFAULT '' COMMENT '更新人',
PRIMARY KEY (`id`)
INDEX `idx_business_key`(`business_key`) USING BTREE
) ENGINE=InnoDB COMMENT='计算公式表';
public BigDecimal cal(String businessKey, Map<String, Object> paramMap) {
//1.根据businessKey 获取配置数据
Map<String, Object> configMap = qfConfigService.getConfigMapByBusinessKey(businessKey);
//添加业务单据参数
configMap.putAll(paramMap);
//2.根据businessKey 和时间戳获取 计算公式
String formula = getFormula(businessKey, System.currentTimeMillis());
//3.引擎计算
return MvelExecutor.evalExpression(formula, configMap);
}
所有的指标运算都复用了同一套运算逻辑,配置和公式解耦。
这里说的两者之间的解耦并不是公式一点都不关心运算需要的配置参数,而是指两者在遵守约定的前提下,在公式运算中,会根据属性配置自动填充公式的参数。
举个例子,现在有一个指标的公式为 (cate==101&&brand==1276)?26:38
在这个公式中有 cate 和 brand 两个参数,这两个参数会提前在配置中心配好,在配置表中就是 attribute_key 这个字段。attribute_key 和 attribute_value 会作为表达式运算参数的 key 和 value 参与运算。
Object object = MVEL.executeExpression(expression, paramMap);
考虑到系统的性能问题,项目中使用了编译执行模式,通过一次性编译并缓存结果,实现了多次高效运行。这就需要在系统运行过程中,对于实时改变的公式,能够及时刷新缓存,公式及时生效。由于随着业务的发展,指标越来越多,使用本地缓存,可能会造成内存占用过高,所以使用Redis
缓存编译后的公式。每次公式修改,就删除缓存,下次执行重新编译,从而确保缓存中始终存储的是最新版本的公式。
/**
* 执行表达式
**/
public BigDecimal evalExpression(String expression, Map<String, Object> map) {
Serializable cache = getCache(DesEncryptUtil.encrypt(expression));
Object object = MVEL.executeExpression(cache, map);
return (BigDecimal) object;
}
private Serializable getCache(String expression) {
String cacheExpression = redisUtils.get(expression);
if (StringUtils.isNotEmpty(cacheExpression)) {
return JsonUtil.silentString2Object(cacheExpression, Serializable.class);
}
Serializable compileExpression = MVEL.compileExpression(expression);
redisUtils.setex(expression,ONE_DAY,JsonUtil.silentObject2String(compileExpression));
return compileExpression;
}
明确了设计方案后,具体的迁移过程不是一蹴而就的,要考虑在不影响线上业务的前提下,有计划的逐步完成。迁移主要分代码逻辑迁移和配置迁移,新的迁移逻辑已经在上文的设计方案里有介绍了,不同的指标运算是一个统一的调用入口,只需要在不同的指标运算处替换即可。配置迁移主要包含指标配置迁移和公式迁移。
具体迁移过程分五步进行:
代码逻辑迁移
将指标运算逻辑替换为新逻辑。
指标配置整理入库管理
整理代码中、Apollo配置中、数据库中不同的指标配置,包括历史改变的版本,都加入配置表,以生效时间判定生效的版本。
公式整理入库管理
迁移前所有的公式都在代码中,把代码中的计算公式,同样包含历史的版本,转化为MVEL
表达式,加入公式表,以生效时间判定生效的版本。
数据准确性验证
以线上最近两个月的数据为数据源,计算迁移后指标的运算结果,与迁移前的指标运算结果作对比。如果有不一致的结果,定位原因并修复,然后重新跑数据对比,直到完全一致为止。
灰度&全量
先在2个门店开放新逻辑,先灰度几个指标,如果没有问题,就开放所有指标,最后再开放全量门店。
本文就如何在业务中使用MVEL
表达式引擎进行了分析,旨在解决当前结算系统面临的若干关键问题:
当然实际使用,还需结合具体的使用场景具体分析决定是否要使用,对于比较简单的场景,没有必要引入,这样会增加系统的复杂度,一定是系统存在痛点情况下的综合考量。
关于作者
陈炎琦,转转门店业务后端研发工程师
想了解更多转转公司的业务实践,欢迎点击关注下方公众号: