You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

28 | Web服务业务代码一行不动性能提升20%,怎么做到的?

你好,我是尉刚强。

在软件开发的过程中为了保持软件系统设计的简单性一般情况下我们会把业务操作实现成强一致性的而且是实时生效的。但是在设计与实现高性能软件系统的过程中我们其实还可以通过降低一些非关键业务操作的一致性或实时性来调整软件设计与实现从而换取更大的性能收益。就比方说我们经常使用的部分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对软件性能的影响接下来我主要做了两件事情

  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机制的原理是比较简单的具体原理如下图所示

如图中所示首先读取内存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机制来优化提升软件性能。

思考题

在今天分享的性能优化案例中Cache机制中的A/B测试开关的失效时间配置为5s那么如果这个配置得再长一些你认为会对软件有什么影响呢

欢迎在留言区分享你的看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。