(先给个预告,下一期关于Flink的文章会讲如何将机器学习融入Flink中)
摘要
本文提供了一种在流计算中不停机动态加载代码来做到敏捷而快速的开发的思路。
代码提供在 Lofka 的 lofka-night-watcher 模块中。
TsingJyujing/lofkagithub.com

目前利用JavaScript(仅支持ECMA5的语法)编写的动态脚本可以支持:
- HTTP读写
- RedisCluster操作
- 所有有JDBC Driver的SQL的操作
- MongoDB的操作
- 导入第三方的库
基本上可以完成大部分的不定需求的快速开发。
前言:什么是配置
之前拜读了一下这篇文章:
一篇好TM长的关于配置中心的文章jm.taobao.org

,在 Dubbo Meetup 时有幸听过作者本人的演讲。
其中有一个基本的概念:一个大型的,分布式的系统,停下来不是那么容易的。所以不太可能停下来修改配置再发布,我们需要在运行中控制软件行为,这个时候我们就需要配置中心。
正如文章所说:
映射到软件领域上,我们总是需要对系统的某些功能特性预留出一些控制的线头,以便我们在未来需要的时候,可以人为的拨弄这些线头从而控制系统的行为特征,我把它叫做 “系统运行时(runtime)飞行姿态的动态调整“。
最近正好着手公司的整个流计算框架的重构,其实流计算和大型分布式系统(虽然我们做的流计算本身已经是分布式的了)有着异曲同工之妙——很TM不好停。
一个流计算程序,动辄运行几周,乃至几个月,停下来以后,只能从上一次的Checkpoint的状态恢复,好在我们的确保后续操作都做到了幂等,可以用At Least Once,如要做Exactly Once的消费,又会徒增很多工作量。
不能停机的时候,动态的调整软件的行为就显得很重要。
举几个例子:
- 围栏报警,在车辆出入围栏的时候进行报警,围栏有可能是用户随时画出来的,如何即时生效?
- 报警有的需要短信,有的需要邮件,如何做?
- 某天增加了一个接口用于APP内推送,如何推送?如何做到不停止流计算的情况下增加这个功能?
- 数据库地址变更,能否在不停止的情况下切换?
- 报警X太多了,导致短信太多,决定下线这个报警类型,如何做?
以上问题都不难,但是如果想要减少停机的影响就变得比较麻烦。 除了这些问题,一些监控指标参数的变更,几个参数的联动逻辑,往往不是能一次性确定好的。
更加可气的是,有些产品经理脑子瓦特了,刚提的需求过半个小时就改,真想打到他提肛。 除了红烧产品经理以外,我们也必需有个方法应对不明确、且必须要做的需求。
我们必须搞出一个解决方案,应对脑子坏掉的自己、领导和产品。
我们现在的问题
对于上面五个问题,我们可能会考虑:
- 轮询数据库(可能加一个Cache防止读写过于频繁),检查每个用户的围栏,计算后将结果吐出去来解决问题1。
- 仍然是读取数据库的配置,判断如何推送解决2。
- 改代码重新发布解决3。
- 用一个线程监听配置中心上的配置,数据库地址变化的时候锁住不让读写,然后把Connection(或者Druid)完成功能4。
- 改代码重新发布解决5。
当然,上面的方案并不完美,在流计算的开发与维护中,只有20%是涉及结构的变化,大部分的变化都是细节的逻辑变化,或者在原来的基础上做一些增减,原来放到Redis的数据现在要放到MongoDB,而后除了要写MongoDB还需要做一些其他操作:例如推送部分特殊的消息到X接口。
往往是随着业务的不断扩展,需求也变得越来越复杂。 这不是一件坏事(完全没业务才是坏事),但是慢慢的干净的流计算代码就开始腐化。出现且不限于以下情况:
- 基础库的冲突,你用Protobuf 2.5,而我是3.0起步这样的事情
- 业务堆砌,代码成坨,很多业务刚上线需求就变化了,于是又新建了一个类实现了某个接口……
- 出于稳定性,流计算不太好停机,所以需求通常是攒了一堆再发布,发布严重滞后,这对很多以快打慢的公司是个致命问题。
最后忍无可忍,通过复制和剪切代码将一部分业务单独剥离了出去,集群中开始跑起了第二个流计算服务,Kafka的消费率也从100%变成了200%。
这只是噩梦的开始。
一些基础的部分(如反序列化)可能用的是同一套代码,一套修改以后,另一套也要修改,有远见的人可能已经将公共部分抽取出来放到了私有的Maven上,当然这不能避免事情变得更加糟糕——终于有一天流计算的框架或者是数据流的拓扑也需要修改了,那带来的代价是巨大的。
我们不仅要更新业务,还需要更新逻辑。
一个实用且不完美的解决方案
我这里会给出一个解决方案,说来也很简单,就是用 Nashorn 解释器动态加载执行 JavaScript 脚本来剥离业务逻辑,并且构建代码的自动重新加载机制,使得流计算在不停机的情况下迅速切换逻辑。
对于数据源较为单一的数据处理,我们可以将流计算抽象为:
Source[T]-->FlatMap(List[Function[T->Unit]])
也就是用一堆的函数去消费某个流中的每一个元素,并且做某些操作。 这个时候,如果要入库或者调接口怎么办呢,我们使用 Java 和 JavaScript 来联合做一些操作数据库和Http的库,就可以完成了。
但是这个方案不是完美的,对于网络拓扑的变更来说就有点问题,这个实在不能动态加载,不过这样的需求变化相对来说很少了。
需要说的是,很多大厂(点名阿里巴巴和携程),都喜欢使用 SQL on Stream,但是 SQL 仅仅适合数据源比较单一的地方,比如UV的计算,如果想要联动一些其他的信息,或者有着比较复杂的业务逻辑,这个时候再使用SQL就会显得比较繁琐,这也是我们在这里放弃SQL(当然不是完全放弃)的原因之一。
实现
大致设计
我们采用了Flink作为流计算的框架,Flink的加载机制是:
生成DAG -> 序列化 -> 分发到各个节点 -> 加载DAG(这个时候会运行一些Rich的部分)-> 运行
而我们相当于在DAG中定义了一个动态的Node,所有的数据流到Node中来,然后再根据当前的加载情况去分发数据。
于是我们采用反复读取URL,对比有无改变来决定是否重新加载JavaScript引擎。
具体细节
我们已经在公司的实时上报流数据处理中使用这种方案来保持良好的动态性,效果相当不错。 将一些代码修改以后,应用在了我们开源的日志处理系统上:
TsingJyujing/lofkagithub.com

