导语 | 在庞大的数据系统中,往往会有大量的计算需求。传统的方式便是直接在代码写各种计算逻辑判断,这导致了代码非常臃肿,计算维护的成本变大。所以想着编写一套DSL,定义专用的语法去实现对数据的计算,并将其独立成为底层基础服务。
一、DSL 设计
(一)何为 DSL
领域特定语言(英语:domain-specific language、DSL)指的是专注于某个应用程序领域的计算机语言。不同于普通的跨领域通用计算机语言(GPL),领域特定语言只用在某些特定的领域。
简单来说,就是利用DSL,通过抽象构建模型,抽取公共的代码,以达到提高开发效率,减少重复的劳动的目的,比如经常使用的SQL。
同样的思路,我们要将复杂的逻辑判断与计算规则抽象化,构建计算DSL。
(二)如何通用化设计计算 DSL
值得庆幸的是,办公中经常使用的Excel就包含了许多计算规则。
让我直接举一个例子来说明,比如:要计算实际支出超出预算的金额,由于超出金额不可能为负数,所以逻辑条件为:如果实际支出大于预算,则结果为实际支出减预算,反之则取0。对应的Excel计算公式为:
IF (C2 > B2, IMSUB (C2, B2), 0)(C2 代表实际支出,B2 代表预算,IMSUB 代表减法)
有了一个这么专业的例子,那么对应我们的计算DSL就是:
IF ({budget} > {actual_expenses}, IMSUB ({budget}, {actual_expenses}), 0)({}用于标示具体字段,budget、actual_expenses 代表数据库中对应的预算、实际支出字段)
(三)DSL 设计的优势
二、计算引擎的实现
(一)DSL 解析
对于这种有关键字并且无限嵌套的DSL,应该没有比堆栈更合适的方法来解析了。下面是具体例子的部分解析代码:
$dsl = 'IF({budget}>={actual_expenses},IMSUB({budget},{actual_expenses}),0,1)';
$stack = []; // 堆栈$result = []; // 结果$comparisonOperators = ['<', '>', '&', '|', '=']; // 比较运算符$placeholders = [',', '(', ')']; // 占位符for ($index = 0; $index < strlen($dsl); $index++) { $key = $dsl[$index]; switch ($key) { // 解析变量 case '}' : $variable = ''; while (true) { $item = array_pop($stack); // 出栈 if ($item === '{') { break; } $variable = $item . $variable; } $result[] = $this->getVariable($variable); // 获取真实变量值 break; // 解析方法 case '(' : $method = ''; while (true) { $item = array_pop($stack); // 出栈 if (is_null($item)) { break; } $method = $item . $method; } $result[] = $method; $result[] = $key; break; // 存储占位符,清空栈内变量(常量) case in_array($key, $placeholders) : $variable = ''; while (true) { $item = array_pop($stack); // 出栈 if (is_null($item)) { break; } $variable = $item . $variable; } $variable != '' && $result[] = $variable; $result[] = $key; break; // 解析比较运算符 case in_array($key, $comparisonOperators) : if ($dsl[$index + 1] == '=') { // 兼容 >=、<= $result[] = $key . '='; $index++; } else { $result[] = $key; } break; // 入栈 default : $stack[] = $key; break; }}
(二)数据结构化
通过DSL解析可以得到“未赋值”的结构,再根据预先存储的数据模型对变量进行赋值,我们便可以得到如下结构:
这样一来,DSL就变成了机器所能识别的数据,将参数带入到指定的函数中便能得到计算结果。
(三)递归计算
从上图的结构中,我们可以分析出:每一个计算都包含了计算函数、占位符(开始符、分割符、结束符)以及函数对应的多个参数。其中参数可以是比较运算(IF函数第一个参数必为比较运算),也可以是另一个函数。这时候我们只需要使用递归的方式去不断往下运算便能得出结果。
/** * IF 函数核心计算逻辑 */public function calculate(){ // 计算比较结果 if ($this->getComparativeResult()) { return $this->getResult($this->params[1]); // 返回真 } else { return $this->getResult($this->params[2]); // 返回假 }}/** * 获取计算结果 */public function getResult($params){ // 如果是函数,则继续计算 if (is_array($params)) { return (new Calculate($params))->calculate(); // 递归计算 } // 非函数,直接返回结果 return $params;}
(四)架构梳理
首先对输入的DSL进行校验、解析并结构化数据;然后启动多个计算引擎同时并行处理;最终输出计算结果。
三、项目接入
(一)架构设计
整体架构分为五层,上层应用层提供给具体应用接入;通讯层负责对接收应用层的数据,及对支持应用层轮询获取计算结果;DSL解析层负责DSL校验、DSL解析以及数据结构化;处理完之后再到核心计算层,进行具体的计算执行;最后再将结果入库并将结果发送到消息队列中。
其中,DSL 解析层和核心计算层共同组成计算引擎。
四、问题与思考
(一)计算提升效率缓慢
在完成项目接入后,为了提升计算效率,采用并行执行的方式来执行计算。期望的效果便是:随着并行的数量增加,效率也随之增加。
但事实总是事与愿违,即使扩大计算的并行数量也无法成倍提升计算效率,并且当并行数达到一定量时,效率提升越不明显。
(二)计算依赖
在经过仔细的问题排查之后,发现数据计算之间是有依赖关系的。让我们直接看下图的例子:当同时计算A、B、C三个字段时,不管如何并行执行,B的计算永远依赖A计算的结果;同理,C的计算也永远依赖A和B的计算结果。总而言之,就是说计算效率是有瓶颈的。
那么,如何能够用最少的资源达到整体计算的最佳效率呢?
五、解决方案:寻找最优解
(一)策略优先算法
对于每个计算字段来说,我们是知道具体依赖的程度的:
所以,我们将每个字段排了优先级,对于同一优先级的字段并行计算,依次进行,便能以最少的资源达到整体计算的最佳效率。
(二)计算速度不一致
在实际的计算中,每个字段计算的速度是不一样的。比如:在第一优先级中的A需要不断的累加才能得出结果,需要比同一优先级的D花费更长的时间。假如此时D已经计算完成,那么E其实已经不需要再依赖其他计算了,应该立即被执行。但由于第一优先级还未算完,所以只能继续等待。这样一来,对于计算结果的反馈非常的不友好。
(三)更进一步:动态策略优先算法
为了能快速的响应计算结果,我们需要在计算的同时,对计算完成的字段触发完成事件,对依赖该字段的其他优先级字段,重新分配优先级,当获得第一优先级时,立即执行。
比如:在D计算完成后,去修改E的优先级,因为E只依赖D,而D已经计算完成,所以应该获得第一优先级,立即执行。
六、总结
(一)架构完善
在动态策略优先算法的思路下,我们在原先的结构中引入策略分配层。在DSL解析之后将数据传入到策略分配层中进行策略计算;然后,依次对各个优先级的字段进行计算任务调度;在计算完成后对事件进行处理,再依次进行任务调度;最终在完成整个计算后将数据入库。
其中,DSL解析层、策略分配层和核心计算层共同组成计算引擎。
(二)下一步:引入监控
在完成了一系列的开发与项目接入工作之后。对于整个底层计算服务来说,并不是已经无懈可击了。
作者简介
林楨淵
腾讯 CDC 团队应用开发工程师
腾讯 CDC 团队应用开发工程师,毕业于广东工业大学,负责腾讯投资决策信息平台开发。致力于低代码开发平台(包括流程引擎、表单配置、计算引擎等等)的架构设计与持续优化。在投资领域开发有着丰富的落地经验。
推荐阅读
程序员如何把你关注的内容推送到你眼前?揭秘信息流推荐背后的系统设计