转转服务瘦身实战

文章目录

    • 1 背景
    • 2 第一步-发现并下掉僵尸服务
      • 2.1 如何发现僵尸服务
      • 2.2 如何下掉僵尸服务
    • 3 第二步-发现并下掉僵尸方法
      • 3.1 如何发现僵尸方法
        • 3.1.1 全量方法的获取
        • 3.1.2 活动方法的获取
        • 3.1.3 ServiceAbility Agent方案详解
          • 3.1.3.1 ServiceAbility Agent使用方法
          • 3.1.3.2 解决stop the world对业务流量的影响
          • 3.1.3.3 采集时机
          • 3.1.3.4 采集节点的选择
      • 3.2 如何删除僵尸方法
        • 3.2.1 全自动删除
        • 3.2.2 手动删除
        • 3.2.3 半自动删除
    • 4 第三步-发现并下掉僵尸组件依赖
      • 4.1 如何发现僵尸组件依赖
      • 4.2 如何下掉僵尸组件依赖
    • 5 总结与成果
    • 参考

1 背景

2023年转转迎来了他的8周岁生日,祝贺转转8岁生日快乐。8岁的人还只是个小朋友,8岁的转转成熟稳重,而许多8岁的代码已经迟暮。

互联网公司的业务有一个特点,那就是快速迭代。许多功能的生命周期非常短暂,这带来3个问题。

  1. 有些服务已经没有业务流量,却仍然占用服务器资源,称之为僵尸服务。
  2. 有些代码已经不再调用,却仍然存在于服务项目中,代码变得臃肿,难以维护、优化,称之为僵尸代码。
  3. 有些组件依赖如MySql、redis、RPC服务等,已经不再调用却仍在连接,称之为僵尸组件依赖。
    针对这3个问题,转转架构部制定了3步走计划,在下文中详细阐述。

2 第一步-发现并下掉僵尸服务

直接下掉一个服务可获取最大回收收益,项目代码可删除,占用的服务器资源可回收。且经过评估,技术难度较低,短期内可获得较大收益。所以把下掉僵尸服务放在了第一步。

2.1 如何发现僵尸服务

僵尸服务是指已经没有业务流量,却仍然占用服务器资源的服务。在转转公司,服务入口流量大致分为以下4种。

  1. 经nginx转发的http/WebSocket流量。
  2. RPC服务流量。
  3. MQ消费。
  4. 定时任务平台调度。
  5. 私有协议流量/服务内部定时任务。

对于前4种流量我们有标准的prometheus监控,可以很容易抓取到。而第5种流量需要RD自定义监控指标,瘦身系统通过自定义的指标抓取监控。
瘦身服务每日从监控平台抓取流量监控,每月1日跑出1个月内无流量的服务,并通知服务负责人转转服务瘦身实战_第1张图片

2.2 如何下掉僵尸服务

虽然通过技术手段已经确定服务没有流量,但贸然删除服务节点及其代码仍然是不可取的,对线上服务要始终保持敬畏之心。经过仔细评估,我们制定了如下的服务下线流程。在下掉服务节点后15天内如果发现问题仍然可以随时拉起服务,终止下线流程。
下线流程

3 第二步-发现并下掉僵尸方法

删除僵尸方法的收益中等,并不能节省服务器资源,更侧重于防止项目代码腐败。技术难度中等。所以放在了第2步。

僵尸方法就是指长期没有调用的方法,如果想获取僵尸方法的集合,只需要取项目全量方法和活动方法(有调用的方法)的差集,如下图所示。
转转服务瘦身实战_第2张图片

3.1 如何发现僵尸方法

3.1.1 全量方法的获取

首先是采用什么技术获取全量方法,经过调研,我们采用了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));
            }
        }
    }

其次是扫描时机。

  1. 在瘦身服务上线时对公司内所有项目源码进行一次全量扫描。
  2. 在服务每次上线完成合并代码到master后再发起一次扫描。
  3. 每周日对公司内所有项目源码进行一次兜底全量扫描。
3.1.2 活动方法的获取

活动方法也就是在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中获取所有的InstanceKlassCodeBlob

3种方法的对比如下:

方案 性能损耗 代码侵入性 实现复杂度
Spring Aop
Java Agent
SA

经过对比发现ServiceAblility Agent展现出无与伦比的优势。SA唯一的问题是当进程被attach后,至采集完成detach期间,整个进程处于stop the world状态,该问题在下文中有详细解决方案。

3.1.3 ServiceAbility Agent方案详解
3.1.3.1 ServiceAbility Agent使用方法

SA在各大版本间不兼容。转转线上有jdk8和jdk17,jdk8中SA以独立jar包的形式存在,位于$JAVA_HOME/lib/sa-jdi.jar,需要手动添加至classpath中,而jdk17不需要。以下为示例代码。

  • 获取InstanceKclass数据
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));
                }
            }
        }
    }
}
  • 获取CodeBlob数据
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));
    }
}
3.1.3.2 解决stop the world对业务流量的影响