日志进入流计算之后,我们除了要对其做一些统计,偶尔还需要联动一些特殊的报警,这些联动往往需求来的突然。
例如今天A的ERROR需要实时汇报给某几个人,明天开始B的WARN以上级别日志也需要汇报给XXX……
重复的发布Flink的程序显然不是明智之举,同时也不适合将太多的内部逻辑公开到开源项目中,这会造成买二手手机号代码的臃肿,而且过于个性化,这也是这一部分之前迟迟没有开源的原因。
引擎池设计
由于引擎不是一个可以序列化的东西,所以我们做了一个引擎池单例。
引擎池的本质是一个静态的Map变量,其Key为URL,其Value为读取URL中的JS代码而创建的JavaScript引擎。
使用URL作为引擎的Key,也就是说,对于同一个URL,最后会调用到同一个引擎,这样每个Java虚拟机中都只有一个引擎池,可以大大节约资源。
这个Map里有哪些URL也是动态指定的,如果我们因为业务原因需要下线或者上线某些代码逻辑,在准备好JS脚本以后,只需要修改配置文件,加上该JS脚本的URL,在定期读取的配置文件的时候会自动检测到新的URL并且启动一个新的引擎。
原则上,每一个流的消费都需要指定一个配置文件,来加载专门针对这个流的所有脚本,如果你有多个流需要消费(例如你不仅需要消费单条日志,还需要消费每10秒的统计结果),那么就应该使用多个配置文件。
引擎选型
我们最终选了 Java 8 自带的 Nashorn虚拟机,这样就能有更好的可移植性,一处编译,到处运行(调试)。 JavaScript自带的正则工具和丰富的数据类型(呸,连整数都没有)可以帮助我们方便的描述业务逻辑。 但是Nashorn的问题是,不能使用ECMA6的语法,例如=>匿名函数,Class,等等各种新特性都不能用了。
不过大丈夫萌大奶,毕竟是JavaScript,所以我们可以选择 TypeScript/Scala/Kotlin/Python 来生成 JavaScript,甚至可以用 Babel 将ECMA6的脚本转换为ECMA5。
防止消息丢失
每一次重新加载引擎都会先加锁,锁住引擎,阻止消费,这样就做到了不丢任意一条消息,不过目前还不能做到精确到毫秒级别的切换,例如今天15:00前使用逻辑A,15:00以后使用逻辑B……
案例
下面举几个例子,
异常日志通知
收到某些ERROR日志的时候,对其进行一个筛选,如果是需要重点关注的(或者是不需要忽略的),就将详情以短信的形式(通过调用Http接口)通知。
// 使用默认的open和close(也就是啥也不做)
FILTERS = {
"big_data_alarms": {
"filter_func": function (log_data) {
// 这里编写日志过滤器
return log_data.startsWith("cvnavi/bigdata/online") && log_data["level"]==="ERROR";
},
// 设置需要通知的方式
"notice_info": USER_GROUP["big_data"].map(function (user) {
return {
"user_id": user_id,
"method": NOTIFY_CODE.mail
}
})
}
};
/**
* 用户ID表
*/
USER_ID_MAP = {
"zhangsan": "ef40000cced",
"lisi": "efd1032a80"
};
/**
* 用户组定义
*/
USER_GROUP = {
"big_data": [
"zhangsan",
"lisi"
],
"backend": [
"zhangsan"
]
};
/**
* 通知编码
*/
NOTIFY_CODE = {
"tel": "10001",
"sms": "10002",
"mail": "10003",
"dingding": "11001",
"wechat": "11002"
};
/**
* 数据处理
* @param str_data 日志JSON文本数据
*/
function processing_data(str_data) {
var log_data = JSON.stringify(str_data);
FILTERS.forEach(function (f) {
if (f.filter_func(log_data)) {
f.notice_info.forEach(function (notice) {
notify_people(notice.user_id, log_data, notice.method);
})
}
});
}
/**
* 调用内部通信的API通知相应的
* @param user_id 用户ID
* @param log_data 日志数据
* @param notify_method 通知方法
*/
function notify_people(user_id, log_data, notify_method) {
var post_data = {
"method": notify_method,
"data": JSON.stringify(log_data, null, 2),
"user_id": user_id
};
$.post(
"http://172.16.9.179:8080/api/notify/",
post_data
, function (resp_str) {
var response_data = JSON.parse(resp_str);
if (response_data["status"] !== 200) {
// 注意这里不能对这条日志联动
// 否则可能自激振荡
console.err("Error while notifying: " + JSON.stringify(post_data))
} else {
console.log("Notify successfully:" + JSON.stringify(post_data))
}
}
)
}
总结
通过合理利用 Java 8 的 Nashorn 引擎,我们做到了在流计算中动态的变更逻辑,可以完成小时乃至分钟级别的开发,在测试环境内快速上线,避免不稳定的需求带来的高修改成本。
在我企业的实践中应用效果较好,也希望能对你有用。
本文介绍了一种在流计算中动态加载JavaScript脚本的方法,实现了敏捷开发与快速响应需求变化的目标。通过Nashorn引擎在Flink框架下实现代码的不停机更新。

被折叠的 条评论
为什么被折叠?



