一次接口性能问题排查和优化实践

433 阅读13分钟

背景

最近一段时间受到市场行情影响,有个Java服务的Get /account/status接口的请求量很大,该接口是查询用户状态接口,用户uid在header里传参,这段时间该接口的QPS最高甚至能达到8k以上,并且在流量高峰期接口报大量502。在有一天晚上接口又一次挂了后,leader要求对该接口做性能优化,于是开始这次工作。

问题沟通

在开始接口性能优化之前,想先了解当时具体造成接口性能问题的原因,于是在次日,我找Ops沟通,查看当时生产环境服务K8s监控。

OPS沟通:

该服务生产环境K8s的HPA策略是最小5个pod最大10个pod,每个Pod的CPU内存是4C8G,CPU达到60%时开始扩容 。

查看监控可知当时接口qps在2300左右,理论上这样的资源配置下10个副本pod是可以支撑住流量的,只是扩容速度赶不上流量突增的速度,导致了接口报502。

当时流量属于突增流量,K8s副本扩容速度赶不上流量增长速度,所以原有的5个pod负荷大增,CPU负载变高,OPs直接手动快速扩容另外5个pod,并且将HPA策略改为最小10个最大20个,CPU达到30%时开始扩容。

在运维方面,需要思考如何做到流量突增情况下副本如何快速扩容,但除此之外,接口的性能优化还是要做,因为只要接口的性能上限提高了,那就意味着每个副本pod的流量承接能力就上升了,那流量突增导致的问题就能缓解。

网关日志排查:

因为想知道来自web和app的流量都有多少,所以需要查看网关的请求日志。网关日志存储在clickhouse,从接口监控可以知道流量在昨天20点15左右达到高峰,于是查看网关在20点到21点的请求日志,使用sql进行查看,发现相同一段时间内app的502请求要比web大70倍;而查询所有请求包括正常响应的请求,可以看到相同一段时间内app的所有请求要比web大接近五六十倍。询问了下网关同事,正常情况下app的请求量要比web大8到10倍,这让我觉得这个接口app的请求量要比web高得夸张很多。

app前端沟通:

因为app的请求量与web请求量的比例比正常情况下高很多,所以找app前端同事了解下这个接口的调用情况,得知在ap的账户和交易页面都存在对该接口的轮询,账户页面是1分钟1次,交易页面是5s1次。 在行情时,交易页面是最频繁使用的页面,但是理论上交易页面却无需这么频繁轮询该接口,因为更改账户状态的情况存在较少,从后台统计情况来看账户状态设置一旦生效后很少会更改。于是与PD沟通后交易页面该接口调用情况改为10s 1次

代码梳理

该接口是个Get接口,业务逻辑都是查询操作,自身代码逻辑和计算并不复杂,但却是个重网络IO接口,调用了不少第三方接口,比如api1,api2,api3,api4,并且查询了数据库和redis缓存,最终组装数据结果返回给前端。经过分析,从试图减少网络IO的角度处理:

  • 发现第三方接口api2的结果只是获取一个字段,这个返回可以放在api1,让api1返回api2的字段,这样就减少了一次接口调用
  • 从业务逻辑上来说,api3的调用是为了做前置校验,而这个前置校验随着业务的放开已经不需要,于是决定拿掉这个接口调用

接口压测

尽管更改了代码,但接口的性能仍需要通过压测来得到,比如能支撑多少QPS,还有P95响应时间。并且,压测也是为了找到接口的性能瓶颈,为了验证不同改动下的压测有啥变化,压测可能需要很多轮。为此需要做些准备:
(1)压测时服务是部署在容器化环境的,环境配置是一个Pod,CPU最小0.3C,最大3C,内存最小1G,最大4G。在这么一台配置的机器上,理想是达到八九百的QPS。
(2)QC准备了压测脚本,可以设置多少个用户请求,1s内启动多少个线程,并且压测脚本会根据响应时间调整QPS请求数,当响应时间短时QPS请求就会升高,响应时间长时QPS请求就会降低
(3)在容器化环境配置了grafana监控,在这里配置了三个监控,接口P95响应时间,接口QPS,调用依赖接口请求耗时,通过这三个监控就能直观看到压测时接口流量变化