在上文中我们总结了转转公司的4种主要流量入口有经nginx转发的http请求、RPC服务请求、MQ消费、定时任务调度。而这4种流量我们都实现了在进程不结束的情况下调用api进行流量下线的能力。

在流量下线30秒后对jvm进程进行活动方法采集,在采集后重启进程,流量自然恢复。
转转服务瘦身实战_第3张图片

对于有其他特殊流量的服务,我们提供了手动调用命令进行采集的方案。可由RD自行采用其他方案下掉进程流量,如手动调用接口,通过apollo配置等。在自行下掉流量后可手动调用命令进行活动方法的采集。

3.1.3.3 采集时机

虽然实现了流量下线的能力,并在流量下线30秒后进行采集,但是仍然有某些定时任务的执行时间会超过30秒。为了尽量减少对业务的影响,需要尽量避开长耗时定时任务时间。在最终实现中我们我们允许RD设置每个服务的采集时间,精确至分钟。每分钟运行一次定时任务,对配置该在该分钟内的服务进行采集。

3.1.3.4 采集节点的选择

目前转转每个服务都有1到n个子集群(一组相同启动参数节点的集合),每个子集群的功能略有差异,方法的调用也有所不同,每次采集时从所有子集群中选择1个节点进行采集。
进程的启动时间也是采集时需要考虑的因素之一,我们选择的是(启动时间-30天前的时间戳)取绝对值最小的节点。首先,刚刚启动的节点,方法还没有充分调用,不适合采集;其次启动时间过久的节点,比如1年以上的节点,也不适合采集,因为采集到活动方法可能只在1年前调用过,1年之后没再调用过。

3.2 如何删除僵尸方法

有了全量方法和活动方法,从全量方法集合中减去活动方法集合就得到了僵尸方法。怎样删除僵尸方法也是个需要考虑的问题,大致有3种可供选择的方案。

3.2.1 全自动删除

使用程序全自动删除风险太高,而且有一定的不准确性。不准确性来源于事实上活动方法集合是包含于有用方法集合。某些用的方法可能永远也不会调用到,比如出现某种异常时的兜底方法,如果异常几十年不出现,这个兜底方法几十年都不会有调用,但是这种方法不能删除。某些调用到的方法也会采集不到,比如关闭方法,因为活动方法的采集在进程关闭之前,关闭方法暂时还未调用。

3.2.2 手动删除

由RD到服务瘦身平台上手动查询僵尸方法,并结合业务实际情况,再决定是否删除。该方式准确性高,但是不友好。

3.2.3 半自动删除

我们开发了idea插件,由插件自动扫描出僵尸方法,再由RD结合业务实际情况决定是否删除。该方式效率高,操作友好。最终我们选择了这种方式。
该插件支持设置僵尸时间天数,自动扫描出僵尸方法,并提供快速删除方法按钮。
转转服务瘦身实战_第4张图片
转转服务瘦身实战_第5张图片

4 第三步-发现并下掉僵尸组件依赖

下掉僵尸组件依赖的收益较低,而现有监控条件不足以满足要求,需要进一步开发,复杂度较高,所以放在了最后一步。

4.1 如何发现僵尸组件依赖

僵尸组件依赖的发现仍然依赖promethues监控,对于某些组件如RPC、codis、rocket mq等,所用的中间件都是转转自研或者二次开发过的,已经提前在中间件中埋入了监控,直接使用现有监控数据即可。而有些组件使用的是开源中间件,无法修改源码。对于开源中间件我们使用java agent技术进行字节码增强加入监控,比如在mysql驱动中加入监控,如下图所示。
转转服务瘦身实战_第6张图片

4.2 如何下掉僵尸组件依赖

暂时没有很好的可以自动化或者半自动化下掉僵尸组件的方法,目前我们的做法是检测到僵尸组件依赖后,向服务负责人发送邮件,最终由RD修改代码来下掉僵尸组件依赖。

5 总结与成果

本文详细介绍了转转在服务瘦身方面的技术实现方案,尤其是代码瘦身部分,甚为详细。希望能对读者有所帮助,如遇技术问题可联系转转架构部。

当前该项目收获的成果如下。

  1. 发现僵尸服务功能上线较早,从10月1日至12月20日,共下线服务30个,实例数68个,节省内存246GB
  2. 发现僵尸代码功能上线不久,仍处于试用期,暂未有丰硕成果。但是我们统计了已接入服务的代码利用率,当前综合方法利用率仅43%,行数利用率仅50%,未来可期。
    转转服务瘦身实战_第7张图片
  3. 发现僵尸组件依赖功能刚刚上线,目前数据量较小,还不足以得出结论。

关于作者

王建新,转转架构部服务治理负责人,主要负责服务治理、RPC框架、分布式调用跟踪、监控系统等。爱技术、爱学习,欢迎联系交流。

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

参考

狂砍千万行代码,零故障!去哪儿网系统瘦身技术揭秘

代码瘦身的设计思想及技术内幕

你可能感兴趣的:(java,jvm,后端)