# 28 | Web服务业务代码一行不动,性能提升20%,怎么做到的? 你好,我是尉刚强。 在软件开发的过程中,为了保持软件系统设计的简单性,一般情况下,我们会把业务操作实现成强一致性的,而且是实时生效的。但是,在设计与实现高性能软件系统的过程中,我们其实还可以通过降低一些非关键业务操作的一致性或实时性,来调整软件设计与实现,从而换取更大的性能收益。就比方说,我们经常使用的部分Cache技术,其背后的原理就是通过降低数据的一致性,来提升软件的执行速度。 那么今天这节课,我要分享的也是一个**通过降低业务操作的一致性和实时性,来换取软件性能提升的案例。** 我会按照“优化前性能分析”“优化解决方案”“优化成果分析”的顺序来进行讲解,并带你剖析在这个过程中我是如何思考问题,以及如何根据具体业务场景和软件实现现状进行权衡的,以此来让你可以更加清楚和明白,如何去分析不同的业务操作的一致性和实时性差异和影响范围,从而设计出更加适合业务场景的Cache技术解决方案,来优化提升软件的性能。 下面,我们先来了解下这个案例的背景,一起来分析下这个软件系统优化前的性能。 ## 优化前性能分析 在互联网的Web服务中,A/B测试作为一种数据驱动产品进行优化的科学方法,应用比较广泛。其中A代表原有实现方案,B代表新的实现方案,然后通过显式地控制与调整使用方案A和方案B的用户占比,并获取观察分析数据,来评估新功能或者实现方案上线后是否有效,以及预期收益是否在合理的范围内。 由于A/B测试的机制原理是业务无关的,所以,很多编程语言中的一些第三方库已经实现了A/B测试的功能,方便我们使用。今天我介绍的Web服务性能优化项目中(基于Ruby语言开发的),也正是使用了一个A/B测试的库[Split](https://rubygems.org/gems/split)。 首先,在这个Web服务性能优化项目中,会使用监控分析工具NewRelic,来获取请求内部处理的跟踪信息。而我们通过分析跟踪获取信息后发现,有很多基于Redis的请求操作占用了比较多的处理时间,再进一步分析软件的代码实现后发现,这些Redis的请求操作主要都来自这个A/B测试的第三方库Split。 因此,为了进一步地分析A/B测试的库Split对软件性能的影响,接下来我主要做了两件事情: 1. 学习Github上Split开源库文档中的设计思路,以及这个库的主要流程的源代码,目的是分析是否有Split库使用场景不当,导致引入性能问题; 2. 梳理出这个Web服务中一次Split的调用期间,所有的Redis的API请求的详细列表,这一步主要是为了寻找是否有冗余的Redis读取操作。 其中,针对Redis的API请求列表梳理结果如下: ``` GDRedis.instance.exists "new_feature_swich" GDRedis.instance.type "new_feature_swich" GDRedis.instance.lrange "new_feature_swich",0, -1 GDRedis.instance.lrange "new_feature_swich:goals", 0, -1 GDRedis.instance.get "new_feature_swich:metadata", GDRedis.instance.hset "experiment_configurations/new_feature_swich", resettable, true GDRedis.instance.hset "experiment_configurations/new_feature_swich", :algorithm, "Split::Algorithms::WeightedSample" GDRedis.instance.hkeys "split:530c9534d48164466d000216" GDRedis.instance.exists "new_feature_swich" GDRedis.instance.hgetall "experiment_configurations/new_feature_swich" GDRedis.instance.type "new_feature_swich" GDRedis.instance.lrange "new_feature_swich",0, -1 GDRedis.instance.lrange "new_feature_swich:goals", 0, -1 GDRedis.instance.get "new_feature_swich:metadata", GDRedis.instance.hget "experiment_winner", "new_feature_swich" GDRedis.instance.hget "experiment_start_times", "new_feature_swich" GDRedis.instance.hget "experiment_winner" "new_feature_swich" GDRedis.instance.get "new_feature_swich:version" GDRedis.instance.hkeys "split:5c6b6a1377fa104b6a427c8e" GDRedis.instance.hget "experiment_start_times", "new_feature_swich" GDRedis.instance.hget "split:530c9534d48164466d000216", "new_feature_swich:5" GDRedis.instance.hset "split:530c9534d48164466d000216", "new_feature_swich:5", "disabled ``` 那么,如果仔细观察这些Redis的API请求列表,你就会发现,这个列表中有好些API请求是重复的,比如说: ``` GDRedis.instance.exists "new_feature_swich" GDRedis.instance.type "new_feature_swich" GDRedis.instance.lrange "new_feature_swich",0, -1 GDRedis.instance.get "new_feature_swich:metadata", GDRedis.instance.lrange "new_feature_swich:goals", 0, -1 ``` 这样在整体阅读完代码后,你就会明白,这个A/B测试的Split库的一次接口调用过程中,Split内部的不同模块间重复读取了Redis中的同一条数据。但其实,这是完全没有必要的,因为这是一个很明显的低效率编码实现问题。 > 补充:其实我们的软件系统引入的第三方库,因为使用方式和场景不是最佳的,很有可能会导致运行性能比较差,从而也会直接影响到软件系统的性能。 再深入分析业务代码实现,我们发现这个Web服务中每个REST请求,平均会调用这个A/B测试的Split库的接口1.5次左右,而每个Split接口实现中大概会调用Redis接口的次数在10~30之间。 这里我们根据Redis的API平均处理时延为0.5ms左右,来进行粗略估算,就可以计算出Web服务的REST请求中,使用A/B测试接口占用的处理时间开销大约为:1.5 _(10+30)/2_0.5 = 15ms左右。又因为目前这个Web服务的REST请求平均处理时延在120ms左右,所以就可以预估出A/B测试占用开销为15ms/120ms=**12%**左右。 因此,如果可以优化掉这个执行开销,那么性能收益其实是相当可观的。 好了,现在,我们已经深入了解了Split的内部原理和代码实现,以及它对业务的性能影响,那接下来,我们就可以考虑怎么来优化下这里的软件性能了。 ## 性能优化解决方案 不过,在确定优化解决方案之前,我们还需要分析下A/B测试功能在业务中的使用场景,这样才能更容易寻找到性能比较好的解决方案。 在这个Web服务中,A/B测试属于常用功能,一般情况下是按照一周的时间维度来手动配置的。所以,在理论上你可以认为A/B测试的配置是比较稳定的,不容易频繁变化。但是,在业务实现中的每个REST请求中,都会去读取和更新A/B测试的配置,因此你可以认为,A/B测试配置变更可以在ms级别后实时生效。 那么这里,我就有一个问题想问:**针对A/B测试的配置变更操作,是不是一定要在ms级实时生效呢?** 答案是,**不需要**。实际上,通过进一步分析A/B测试的业务使用场景,你会发现系统中的各个业务请求间都是相对独立的,如果获取到A/B测试的配置数据在短时间内不完全一致时,其实并不会影响到业务功能。 所以基于这个分析判断,我们可以得出一个结论:**适当地降低A/B测试配置生效的实时性和一致性,就可以给Cache技术留下比较大的空间来优化提升软件的性能**。 > 补充:可能会有极小的概率会出现,单个用户在很短时间内多个请求,页面的显示风格有些差异,但也是用户可以接受的。 那么,针对这个A/B测试的功能,现在的代码实现是**每个请求都会读取配置**。如果可以按照每个服务的进程,使用内存Cache手段来保存A/B开关配置,就可以优化掉绝大部分的A/B测试中,Redis的API占用的时间开销。 而这里的内存Cache机制,我们可以采用**基于时间失效**(即Cache保存的数据超过一定时间后自动失效)这种比较简单的实现方式,在经过业务分析后,我们得知配置失效时间在5s~10s是可以接受的。 那么现在,针对该项目的性能优化思路就已经很清晰了,也就是我们可以考虑**怎么在代码中实现内存Cache机制,来保存A/B测试配置信息**。这里有两种方式可供选择。 * 方式1:将A/B测试的配置的Cache机制直接实现在这个A/B测试库split的源码中。 * 方式2:在A/B测试库的外部再封装一层接口,来实现Cache机制,然后业务代码修改使用新的接口。 这两种实现方式我们具体该怎么选择呢? 使用方式1有一个好处是:所有的代码修改对Web服务中业务代码都完全不感知,如果出现异常时,直接更新依赖的Split库的版本即可。如果使用方式2,业务中所有调用A/B测试库的接口处都需要进行修改,由于目前业务代码中使用A/B测试接口的地方特别多,所以综合分析之后,我选择采用方式1。 这里你可能会想:在A/B测试库split的源码中直接实现内存Cache机制,不是会很复杂吗?因为它还需要引入一个内存Cache的库才行。 但其实,对于很多软件实现来说,可能你只需要加入一个HashMap数据结构,就可以实现Cache的机制了,所以,修改A/B测试功能的Split库的源代码,增加内存Cache机制的原理是比较简单的,具体原理如下图所示: ![](https://static001.geekbang.org/resource/image/3e/23/3e27d019aa1493b87e26cf15b4157d23.jpg?wh=2000x1125) 如图中所示,首先读取内存Cache中A/B开关值的时间戳,然后基于时间戳判断,这个Cache中数据是否已经过期(这里配置过期时间暂定5s)。如果没有过期的情况下,系统可以直接返回Cache中记录的A/B开关值;否则的话,就需要重新读取Redis中的A/B开关配置信息,更新到Cache中并记录更新时间戳,然后再返回新读取到的A/B开关值。 完成这一步的Cache机制的代码修改时,其实还并没有修改我们在前面的分析中所发现的低性能问题:A/B测试库Split实现中会重复读取Redis的值。那么,这个性能问题需要进行修改吗? 其实引入Cache之后,由于大量的A/B开关值都是从Cache中读取的,原来在Split库中重复读取Redis值请求的问题,对Web服务的性能影响会比较小,因此理论上你可以不用修改。 > 补充:不过我在阅读代码的过程中发现,其实只需要修改几行代码就可以解决这个问题,所以就顺带也修改了,但实际上我并不推荐这么做,因为这里修改性能收益是比较有限的。 那么到这里,我们就已经完成了这个性能优化方案的所有代码修改工作。不过现在我们还需要确认一个问题,就是在A/B测试的Split库中增加了内存级Cache优化修改后,这个Web服务的性能提升效果和预期是一致的吗? ## 优化成果分析 为了更好地支撑优化成果分析,在优化版本上线过程中,我们又使用监控工具来获取了系统的平均响应时延变化情况,具体如下图所示: ![](https://static001.geekbang.org/resource/image/4d/4f/4d8584626e7f11259315cdfd41cefa4f.jpg?wh=1152x612) 从图上你可以看到,系统的平均响应时延值降低了接近20%,远超过了性能优化分析的预估值12%。其实,这种现象是比较正常的,这是因为**减少业务中大量IO请求操作的同时,也会减少进程/线程的切换发生次数**,而这部分的性能优化提升效果是比较难进行估算分析的。 同时我们还会发现,整个软件服务器集群对Redis的API请求操作次数也降低了接近一倍,具体如下图所示: ![](https://static001.geekbang.org/resource/image/ba/30/ba444ff702e56b603fed432aaa442930.jpg?wh=1000x538) 可以发现,当系统对Redis的API操作请求次数减少之后,也就意味着,现在系统中的Redis数据库就可以服务于更大的软件服务规模。所以很多时候,**一个点上的性能优化会改善产品中多个方面的性能特征**。 ## 小结 今天我分享的这个性能优化案例,并没有去修改业务中的代码,只是修改了第三方库中少量的源代码,但是性能优化提升的效果却是非常明显的,所以在剖析完这个优化案例之后,你可以重点关注以下几个要点,来帮助支撑你的性能优化工作。 * 首先,我们业务软件的性能不仅会受制于系统内的代码实现,还会受到外部依赖库的执行性能影响; * 其次,在性能优化的过程中,最具挑战的事情可能并不在于最后的代码修改,而在于前面的解决方案设计过程中,如何深入业务场景和软件设计实现,在每一个环节都进行分析、评估、取舍,最后才确定出更优的解决方案; * 最后,就是其实在软件系统中有不少的业务操作,对实时性和一致性要求并不是非常高,你就可以采用Cache机制来优化提升软件性能。 ## 思考题 在今天分享的性能优化案例中,Cache机制中的A/B测试开关的失效时间配置为5s,那么如果这个配置得再长一些,你认为会对软件有什么影响呢? 欢迎在留言区分享你的看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。