在对优化的代码做压测前,需要先做两个压测对照组:

  • 一个是接口原始代码能支持的QPS和P95响应时间
  • 一个是接口空载情况下最高能达到的QPS和响应时间,可以理解为接口代码不做任何处理,直接return

情况一的qps只有一百多,在3C 响应时间飙到最高15s,性能可谓非常差劲了。情况二QPS达到2k多,响应时间低于1ms,毕竟是空载。

如何压测?

代码中有数据库查询,redis缓存查询,多个接口调用,可以依次mock第三方调用然后压测比对不同mock时的性能差距。当然这是比较原始的方法,也可以在代码中打点,监控不同调用的耗时。

经过不断的压测,发现在两处代码有耗时,一是调用api1,二是调用api4。api1的调用耗时的情况最为严重,是主要的性能瓶颈,api4的耗时相对来说轻一些。

  1. 针对api1的调用。这个接口根据提供方的描述是纯内存操作,本身接口性能是很高的,这点我们专门单独压测过这个接口,发现确实性能很高,所以接口调用耗时肯定不会是在该接口自身上,那只能是调用方式有问题。通过使用IDEA自带的Profile工具配合本地压测,找到了代码耗时所在

image.png
不断深入代码调用链,分析代码,找到了问题代码所在:

public Mono<void> getUserSetting(String userId) {  
    return webClientAdd(userId).get().uri(b -> b.path("/xxx/url")  
    .queryParam("uid", userId)  
    .build())  
    .retrieve()  
    .then();  
}
public WebClient webClientAdd(String userId) {  
        return webClient().mutate()  
        .defaultHeader("X-User-Id", userId)  
        .defaultHeader("X-Request-Source", "app")  
        .build();  
}

Webclient是个http请求客户端,一旦创建就是个不可变对象(Once build, immutable),因为创建Webclient是个比较耗时的操作,一般是要复用的。

而这段代码中的Webclient本来是能复用的,但调这个接口需要在header里传参数,已有的webclient对象不满足要求,从 webClient().mutate() 可以看出写代码的人为了能更方便调用在复用的webClient()后面使用了mutate方法做到在header里传参数,可能以为mutate()只是添加属性。

但实际上Webclient的mutate方法是继承已有的Webclient对象创建出一个新的对象,而这种操作会导致每次请求都创建一个新的Webclient对象,所以可想而知,当QPS升高时,创建对象的耗时也会随之升高,这就是主要的性能瓶颈所在了。

  1. 针对api4的调用,就属于接口自身的性能问题,调用的这个接口本身是返回了很多字段,但我这边服务需要的只是其中一个字段,跟接口提供方进行了沟通,提供了一个参数,只要我这边传这个参数调用时接口就会只查询返回我需要的这一个字段,这样就提供了该接口的性能

性能不稳定问题

将上面两种情况存在的问题优化之后,接口的性能表现已经改善很多。P95能达到800多ms,QPS能达到600左右。但这样的性能表现仍然是不理想的,因为这要低于3C4G的机器资源所具备的性能表现,而且压测过程中QPS和P95响应时间的表现很不稳定,Grafana监控图表线忽高忽低。

与同事讨论后觉得可能因为服务是容器化部署,在容器化环境下,一个虚拟机可以有很多个pod,这些pod会被不同项目所使用,那如果某个别的服务的pod的负载升高,占用了比较多的硬件资源,是会影响到其他pod。就好比你的百度云盘空间大小看上去有10个G,但实际上百度云并不是真的分配给你10个G不变,而是你云盘资源占用多少就申请分配了多少,直到达到上限10G。

为了解决这个问题,找了Ops沟通帮忙将服务部署在独立的容器化环境中,就是这3C4G的资源配置只给这个服务。

这个时候再压测,QPS能达到八九百,p95在600ms左右波动,并且不会再有那种Grafana监控图表忽高忽低不稳定的情况。
对比最开始QPS提升了8,9倍,P95提升了十几倍。

数据库优化

上文提到这个Get /account/status接口有去查询数据库,那自然想到能不能从数据库查询优化的角度来处理,数据库用的是mysql。先看下调用Get /account/status接口去查询数据库的两条SQL语句:

