2023年转转迎来了他的8周岁生日,祝贺转转8岁生日快乐。8岁的人还只是个小朋友,8岁的转转成熟稳重,而许多8岁的代码已经迟暮。
互联网公司的业务有一个特点,那就是快速迭代。许多功能的生命周期非常短暂,这带来3个问题。
直接下掉一个服务可获取最大回收收益,项目代码可删除,占用的服务器资源可回收。且经过评估,技术难度较低,短期内可获得较大收益。所以把下掉僵尸服务放在了第一步。
僵尸服务是指已经没有业务流量,却仍然占用服务器资源的服务。在转转公司,服务入口流量大致分为以下4种。
对于前4种流量我们有标准的prometheus监控,可以很容易抓取到。而第5种流量需要RD自定义监控指标,瘦身系统通过自定义的指标抓取监控。
瘦身服务每日从监控平台抓取流量监控,每月1日跑出1个月内无流量的服务,并通知服务负责人
虽然通过技术手段已经确定服务没有流量,但贸然删除服务节点及其代码仍然是不可取的,对线上服务要始终保持敬畏之心。经过仔细评估,我们制定了如下的服务下线流程。在下掉服务节点后15天内如果发现问题仍然可以随时拉起服务,终止下线流程。
删除僵尸方法的收益中等,并不能节省服务器资源,更侧重于防止项目代码腐败。技术难度中等。所以放在了第2步。
僵尸方法就是指长期没有调用的方法,如果想获取僵尸方法的集合,只需要取项目全量方法和活动方法(有调用的方法)的差集,如下图所示。
首先是采用什么技术获取全量方法,经过调研,我们采用了spoon工具扫描项目源码获取全量方法,示例代码如下。
private static void doScanJavaFile(String javaVersion, File javaFile, List<SourceCodeJavaMethod> sourceCodeJavaMethodList) {
Launcher launcher = new Launcher();
launcher.addInputResource(new FileSystemFile(javaFile));
launcher.getEnvironment().setNoClasspath(true);
launcher.getEnvironment().setAutoImports(true);
launcher.getEnvironment().setComplianceLevel(Integer.parseInt((javaVersion.contains(".") ? javaVersion.substring(2) : javaVersion)));
Collection<CtType<?>> allTypes = launcher.buildModel().getAllTypes();
for (CtType<?> type : allTypes) {
String className = type.getQualifiedName();
for (CtMethod<?> method : type.getMethods()) {
SourcePosition position = method.getPosition();
sourceCodeJavaMethodList.add(new SourceCodeJavaMethod(className, method.getSignature(), position.getEndLine() - position.getLine() + 1));
}
}
}
其次是扫描时机。
活动方法也就是在jvm运行期间调用过的方法,对活动方法的统计经过调研大致有3种实现方案。
Spring AOP
此方案要求所有需要监控的方法所在的类都是spring bean,对业务代码有侵入性,并且实现复杂度高。
java agent字节码增强
通过在jvm启动参数中加入java agent参数。对源码中的方法进行增强和监控,此方案对业务代码无侵入性,但是实现复杂度高。
ServiceAbility Agent
简称SA,是hotspot虚拟机提供的一种调试工具集,我们常用的jvm命令如jmap、jstack也是采用了该技术。在JVM中,Java代码有两种执行方式,即解释执行和编译执行。JVM会首先进行解释执行,并对解释执行的方法进行计数,超过一定的阈值后则使用jit编译器将字节码编译成本地代码。对于解释执行的方法在SA的Api中用sun.jvm.hotspot.oops.InstanceKlass
类表示,而编译执行的方法则以sun.jvm.hotspot.code.CodeBlob
类表示。只需要将ServiceAbility Agent attach至进程上,就可以从其api中获取所有的InstanceKlass
和CodeBlob
。
3种方法的对比如下:
方案 | 性能损耗 | 代码侵入性 | 实现复杂度 |
---|---|---|---|
Spring Aop | 高 | 高 | 高 |
Java Agent | 低 | 无 | 高 |
SA | 无 | 无 | 低 |
经过对比发现ServiceAblility Agent展现出无与伦比的优势。SA唯一的问题是当进程被attach后,至采集完成detach期间,整个进程处于stop the world状态,该问题在下文中有详细解决方案。
SA在各大版本间不兼容。转转线上有jdk8和jdk17,jdk8中SA以独立jar包的形式存在,位于$JAVA_HOME/lib/sa-jdi.jar,需要手动添加至classpath中,而jdk17不需要。以下为示例代码。
public class KlassVisitor implements SystemDictionary.ClassVisitor {
private List<CalledMethod> out;
public KlassVisitor(List<CalledMethod> out) {
this.out = out;
}
@Override
public void visit(Klass klass) {
if (klass instanceof InstanceKlass) {
String className = klass.getName().asString();
MethodArray methods = ((InstanceKlass) klass).getMethods();
for (int i = 0; i < methods.length(); i++) {
Method method = methods.at(i);
if (method.isNative()) {
return;
}
long invocationCount = method.getInvocationCount() >> 3;
if (invocationCount > 0) {
String name = method.getName().asString();
String signature = method.getSignature().asString();
this.out.add(new CalledMethod(className, name, signature, invocationCount));
}
}
}
}
}
public class CodeBlobVisitor implements CodeCacheVisitor {
private List<CalledMethod> out;
public CodeBlobVisitor(List<CalledMethod> out) {
this.out = out;
}
@Override
public void visit(CodeBlob codeBlob) {
if (codeBlob == null) {
return;
}
NMethod nMethodOrNull = codeBlob.asNMethodOrNull();
if (nMethodOrNull == null) {
return;
}
Method method = nMethodOrNull.getMethod();
if (method == null || method.isNative()) {
return;
}
String className = method.getMethodHolder().getName().asString();
String methodName = method.getName().asString();
String signature = method.getSignature().asString();
long invocationCount = method.getInvocationCount() >> 3;
out.add(new CalledMethod(className, methodName, signature, invocationCount));
}
}
在上文中我们总结了转转公司的4种主要流量入口有经nginx转发的http请求、RPC服务请求、MQ消费、定时任务调度。而这4种流量我们都实现了在进程不结束的情况下调用api进行流量下线的能力。
在流量下线30秒后对jvm进程进行活动方法采集,在采集后重启进程,流量自然恢复。
对于有其他特殊流量的服务,我们提供了手动调用命令进行采集的方案。可由RD自行采用其他方案下掉进程流量,如手动调用接口,通过apollo配置等。在自行下掉流量后可手动调用命令进行活动方法的采集。
虽然实现了流量下线的能力,并在流量下线30秒后进行采集,但是仍然有某些定时任务的执行时间会超过30秒。为了尽量减少对业务的影响,需要尽量避开长耗时定时任务时间。在最终实现中我们我们允许RD设置每个服务的采集时间,精确至分钟。每分钟运行一次定时任务,对配置该在该分钟内的服务进行采集。
目前转转每个服务都有1到n个子集群(一组相同启动参数节点的集合),每个子集群的功能略有差异,方法的调用也有所不同,每次采集时从所有子集群中选择1个节点进行采集。
进程的启动时间也是采集时需要考虑的因素之一,我们选择的是(启动时间-30天前的时间戳)取绝对值最小的节点。首先,刚刚启动的节点,方法还没有充分调用,不适合采集;其次启动时间过久的节点,比如1年以上的节点,也不适合采集,因为采集到活动方法可能只在1年前调用过,1年之后没再调用过。
有了全量方法和活动方法,从全量方法集合中减去活动方法集合就得到了僵尸方法。怎样删除僵尸方法也是个需要考虑的问题,大致有3种可供选择的方案。
使用程序全自动删除风险太高,而且有一定的不准确性。不准确性来源于事实上活动方法集合是包含于有用方法集合。某些用的方法可能永远也不会调用到,比如出现某种异常时的兜底方法,如果异常几十年不出现,这个兜底方法几十年都不会有调用,但是这种方法不能删除。某些调用到的方法也会采集不到,比如关闭方法,因为活动方法的采集在进程关闭之前,关闭方法暂时还未调用。
由RD到服务瘦身平台上手动查询僵尸方法,并结合业务实际情况,再决定是否删除。该方式准确性高,但是不友好。
我们开发了idea插件,由插件自动扫描出僵尸方法,再由RD结合业务实际情况决定是否删除。该方式效率高,操作友好。最终我们选择了这种方式。
该插件支持设置僵尸时间天数,自动扫描出僵尸方法,并提供快速删除方法按钮。
下掉僵尸组件依赖的收益较低,而现有监控条件不足以满足要求,需要进一步开发,复杂度较高,所以放在了最后一步。
僵尸组件依赖的发现仍然依赖promethues监控,对于某些组件如RPC、codis、rocket mq等,所用的中间件都是转转自研或者二次开发过的,已经提前在中间件中埋入了监控,直接使用现有监控数据即可。而有些组件使用的是开源中间件,无法修改源码。对于开源中间件我们使用java agent技术进行字节码增强加入监控,比如在mysql驱动中加入监控,如下图所示。
暂时没有很好的可以自动化或者半自动化下掉僵尸组件的方法,目前我们的做法是检测到僵尸组件依赖后,向服务负责人发送邮件,最终由RD修改代码来下掉僵尸组件依赖。
本文详细介绍了转转在服务瘦身方面的技术实现方案,尤其是代码瘦身部分,甚为详细。希望能对读者有所帮助,如遇技术问题可联系转转架构部。
当前该项目收获的成果如下。
关于作者
王建新,转转架构部服务治理负责人,主要负责服务治理、RPC框架、分布式调用跟踪、监控系统等。爱技术、爱学习,欢迎联系交流。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~
狂砍千万行代码,零故障!去哪儿网系统瘦身技术揭秘
代码瘦身的设计思想及技术内幕