你好,我是尉刚强。
在软件开发的过程中,为了保持软件系统设计的简单性,一般情况下,我们会把业务操作实现成强一致性的,而且是实时生效的。但是,在设计与实现高性能软件系统的过程中,我们其实还可以通过降低一些非关键业务操作的一致性或实时性,来调整软件设计与实现,从而换取更大的性能收益。就比方说,我们经常使用的部分Cache技术,其背后的原理就是通过降低数据的一致性,来提升软件的执行速度。
那么今天这节课,我要分享的也是一个通过降低业务操作的一致性和实时性,来换取软件性能提升的案例。
我会按照“优化前性能分析”“优化解决方案”“优化成果分析”的顺序来进行讲解,并带你剖析在这个过程中我是如何思考问题,以及如何根据具体业务场景和软件实现现状进行权衡的,以此来让你可以更加清楚和明白,如何去分析不同的业务操作的一致性和实时性差异和影响范围,从而设计出更加适合业务场景的Cache技术解决方案,来优化提升软件的性能。
下面,我们先来了解下这个案例的背景,一起来分析下这个软件系统优化前的性能。
在互联网的Web服务中,A/B测试作为一种数据驱动产品进行优化的科学方法,应用比较广泛。其中A代表原有实现方案,B代表新的实现方案,然后通过显式地控制与调整使用方案A和方案B的用户占比,并获取观察分析数据,来评估新功能或者实现方案上线后是否有效,以及预期收益是否在合理的范围内。
由于A/B测试的机制原理是业务无关的,所以,很多编程语言中的一些第三方库已经实现了A/B测试的功能,方便我们使用。今天我介绍的Web服务性能优化项目中(基于Ruby语言开发的),也正是使用了一个A/B测试的库Split。
首先,在这个Web服务性能优化项目中,会使用监控分析工具NewRelic,来获取请求内部处理的跟踪信息。而我们通过分析跟踪获取信息后发现,有很多基于Redis的请求操作占用了比较多的处理时间,再进一步分析软件的代码实现后发现,这些Redis的请求操作主要都来自这个A/B测试的第三方库Split。
因此,为了进一步地分析A/B测试的库Split对软件性能的影响,接下来我主要做了两件事情:
其中,针对Redis的API请求列表梳理结果如下:
GDRedis.instance.exists "new_feature_swich"GDRedis.instance.type "new_feature_swich"GDRedis.instance.lrange "new_feature_swich",0, -1GDRedis.instance.lrange "new_feature_swich:goals", 0, -1GDRedis.instance.get "new_feature_swich:metadata",GDRedis.instance.hset "experiment_configurations/new_feature_swich", resettable, trueGDRedis.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, -1GDRedis.instance.lrange "new_feature_swich:goals", 0, -1GDRedis.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, -1GDRedis.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有一个好处是:所有的代码修改对Web服务中业务代码都完全不感知,如果出现异常时,直接更新依赖的Split库的版本即可。如果使用方式2,业务中所有调用A/B测试库的接口处都需要进行修改,由于目前业务代码中使用A/B测试接口的地方特别多,所以综合分析之后,我选择采用方式1。
这里你可能会想:在A/B测试库split的源码中直接实现内存Cache机制,不是会很复杂吗?因为它还需要引入一个内存Cache的库才行。
但其实,对于很多软件实现来说,可能你只需要加入一个HashMap数据结构,就可以实现Cache的机制了,所以,修改A/B测试功能的Split库的源代码,增加内存Cache机制的原理是比较简单的,具体原理如下图所示:
如图中所示,首先读取内存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服务的性能提升效果和预期是一致的吗?
为了更好地支撑优化成果分析,在优化版本上线过程中,我们又使用监控工具来获取了系统的平均响应时延变化情况,具体如下图所示:
从图上你可以看到,系统的平均响应时延值降低了接近20%,远超过了性能优化分析的预估值12%。其实,这种现象是比较正常的,这是因为减少业务中大量IO请求操作的同时,也会减少进程/线程的切换发生次数,而这部分的性能优化提升效果是比较难进行估算分析的。
同时我们还会发现,整个软件服务器集群对Redis的API请求操作次数也降低了接近一倍,具体如下图所示:
可以发现,当系统对Redis的API操作请求次数减少之后,也就意味着,现在系统中的Redis数据库就可以服务于更大的软件服务规模。所以很多时候,一个点上的性能优化会改善产品中多个方面的性能特征。
今天我分享的这个性能优化案例,并没有去修改业务中的代码,只是修改了第三方库中少量的源代码,但是性能优化提升的效果却是非常明显的,所以在剖析完这个优化案例之后,你可以重点关注以下几个要点,来帮助支撑你的性能优化工作。
在今天分享的性能优化案例中,Cache机制中的A/B测试开关的失效时间配置为5s,那么如果这个配置得再长一些,你认为会对软件有什么影响呢?
欢迎在留言区分享你的看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。