Spring Cloud Gateway 雪崩了,我 TM 人傻了

本系列是我TM人傻了系列第六期[捂脸],往期精彩回顾:

升级到Spring5.3.x之后,GC次数急剧增加,我TM人傻了

这个大表走索引字段查询的SQL怎么就成全扫描了,我TM人傻了

获取异常信息里再出异常就找不到日志了,我TM人傻了

spring-data-redis连接泄漏,我TM人傻了

干货满满张哈希:SpringCloudGateway没有链路信息,我TM人傻了(上)

大家好,我又人傻了。这次的经验告诉我们,出来写代码偷的懒,迟早要还的。

问题现象与背景

昨晚我们的网关雪崩了一段时间,现象是:

1.不断有各种微服务报异常:在写HTTP响应的时候,连接已经关闭:

reactor.netty.http.client.PrematureCloseException:ConnectionprematurelyclosedBEFOREresponse

2.同时还有请求还没读取完,连接已经关闭的异常:

org.springframework.http.converter.HttpMessageNotReadableException:I/Oerrorwhilereadinginputmessage;nestedexceptionisjava.io.IOException:UT000128:Remotepeerclosedconnectionbeforealldatacouldberead

3.前端不断有请求超时的报警,504GatewayTime-out

4.网关进程不断健康检查失败而被重启

5.重启后的网关进程,立刻请求数量激增,每个实例峰值2000qps,闲时每个实例500qps,忙时由于有扩容也能保持每个实例在1000qps以内,然后健康检查接口就很长时间没有响应,导致实例不断重启

其中,1和2的问题应该是应为网关不断重启,并且由于某些原因优雅关闭失败导致强制关闭,强制关闭导致连接被强制断开从而有1和2相关的异常。

我们的网关是基于SpringCloudGateway实现的,并且有自动根据CPU负载扩容的机制。奇怪的是在请求数量彪增的时候,CPU利用率并没有提高很多,保持在60%左右,由于CPU负载没有达到扩容的界限,所以一直没有自动扩容。为了快速解决问题,我们手动扩容了几个网关实例,将网关单实例负载控制在了1000以内,暂时解决了问题。

问题分析

为了彻底解决这个问题,我们使用JFR分析。首先先根据已知的线索去分析:

SpringCloudGateway是基于Spring-WebFlux实现的异步响应式网关,http业务线程是有限的(默认是2*可以使用的CPU个数,我们这里是4)。

网关进程不断健康检查失败,健康检查调用的是/actuator/health接口,这个接口一直超时。

健康检查接口超时一般有两个原因:

健康检查接口检查某个组件的时候,阻塞住了。例如数据库如果卡住,那么可能数据库健康检查会一直没有返回。

http线程池没来得及处理健康检查请求,请求就超时了。

我们可以先去看JFR中的定时堆栈,看是否有http线程卡在健康检查上面。查看出问题后的线程堆栈,重点关注那4个http线程,结果发现这4个线程的堆栈基本一样,都是在执行Redis命令:

发现http线程没有卡在健康检查,同时其他线程也没有任何和健康检查相关的堆栈(异步环境下,健康检查也是异步的,其中某些过程可能交给其他线程)。所以,健康检查请求应该是还没被执行就超时取消了。

那么为啥会这样呢?于此同时,我还发现这里用的是RedisTemplate,是spring-data-redis的同步RedisAPI。我猛然想起来之前写这里的代码的时候,因为只是验证一个key是否存在和修改key的过期时间,偷懒没有用异步API。这里是不是因为使用同步API阻塞了http线程导致的雪崩呢?

我们来验证下这个猜想:我们的项目中redis操作是通过spring-data-redis+Lettuce连接池,启用并且增加了关于Lettuce命令的JFR监控,可以参考我的这篇文章:这个Redis连接池的新监控方式针不戳~我再加一点佐料,截至目前我的pullrequest已经合并,这个特性会在6.2.x版本发布。我们看下出问题时间附近的Redis命令采集,如下图所示:

我们来简单计算下执行Redis命令导致的阻塞时间(我们的采集是10s一次,count是命令次数,时间单位是微秒):使用这里的命令个数乘以50%的中位数,除以10(因为是10s),得出每秒因为执行Redis命令导致的阻塞时间:

32*152=48641*860=8605*163=81532*176=56321*178=17816959*168=2849112774*176=1362243144*166=52190417343*179=3104397702*166=116532总和67405186740518/10=674051.8us=0.67s

这个仅仅是使用中位数计算的阻塞时间,从图上的分布其实可以看出真正的值应该比这个大,这样很有可能每秒需要在Redis同步接口上阻塞的时间就超过了1s,不断地请求,请求没有减少,从而导致了请求越积越多,最后雪崩。

