解鎖轉(zhuǎn)轉(zhuǎn)門店業(yè)務(wù)靈活性:如何利用MVEL引擎優(yōu)化結(jié)算流程
1 業(yè)務(wù)現(xiàn)狀
隨著門店結(jié)算業(yè)務(wù)的不斷擴(kuò)展,我們面臨了日益增長的復(fù)雜性。目前,需要聚合計算的結(jié)算指標(biāo)數(shù)量龐大,每個指標(biāo)都依托于一套復(fù)雜的公式,而這些公式又是由眾多業(yè)務(wù)配置參數(shù)構(gòu)成的。業(yè)務(wù)的復(fù)雜化導(dǎo)致需要維護(hù)的公式數(shù)量急劇增加,帶來了一系列挑戰(zhàn):
- 配置分散問題:業(yè)務(wù)配置目前分散在代碼、
Apollo
配置中心以及數(shù)據(jù)庫中,這種分散性使得維護(hù)工作變得繁瑣且低效。 - 頻繁更新問題:隨著業(yè)務(wù)的不斷調(diào)整,結(jié)算公式需要頻繁更新。每一次微小的改動都要求進(jìn)行系統(tǒng)上線,這增加了開發(fā)的負(fù)擔(dān)。
- 代碼維護(hù)問題:每次新公式的上線,都需要保留舊版本的指標(biāo)公式。這導(dǎo)致在代碼中需要同時維護(hù)多套指標(biāo)公式,嚴(yán)重影響了代碼的可讀性和可維護(hù)性。
指標(biāo)計算流程
基于這些問題,我們的優(yōu)化方案是建立一個公式管理中心,將所有的這些指標(biāo)運(yùn)算進(jìn)行收攏。同時引入了強(qiáng)大的表達(dá)式引擎來處理這些運(yùn)算,本文就如何使用表達(dá)式引擎解決這些問題展開分析。
2 調(diào)研分析
2.1 為什么選擇表達(dá)式引擎
在門店結(jié)算業(yè)務(wù)的核心環(huán)節(jié),我們專注于對關(guān)鍵指標(biāo)的公式進(jìn)行精確計算,并有效管理不同版本的公式。通過引入表達(dá)式引擎,我們能夠?qū)⒂嬎氵壿嫃臉I(yè)務(wù)代碼中解耦,實(shí)現(xiàn)業(yè)務(wù)邏輯與計算邏輯的分離。這種方法不僅集中化了指標(biāo)公式的管理,而且由于許多表達(dá)式引擎原生支持高精度的BigDecimal
類型,它還確保了金融級精度的貨幣計算需求得到滿足。
此外,表達(dá)式引擎的動態(tài)執(zhí)行特性允許我們在不重新部署的情況下實(shí)時更新公式,這樣的靈活性對于快速響應(yīng)業(yè)務(wù)需求變化至關(guān)重要,大大提升了業(yè)務(wù)調(diào)整的敏捷性和系統(tǒng)的可維護(hù)性。
2.2 表達(dá)式引擎的對比
本文主要對幾種常見的表達(dá)式引擎AviatorScript
MVEL
QLExpress
OGNL
進(jìn)行對比分析。
2.2.1 簡介
AviatorScript
: 是一款高性能、輕量級的Java語言實(shí)現(xiàn)的表達(dá)式求值引擎,Aviator可直接將表達(dá)式編譯成Java字節(jié)碼,交給JVM去執(zhí)行。MVEL(MVFLEX Expression Language)
: 是一種動態(tài)/靜態(tài)的可嵌入的表達(dá)式語言和為Java平臺提供Runtime(運(yùn)行時)的語言,在很大程度上受到了Java語法的啟發(fā),支持解釋模式執(zhí)行,也支持編譯模式執(zhí)行。QLExpress(Quick Language Express)
: 是阿里巴巴開源的一門動態(tài)腳本引擎解析工具,起源于阿里巴巴的電商業(yè)務(wù),旨在解決業(yè)務(wù)規(guī)則、表達(dá)式、數(shù)學(xué)計算等動態(tài)腳本的解析問題。具有線程安全、高效執(zhí)行、代碼依賴小等特性。OGNL(Object-Graph Navigation Language)
: 即對象圖導(dǎo)航語言,是一種功能強(qiáng)大的開源表達(dá)式語言,通過簡單一致的表達(dá)式語法,可以存取對象的任意屬性,調(diào)用對象的方法,遍歷整個對象的結(jié)構(gòu)圖,并實(shí)現(xiàn)字段類型的轉(zhuǎn)化等功能,常用于Java中。
2.2.2 性能分析
性能測試工具使用JMH(Java Microbenchmark Harness)
,是由 OpenJDK/Oracle 官方發(fā)布的工具,他們對JIT和JVM對于基準(zhǔn)測試影響非常了解,能得到一個更好的結(jié)果。
在當(dāng)前的業(yè)務(wù)場景中,主要對帶有變量和條件判斷的表達(dá)式進(jìn)行高精度的求值,測試表達(dá)式:(cate==101&&brand==1276)?((a*18 +b*3)*x/y)-c%3+99.64 : a*18
在本機(jī)環(huán)境下,執(zhí)行五次,AviatorScript
的性能要略優(yōu)于 OGNL
優(yōu)于 MVEL
,前三者的性能遠(yuǎn)遠(yuǎn)優(yōu)于QLExpress
。
2.2.3 社區(qū)活躍度
社區(qū)活躍度主要看這幾個項目在GitHub
上的 Star
、Fork
、Watch
、Last Commit
來進(jìn)行分析,截止到發(fā)稿時間的對比如下:
項目 | 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 更高,說明他們的影響力更高,更受歡迎。
2.3 最終選擇
通過以上的對比分析,最終選擇使用 MVEL
,因?yàn)樵谛阅?、社區(qū)活躍度上都有很大的優(yōu)勢,在語法上更加的接近Java語法,更容易上手。在一些開源項目中都有使用如:Drools
、Quartz Scheduler
、JBPM
等。MVEL
的執(zhí)行流程:
MVEL執(zhí)行流程
每次執(zhí)行都要去解析,編譯,再執(zhí)行表達(dá)式,這種在表達(dá)式執(zhí)行比較頻繁的場景下會很消耗性能。MVEL
提供了兩種執(zhí)行模式來應(yīng)對不同的需求:
解釋模式:這種模式在每次執(zhí)行時都會重新編譯表達(dá)式,雖然提供了動態(tài)執(zhí)行的能力,但頻繁的編譯過程會顯著影響性能。
編譯模式:編譯模式通過將表達(dá)式預(yù)先編譯成字節(jié)碼,然后在后續(xù)執(zhí)行中直接運(yùn)行這些字節(jié)碼,從而避免了每次執(zhí)行時的編譯開銷。這種方法顯著提高了執(zhí)行效率,但需要一種機(jī)制來處理在系統(tǒng)運(yùn)行期間對公式的實(shí)時更新。
通過這兩種模式,我們可以根據(jù)實(shí)際需求選擇最合適的執(zhí)行策略,以實(shí)現(xiàn)性能和靈活性的最佳平衡。
3 業(yè)務(wù)應(yīng)用
3.1 整體設(shè)計
抽取出三個模塊,配置中心、公式管理中心、公式運(yùn)算中心。配置中心維護(hù)指標(biāo)配置數(shù)據(jù),公式中心維護(hù)指標(biāo)公式,公式運(yùn)算中心在前兩者維護(hù)好的基礎(chǔ)上運(yùn)算獲取結(jié)果。
新的指標(biāo)運(yùn)算流程
3.2 表結(jié)構(gòu)設(shè)計
需要兩張表來存儲配置數(shù)據(jù),業(yè)務(wù)指標(biāo)配置和計算公式配置。
CREATE TABLE `business_config` (
`id` bigint(20) NOT NULL DEFAULT '0' COMMENT '主鍵',
`indicator_key` varchar(255) NOT NULL DEFAULT '' COMMENT '指標(biāo)key',
`attribute_key` varchar(255) NOT NULL DEFAULT '' COMMENT '屬性key',
`attribute_desc` varchar(255) NOT NULL DEFAULT '' COMMENT '屬性詳細(xì)描述',
`attribute_value` varchar(255) NOT NULL DEFAULT '' COMMENT '屬性值',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創(chuàng)建時間',
`create_by` varchar(32) NOT NULL DEFAULT '' COMMENT '創(chuàng)建人',
`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 '指標(biāo)唯一索引'
) ENGINE=InnoDB COMMENT='業(yè)務(wù)指標(biāo)配置表';
CREATE TABLE `formula_config` (
`id` bigint NOT NULL DEFAULT 0 COMMENT '主鍵',
`indicator_key` varchar(255) NOT NULL DEFAULT '' COMMENT '指標(biāo)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 '創(chuàng)建時間',
`create_by` varchar(32) NOT NULL DEFAULT '' COMMENT '創(chuàng)建人',
`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='計算公式表';
3.3 公式運(yùn)算主代碼流程
public BigDecimal cal(String businessKey, Map<String, Object> paramMap) {
//1.根據(jù)businessKey 獲取配置數(shù)據(jù)
Map<String, Object> configMap = qfConfigService.getConfigMapByBusinessKey(businessKey);
//添加業(yè)務(wù)單據(jù)參數(shù)
configMap.putAll(paramMap);
//2.根據(jù)businessKey 和時間戳獲取 計算公式
String formula = getFormula(businessKey, System.currentTimeMillis());
//3.引擎計算
return MvelExecutor.evalExpression(formula, configMap);
}
所有的指標(biāo)運(yùn)算都復(fù)用了同一套運(yùn)算邏輯,配置和公式解耦。
這里說的兩者之間的解耦并不是公式一點(diǎn)都不關(guān)心運(yùn)算需要的配置參數(shù),而是指兩者在遵守約定的前提下,在公式運(yùn)算中,會根據(jù)屬性配置自動填充公式的參數(shù)。
舉個例子,現(xiàn)在有一個指標(biāo)的公式為 (cate==101&&brand==1276)?26:38
在這個公式中有 cate 和 brand 兩個參數(shù),這兩個參數(shù)會提前在配置中心配好,在配置表中就是 attribute_key
這個字段。attribute_key 和 attribute_value 會作為表達(dá)式運(yùn)算參數(shù)的 key 和 value 參與運(yùn)算。
Object object = MVEL.executeExpression(expression, paramMap);
3.4 編譯模式下的緩存策略
考慮到系統(tǒng)的性能問題,項目中使用了編譯執(zhí)行模式,通過一次性編譯并緩存結(jié)果,實(shí)現(xiàn)了多次高效運(yùn)行。這就需要在系統(tǒng)運(yùn)行過程中,對于實(shí)時改變的公式,能夠及時刷新緩存,公式及時生效。由于隨著業(yè)務(wù)的發(fā)展,指標(biāo)越來越多,使用本地緩存,可能會造成內(nèi)存占用過高,所以使用Redis
緩存編譯后的公式。每次公式修改,就刪除緩存,下次執(zhí)行重新編譯,從而確保緩存中始終存儲的是最新版本的公式。
/**
* 執(zhí)行表達(dá)式
**/
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;
}
3.5 業(yè)務(wù)指標(biāo)遷移
明確了設(shè)計方案后,具體的遷移過程不是一蹴而就的,要考慮在不影響線上業(yè)務(wù)的前提下,有計劃的逐步完成。遷移主要分代碼邏輯遷移和配置遷移,新的遷移邏輯已經(jīng)在上文的設(shè)計方案里有介紹了,不同的指標(biāo)運(yùn)算是一個統(tǒng)一的調(diào)用入口,只需要在不同的指標(biāo)運(yùn)算處替換即可。配置遷移主要包含指標(biāo)配置遷移和公式遷移。
具體遷移過程分五步進(jìn)行:
- 代碼邏輯遷移
將指標(biāo)運(yùn)算邏輯替換為新邏輯。 - 指標(biāo)配置整理入庫管理
整理代碼中、Apollo配置中、數(shù)據(jù)庫中不同的指標(biāo)配置,包括歷史改變的版本,都加入配置表,以生效時間判定生效的版本。 - 公式整理入庫管理
遷移前所有的公式都在代碼中,把代碼中的計算公式,同樣包含歷史的版本,轉(zhuǎn)化為MVEL
表達(dá)式,加入公式表,以生效時間判定生效的版本。 - 數(shù)據(jù)準(zhǔn)確性驗(yàn)證
以線上最近兩個月的數(shù)據(jù)為數(shù)據(jù)源,計算遷移后指標(biāo)的運(yùn)算結(jié)果,與遷移前的指標(biāo)運(yùn)算結(jié)果作對比。如果有不一致的結(jié)果,定位原因并修復(fù),然后重新跑數(shù)據(jù)對比,直到完全一致為止。 - 灰度&全量
先在2個門店開放新邏輯,先灰度幾個指標(biāo),如果沒有問題,就開放所有指標(biāo),最后再開放全量門店。
4 總結(jié)
本文就如何在業(yè)務(wù)中使用MVEL
表達(dá)式引擎進(jìn)行了分析,旨在解決當(dāng)前結(jié)算系統(tǒng)面臨的若干關(guān)鍵問題:
- 集中管理配置:通過建立一個公式管理中心,實(shí)現(xiàn)配置的統(tǒng)一管理,簡化維護(hù)流程。
- 即時生效的公式修改:對公式很小的改動,直接修改公式立即生效,無需代碼上線。
- 降低代碼復(fù)雜度:通過將公式的歷史版本存儲在數(shù)據(jù)庫中,并根據(jù)時間戳獲取當(dāng)前生效的公式,減少了代碼中多套公式的維護(hù)負(fù)擔(dān)。
當(dāng)然實(shí)際使用,還需結(jié)合具體的使用場景具體分析決定是否要使用,對于比較簡單的場景,沒有必要引入,這樣會增加系統(tǒng)的復(fù)雜度,一定是系統(tǒng)存在痛點(diǎn)情況下的綜合考量。