作者介绍
去哪儿旅行基础架构组技术专家——马阳阳
TakinTalks稳定性社区专家团成员,去哪儿旅行基础架构组技术专家。公司云原生 SIG 成员,负责测试环境治理平台、代码精简平台、组件市场等,专注于研发效能领域。2022 年深度参与的“线上代码精简50%”项目获得公司级技术型一等奖,指导多个团队完成系统精简,积累了大量经验。
温馨提醒:本文约7500字,预计花费12分钟阅读。
背景
一个普遍而持久的问题:随着时间的流逝,系统中逐渐堆积起了大量的历史债务,比如被废弃的模块和无用的代码。这些"历史包袱"不只是让系统变得臃肿,也在新功能的开发或者系统维护时对开发工程师造成困扰。
我们可以看看去哪儿旅行的一组数据:每个开发人员平均需要维护6个应用,每个人负责的代码量大约是十几万行。在这些经常使用的应用中,有20%的应用在一年内都没有进行过任何变更。更令人震惊的是,线上方法行的覆盖率仅有40%,这意味着,有60%的代码是没有流量经过的,被视作无用代码。
这些数据揭示了许多问题,比如老旧系统的维护成本高昂,时间估计误差较大,开发进度缓慢,这些问题都可能影响到系统的稳定性。最终,这将导致效率和质量的双重下降。为了解决这些问题,去哪儿旅行在2022年初启动了一个名为“系统瘦身”的项目,在不影响系统运行的前提下,成功地剔除了千万行无用代码。
本文将分享如何运用可观测性技术来识别并清除无用代码。我将从服务和代码两个角度详细分享项目的实施细节,并会探讨在服务和代码精简后,对研发效能、质量和效率等指标的提升效果。期望通过本文的经验分享,能够帮助读者在系统精简方面有更深的理解和更好的实践。
一、如何制定目标和系统瘦身策略?
1.1 系统瘦身目标&挑战
“系统瘦身”项目的目标:削减代码和服务的数量均达到50%。其中,代码精简是主指标,而服务精简则是次要考虑的目标。但要实现这个目标,其难度和挑战性不可小视。
首先,目标的设定其实非常高。减少50%的代码和服务,意味着我们需要在保证系统正常运行的前提下,削去一半的代码。我相信听到这一要求,许多人都会感到难以置信。
其次,精简工作的范围广泛,涉及到各个业务线。这个项目是公司级别的项目,去哪儿旅行的每一条业务线,包括国内和国际机票、门票、酒店、火车票等,都需要完成50%的精简目标。为了达成这个目标,需要清理掉线上几千万行的代码,这个任务量是相当巨大的。
再者,风险巨大。当引入新功能时,只需考虑其是否可用。但在删除代码时,必须明确分析出这些待删除的代码是否有人在使用。因为一旦误删,可能会导致线上故障。
最后,缺乏参考经验。我们查阅了大量的国内外资料,却未能找到任何可以借鉴的经验。因此,这是一个在业内首创的项目,对技术团队提出了高技术和创新性的要求。
1.2 系统瘦身整体规划
1.2.1 时间规划
在明确目标后,我们首先进行了时间规划,将整个项目时间分为两个阶段:前两个月用于进行服务精简,随后则进行代码精简。我们选择先进行服务精简,是因为服务精简的性价比较高,一旦关闭一个服务,可能会立即减少数万至数十万行的代码。
1.2.2 组织架构规划
我们设立了一个瘦身公共支持团队。这是一个虚拟组织,主要提供通用工具和技术支持,以帮助各个业务线的团队进行高效的系统精简。在使用这些工具进行精简过程中,各业务线团队若有需求或遇到技术问题,都可以向公共支援团队寻求帮助。
1.3 选择策略
1.3.1 策略一:两阶段法
无论是服务精简还是代码精简,我们都将其分为两个阶段,即“找得到”和“删得好”。
“找得到”阶段,即寻找那些可以被删除或可以被优化的服务和代码;“删的好”阶段,即实际进行删除和优化操作。
1.3.2 策略二:四步筛选模型
我们为查找阶段抽象出了一个通用的筛选模型,包括四个步骤:挖掘特征、度量特征、收集数据和匹配特征。
挖掘特征,即分析待删除的代码或服务的特性;度量特征,即确定如何测量这些特性;收集数据则是收集与这些特性相关的数据;最后,通过收集的数据和度量算法,完成特征的匹配。该模型的第三和第四步关键在于实现自动化,让程序帮助完成这些步骤。
二、如何实现自动化且低风险的服务精简?
2.1 识别可精简的服务
根据我们的“两阶段”策略,寻找可精简的服务是第一阶段的任务。在这个过程中,首先需要确定具有精简潜力的服务。为了寻找这些特征,我们采用了两种策略:删除服务和合并服务,以达到服务精简的目标。
可删除的服务:需要识别出哪些服务是低价值的。低价值的定义有两个方面,一是该服务没有流量,这就意味着这个服务并没有产生价值;二是该服务长时间未进行更新或迭代,对于这样的服务,我们认为其增量价值为零。通过这两个特征进行筛选,然后人工评估其存量价值,如果存量价值也很低,那么这个服务就可以被删除。
可合并的服务:需要评估哪些服务可以被合并。这可能听起来有些抽象,但其实很简单,就是在没有违反微服务架构原则的前提下,将两个或多个服务合并为一个。微服务架构有很多拆分服务的原则,比如业务流程、业务重要性和各种质量指标等。如果两个服务都没有触及到这些原则,那么它们就可以被合并。不过,由于业务相关的特征很多,这个决定需要由熟悉业务的人员来做。
在确定了哪些服务可以被删除或合并后,就可以开始进行精简操作。对于合并服务的策略,需要由业务线团队手动执行。而对于删除服务的策略,则可以由公共支持团队通过自动化工具来完成。接下来的度量和删除,也将围绕着这部分可删除的服务进行。
2.2 度量特征
前面提到可删除的服务一种是不迭代,一种是没流量。
那么如何度量服务是否有流量?主要关注三种流量:南北流量,即经过网关的流量;东西流量,即服务之间的调用流量;以及服务内部的流量。我们可以通过访问日志和历史数据来确定一个服务是否有流量。如果一个服务在这三类日志中都没有相关的流量记录,那么就可以判定这个服务没有流量,进而可以被删除。
服务是否迭代,分为两部分来考虑。第一种是代码不迭代,最终表现为发布少。第二种是配置不迭代,通常来说,我们会在分布式配置中心进行配置的更改,而每次更改都会记录日志,因此可以通过分析日志来找出那些配置不再迭代的服务。
2.3 删除服务
2.3.1 建立服务精简平台
在识别出无流量且不再迭代的服务,即识别出低价值服务后,便可以进入服务精简的第二阶段,也就是删除阶段。在此阶段,关键在于如何“删得好”,即如何将删除服务的操作流程标准化。一旦流程标准化,我们就可以建立一个服务精简平台,将标准化的流程实现在平台中,从而轻松实现自动化的服务删除。
2.3.2 服务删除流程
在去哪儿旅行,我们定义了一个服务删除的标准流程,主要分为四个阶段:确认期、预收回期、观察期和回收期。
确认期是通过度量方法和数据分析找出可能需要删除的服务,并需要应用负责人进行手动确认。一旦确认,便进入待下线状态,并进入预收回期。此时,服务精简平台会扫描这些待下线的服务,然后执行下线操作。需要注意的是,下线状态意味着服务不再接收线上流量,但相关的进程和资源占用仍然存在。
在下线后,服务进入观察期,我们会让业务线的同事进行两周的观察。如果在这两周内没有任何业务问题出现,说明这个服务真的可以被下线,那么便进入最后一个阶段,即回收期。在此阶段,会真正回收那些不再使用的资源。
2.4 一些实践经验
以下是关于服务删除的一些实践经验。
三、代码精简:如何精准找到线上无流量的方法?
3.1 可精简代码特征分析
我们的策略依然是“两个阶段”——确定可以被精简的代码,然后进行代码的精简。
关于可能被精简的代码,我这里列举了三种情况——
首先,可以通过静态代码扫描找出一些未被引用的方法。例如,如果A方法依赖B方法,但A方法没有在任何地方被引用,那么A方法就有可能被精简掉。
其次,也可以通过线上运行时的状态分析,找出那些没有流量经过的方法。这些方法也是有可能被删除的。
最后,还可以通过代码重构,简化流程处理,减少代码重复,使得代码组织更清晰,这也有助于减少代码量。
在使用这三种方法时,需要评估每种方法的效果。具体来说,需要考虑两个指标:一是能够删掉的代码总量,如果这个量大,说明该方法的效果好;二是该方法的通用性,如果具备通用性,就可以通过编程自动完成代码精简,从而提高效率。
对于未被引用的方法,虽然其数量可能不大,但是具备通用性。对于没有流量的方法,其数量是非常大的,并且也具备通用性。之前提到,在去哪儿旅行,线上有60%的代码都是没有流量的。至于代码重构,其效果大小与系统有关,对于新的系统,重构可能带来的效果较小。虽然理论上,基于规则的重构是通用的,但这部分量往往很少,大部分重构都需要基于对业务的理解,结合业务进行,所以整体而言,重构并不具备完全的通用性。
因此,对于量大的两个方法——未被引用的方法和没有流量的方法,将由公共支持团队提供工具进行处理。而对于代码重构,这需要业务线团队自己去完成。
3.2 度量选型
接下来,将主要关注如何处理没有流量的方法这一问题。本节主要讨论的是Java技术栈,因为在去哪儿,90%以上的系统都是基于Java体系。
3.2.1 方案一:面向切面编程 (AOP)
在Java中,可以采用面向切面编程(AOP)的技术。可以在每个方法前添加切面,使得每次方法执行都会记录日志。然后,在线上运行一段时间后,通过扫描日志来识别哪些方法没有流量。这是一种直观的策略。
3.2.2 方案二:基于Agent的字节码插桩
另一种更底层的技术是基于Agent的字节码插桩。可以在JVM启动时添加Agent参数,进行字节码插桩。其逻辑和基于AOP的方法相似,都是为每个方法添加日志记录,然后通过分析日志来找出没有流量的方法。
3.2.3 方案三:基于Serviceability Agent (SA)工具
第三种方法是使用Serviceability Agent(SA)工具。大家都知道Java代码有两种执行方式:初始阶段代码会被解释执行,当代码热度达到一定次数或一定时间内的执行次数后,会进行编译执行。
在JVM中,每个方法的执行次数都被记录在一个字段中。通过SA工具,可以使用Java语言读取JVM中的这些数据。SA工具其实并不复杂,它可以暴露Java中的一些对象,并可以探测运行的JVM中每个方法的执行次数。其基本原理是探测JVM中的各种状态值进行分析。
那么,如何基于SA找到没有流量的方法?首先,需要对SA进行包装,可以附加到JVM进程上。然后,通过探测JVM中每个方法的调用次数,将数据保存下来。整个过程可以称为"跑数",即探测JVM中方法计数并保存结果的过程。
在跑数时,需要注意几个关键点:首先,JVM处于STW(Stop-the-World)状态,即业务线不工作;其次,跑数时长一般为1到3分钟;第三,跑数过程中需要查看内存中各个参数的情况,可能会额外消耗一些内存。
3.2.4 三种方案的比较
在选择度量的方案时,需要关注的是稳定性,最好的方案应该是对线上业务无影响。我们将三种方案根据性能损耗、故障风险和实现复杂度进行了评估。最终,选择使用SA工具。通过对SA的深度优化,可以做到对线上业务性能损耗为零,故障风险也为零。
3.3 度量方案的关键实施步骤
前文有提到,使用SA进行数据抽取时会导致STW,增加内存消耗,且需要运行两三分钟。那么,如何才能在这种情况下不影响业务呢?关键在于控制SA跑数的时机。
3.3.1 定时跑数
首先,我们考虑的是定时跑数的方案,例如每日执行一次,针对线上的某个服务进行运行。设想服务就像一堆容器或KVM,包含许多Pod,我们随机选择一个Pod进行下线,但这个下线只是暂停接收线上流量,实际上它的JVM进程仍然在运行。停止服务后,使用SA对其进行探测。此时,即使发生STW或内存增加,也无关紧要,因为它已经不再接收线上流量。探测完成后,再将Pod上线,恢复正常服务。因此,整个过程对业务没有任何影响。
但是,我们发现这个方案在实际应用中存在许多困难。首先,JVM在运行时需要有足够的剩余内存。如果剩余内存很低,例如只有一两百兆,使用SA跑数会很可能导致系统卡住,进而无法正常结束或成功跑数。因此,需要预留充足的内存。通常,内存使用量与JVM的内存使用量正相关。在SA跑数时,内存大约会增加500M到1G。
其次,需要确认服务是否有多个实例。如果只有一个实例,下线操作会导致服务直接中断。如果有两个实例,下线一个,那么它也会变成单点,这也是无法下线的。只有在服务具有两个以上的实例时,才能选择一个进行下线。
最后,还需要考虑多环境问题。在我们的系统中,每个服务可能运行在多个环境中,每个环境可能有不同的流量,但代码只有一份。这就需要针对每个环境都进行一次跑数。
3.3.2 下线时跑数
考虑到以上的困难,我们提出了一种更好的方案。不再定时跑数,而是与系统管理员的操作配合,例如我们会经常进行服务的发布、重启或下线等操作,这些操作都会进入后续的灰度流程。
在下线前,会进行SA跑数,即使此过程导致系统挂掉也无所谓,因为最终都会进行下线操作。同样,如果是发布新版本,也没关系,因为这个容器后面会拉取新的Pod。这个方案能实现对业务性能无影响、故障风险为零的效果。
然而,这个方案也有一个局限性,那就是如果一个服务非常稳定,一年都不需要重启或发布新的版本,那么就只能回到原来的策略,即定时跑数。例如,可以对所有的服务进行检查,找出那些已经运行两三个月而没有发布新版本的服务,然后给这些服务定时跑数。
3.3.3 SA跑数代码实现
在实现SA跑速的代码部分,需要根据解释执行和编译执行的不同,使用不同的API去探测它。
(跑数代码实现)
解释执行方法的代码实现很简单,分为三个步骤:实现特定的Visitor接口、根据Visit方法入参获取到Method结构、根据Method结构状态判断是否有流量。通过这些步骤,可以快速识别出没有流量的方法。
(代码实现-解释执行方法)
(代码实现-编译执行方法)
3.3.4 计算可精简方法集
通过上述代码,可以获取到一份结果,其中包含了函数的唯一标识、调用次数和代码行数等字段。然而,仅仅跑一次是不够的,我们需要进行长期的跑数,例如跑2个月的数据。在这个时间范围内,会发现一些函数始终没有被调用,这时可以确定这些函数是没有流量的。
将每次跑数的结果进行聚合并取并集,就能得到所有有流量的方法集。另外,通过静态代码分析,可以获取到工程方法全集。最后,用方法全集减去有流量的方法集,就得到了没有流量的方法集,也就是最终可以精简的方法集。
3.3.5 业务流程图
3.4 代码清理的多种手段
在代码清理阶段,我们会对那些无流量的代码进行彻底的删除。
3.4.1 全自动化清理策略
最初,我们推出了全自动的删除策略。看似理想的这种策略,具有高度自动化和高效率的特点。通过此方式,程序会自动克隆项目代码,创建分支,然后自动删除无流量的方法。完成后,程序会推送分支,然后通知人工进行代码审查(CR),最后发布。此策略的主要优势在于,前面的所有步骤都是全自动完成的,唯一需要人工进行的是代码审查和发布。
然而,实际部署这种策略后,发现很多业务团队不敢或不愿使用。原因是业务同学会觉得直接创建了一个分支,然后在代码审查平台上看到大量的代码被删除,他们无法判断被删除的代码量,也无法确定哪些代码应该被删除,哪些不应该。因此,他们会认为发布存在风险。
3.4.2 半自动清理策略
因此,后来推出了一种半自动化的策略。开发了一个Idea插件,能够扫描整个工程中所有无流量的方法。
在插件的左侧,提供了一个可以被删除的方法列表。用户可以在这个列表中进行全选,也可以按包进行选择。选择完之后,右侧提供了一些一键删除方法的功能。这时,删除流程就变成了人工通过Idea插件件进行手动删除。因为每一个方法都是他们亲手删除的,所以他们认为在删除过程中的可控力度更强,风险也更低。
3.4.3 代码清理最佳实践
总的来说,我们提供了两种不同的策略,以适应业务的重要程度。对于重要性较低的业务,可以进行全自动化的精简;对于重要性较高的业务,则进行半自动化的精简。
3.5 一些实践经验
四、最终效果如何?
- 代码量减少50%,服务减少26%;
我们设定的目标是将代码量减少50%,最终,恰好实现了这一目标,同时服务数量也减少了26%。整个项目实施周期超过半年,期间并未发生任何严重的故障。
- 故障率持续下降,维持在0.3%以下;
从数据中可以看出,故障率基本上呈现出下降的趋势。随着代码瘦身项目和其他稳定性保障项目的相继落地,当前的故障率维持在0.3%以下。
- 需求处理的平均耗时减少10.9%;发布效率提升9.5%。
我们对比了项目实施前后两个时间段的需求处理平均耗时,发现项目实施完成后,处理需求的平均时间减少了10.9%。此外,由于代码量的减少,从克隆代码到编译,再到JVM运行等每个步骤的时间都有所减少,最终导致发布效率提升了9.5%。
五、总结展望
最后,我想用一张图来做个总结。
“给我一个API,我就能减少一半的代码”。这句话主要是想引导大家进行深思,底层技术虽然可能看起来比较枯燥或者无趣,但其所能带来的价值却无比巨大。回顾整个项目,我们投入了大量的人力资源,涉及了诸多团队,最终成功减少了数千万行的线上代码。从技术角度来看,核心技术仅是使用了一个 JVM 工具的 API。然而从业务角度来看,这直接影响到了公司的服务质量、用户体验,乃至整个公司的效率和运营成本。因此,底层技术能够为上层应用提供无限的可能性,这也鼓励我们持续积极探索新的技术和方向,通过我们的专业技术尽可能地提升公司的整体效能。(全文完)
Q&A:
1、刚提到的服务下线前和下线后,通知到具体负责人?你们是有使用什么工具或者平台吗?
2、原来是通过运行时去分析函数被调用次数。你们有没有遇到一年才运行一次的定时任务吗?这种代码会被误删吧,如果采用你们这种方法。
3、业务团队没有人力的时候公共支撑团队来支援,那公共支撑团队的工作量怎么管理和控制?
4、SA技术进行跑数时,会不会存在可能影响线上服务的风险?
5、精简中有没有出现过故障的问题,都是哪些情况导致的故障?