并且由于是阻塞接口,线程很多时间消耗在等待io了,所以CPU上不去,导致没有自动扩容。业务高峰时,由于有设定好的预先扩容,导致网关单实例没有达到出问题的压力,所以没问题。

解决问题

我们来改写原有代码,使用同步spring-data-redisApi原有代码是(其实就是spring-cloud-gateway的Filter接口的核心方法publicMonotraced(ServerWebExchangeexchange,GatewayFilterChainchain)的方法体):

if(StringUtils.isBlank(token)){//如果token不存在,则根据路径决定继续请求还是返回需要登录的状态码returncontinueOrUnauthorized(path,exchange,chain,headers);}else{try{StringaccessTokenValue=redisTemplate.opsForValue.get(token);if(StringUtils.isNotBlank(accessTokenValue)){//如果accessTokenValue不为空,则续期4小时,保证登录用户只要有操作就不会让token过期Longexpire=redisTemplate.getExpire(token);log.info("accessTokenValue={},expire={}",accessTokenValue,expire);if(expire!=null&&expire

改成使用异步:

if(StringUtils.isBlank(token)){returncontinueOrUnauthorized(path,exchange,chain,headers);}else{HttpHeadersfinalHeaders=headers;//必须使用tracedPublisherFactory包裹,否则链路信息会丢失,这里参考我的另一篇文章:SpringCloudGateway没有链路信息,我TM人傻了returntracedPublisherFactory.getTracedMono(redisTemplate.opsForValue.get(token)//必须切换线程,否则后续线程使用的还是Redisson的线程,如果耗时长则会影响其他使用Redis的业务,并且这个耗时也算在Redis连接命令超时中.publishOn(Schedulers.parallel),exchange).doOnSuccess(accessTokenValue->{if(accessTokenValue!=null){//accessToken续期,4小时tracedPublisherFactory.getTracedMono(redisTemplate.getExpire(token).publishOn(Schedulers.parallel),exchange).doOnSuccess(expire->{log.info("accessTokenValue={},expire={}",accessTokenValue,expire);if(expire!=null&&expire.toHours本来里面承载的就是空的,会导致每个请求发送两遍。.defaultIfEmpty("").flatMap(accessTokenValue->{try{if(StringUtils.isNotBlank(accessTokenValue)){JSONObjectaccessToken=JSON.parseObject(accessTokenValue);StringuserId=accessToken.getString("userId");if(StringUtils.isNotBlank(userId)){//解析TokenHttpHeadersnewHeaders=parse(accessToken);//继续请求returnFilterUtil.changeRequestHeader(exchange,chain,newHeaders);}}returncontinueOrUnauthorized(path,exchange,chain,finalHeaders);}catch(Exceptione){log.error("readaccessTokenerror:{}",e.getMessage,e);returncontinueOrUnauthorized(path,exchange,chain,finalHeaders);}});}

这里有几个注意点:

Spring-Cloud-Sleuth对于Spring-WebFlux中做的链路追踪优先,如果我们在Filter中创建新的Flux或者Mono,这里面是没有链路信息的,需要我们手动加入。这个可以参考我的另一篇文章:SpringCloudGateway没有链路信息,我TM人傻了

spring-data-redis+Lettuce连接池的组合,对于异步接口,我们最好在获取响应之后切换成别的线程池执行,否则后续线程使用的还是Redisson的线程,如果耗时长则会影响其他使用Redis的业务,并且这个耗时也算在Redis连接命令超时中

ProjectReactor如果中间结果有null值,则后面的flatmap、map等流操作就不执行了。如果在这里终止,前端收到的响应是有问题的。所以中间结果我们要在每一步考虑null问题。

spring-cloud-gateway的核心GatewayFilter接口,核心方法返回的是Mono。Mono本来里面承载的就是空的,导致我们不能使用末尾的switchIfEmpty来简化中间步骤的null,如果用了会导致每个请求发送两遍。

这样修改后,压测了下网关,单实例2wqps请求也没有出现这个问题了。

posted @ 22-07-02 01:52 admin  阅读:
彩宝网平台,彩宝网官网,彩宝网网址,彩宝网下载,彩宝网app,彩宝网开户,彩宝网投注,彩宝网购彩,彩宝网注册,彩宝网登录,彩宝网邀请码,彩宝网技巧,彩宝网手机版,彩宝网靠谱吗,彩宝网走势图,彩宝网开奖结果

Powered by 彩宝网 @2018 RSS地图 HTML地图