select * from table where name = 'xxx1' and uid = "userId"
select * from table where name = 'xxx2' and uid = "userId"

可以看到这两条sql语句查询的都是同张表,该表有个主键id字段,mysql会自动为该表的id字段创建主键索引,但这两条sql都没有用到id字段,所以查询方式会是全表扫描。根据where的查询条件,我们可以创建一个name和uid组成的联合索引,sql语句也符合联合索引的最左匹配原则,这样就不再是全表扫描,而是走了索引。

其次根据业务逻辑我们还可以将select * 改成select name, uid,这用到了mysql的一个索引覆盖特性,可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少去主键索引查询全部数据的回表次数。所以最终sql优化改成这样:

select name, uid from table where name = 'xxx1' and uid = "userId"
select name, uid from table where name = 'xxx2' and uid = "userId"

数据库表的数据量越大,这种优化的效果就会越明显。

Redis缓存优化

请求接口时会去查询redis,这就相当于多了一次网络IO,增加了约十几毫秒的IO时间。我们可以在服务启动时将redis缓存同步到本地,然后在接口请求时去本地缓存查询,本地缓存查询的耗时相比比redis缓存查询的耗时可以忽略不计,这样就可以减少redis查询的网络IO耗时。

但这种方式最终我没有去用,因为这对代码改动较大,需要考虑到redis缓存和本地缓存的一致性,涉及到一致性往往都会是比较复杂的操作,需要考虑到各种情况,比如启动个定时任务同步redis缓存和本地缓存数据,针对失败的各种异常处理等等。

依赖接口做成数据离线

从上文知道Get /account/status接口依赖了很多第三方接口,虽然解决了Get /account/status接口自身的性能问题,但如果所依赖的第三方接口也存在性能问题的话就有点无能为力了。比如假设第三方依赖接口api4存在性能不稳定问题,当请求量大时调这个接口会报503,就会导致Get /account/status接口也报错。

api4是个获取数据的Get接口,那我们可不可以设法将这个接口解耦出来。下面说明下这种做法:
(1)先将api4接口返回数据放在本地json文件里,这点视情况而定,如果数据量大则放在本地json文件里,如果数据量小,可以用一个List或者Map结构的容器来存,相当于放在本地内存里。
(2)然后接口代码逻辑想获取api4接口数据时,就去json文件里获取,而不用每次都去调api4接口
(3)同时,为了保证数据一致性,需要起一个定时任务,比如5分钟一次去调api4接口把数据同步更新在本地json文件,这个定时任务要做好异常处理和错误日志,当api4接口调用出现问题时有错误日志可以打印出来

这种方案相当于将接口自身逻辑与api4接口的调用解耦开来,避免了当api4接口挂了时也导致Get /account/status接口也跟着挂。当api4接口挂了时顶多导致数据没有更新,但总比Get /account/status接口挂了要好,毕竟这是个基础接口,一旦挂了影响面是很广的,这是一种取舍。

接口逻辑拆分

这个方面的优化就要从接口逻辑进行梳理了,然后决定是否可以拆分了。举个例子,比如Get /account/status接口主要是返回账户状态,但除了账户状态外还多返回了其他额外字段,比如是否能切换到状态1,和状态2,但实际上大多数请求调用都是为了获取状态字段,这些额外的字段只有少数其他特殊业务的调用会关心,为了这几个额外字段的获取而去多调用其他接口导致耦合严重,这样就很得不偿失了。

为此,可以将接口拆分出来,一个接口返回账户状态字段,一个接口返回这些额外字段。

最后总结

可以看到,上面接口性能优化经历了比较长的过程,但主要可以从三个方面来看:

  • 在硬件部署方面,就是使用硬件资源来堆高接口性能了,包括内存,CPU,还有副本数量
  • 在代码方面,可以通过压测找到性能瓶颈;梳理代码逻辑,决定是否能对接口进行拆分;判断接口是CPU密集型接口还是IO密集型,能不能进行调用解耦
  • 在中间件方面,数据库SQL语句能不能优化,Redis自身是否可以读写分离,对Redis的调用方式是不是一定要同步调用