解决SpringCloud微服务架构的超时问题240s/timeout

记一次最近在项目上遇到了微服务超时问题,该项目使用SpringCloud微服务架构,现把发现与解决问题的过程在此记录。

项目架构

项目完全使用基于SpringCloud架构的微服务模式,如下:

  • Spring Boot 2.x
  • Spring Cloud Gateway(网关服务)
  • Eureka(服务注册与发现)

问题

在应用部署生产环境后,各个服务会随机出现服务超时现象,检查日志发现大量的timeout超时异常,主要表现在后面要说的两点。具体分析原因是生产环境使用ACS容器,服务之间访问使用URL,而不同集群之间使用NAT进行转发,由于各种原因,转发使用的TCP连接会在空闲超过240s后,主动关闭,并且不会通知到应用。这样就会导致应用在一段时间后就会出现超时问题。

经过上面问题的发现,可以看出,我上面提到的超时问题随机出现,实际也不是真正的随机,因为一个应用服务要正常运行需和其他服务建立多个连接,而服务每进行一次请求都会随机使用其中一个连接,这样如果一个连接一直被使用,则连接不会被关闭。而如果刚好一个连接被关闭,而这个连接又被应用使用,就会出现timeout异常了。

gateway代理网关超时

gateway作为网关服务,会和几乎所有的服务进行通信,超时问题在网关服务里面也尤其严重。Spring Cloud Gateway使用reactor-netty进行请求转发,默认10s才会认为请求失败,而转发有一个重试机制,当连接被关闭后,第二次尝试如果仍然是一个被关闭的连接,则又会是10s的等待,这样就会造成整体系统性能严重下降,表现在前端就会是大量的timeout

服务注册超时

Eureka的服务注册默认是30s,定时向注册中心发送renew续约,以告知注册中心服务仍在正常运行,当注册中心没有在约定时间内收到服务实例的renew续约,则会认为服务实例已经下线而不再提供服务,并且renew时使用的连接池会默认清理空闲30s的连接。这里要强调一下,本来清理30s空闲连接是完全可以保证连接不会超时的,但事实是,当一个实例进行renew时却出现了大量的timeout(这是因为eureka-client包内的一个BUG,后面我会具体说明)。

问题解决

既然连接240s不用会被关闭,那我们可以让连接在240s内至少被使用一次,或者我们主动在240s内关闭连接,这里我们使用了后者。

网关

Spring Cloud Gateway使用reactor-netty进行请求的转发,所以我们要在netty上面着手。要处理的核心是HTTPClient的初始化部分。我们可以设置Gateway禁用连接池,这样每次请求都创建新的连接,每次用完就关闭,也就不会有超时问题了,但是随之而来的是性能问题,每次都创建新连接会造成大量消耗。所以就产生了第二种解决方案,定时关闭空闲连接。最新版Gateway组件已经支持在连接池配置项里面指定空闲连接时间,而我在解决这个问题时候Gateway还并不支持配置,所以只能自己写代码来屏蔽Gateway默认初始化HttpClient的部分。

注意:reactor-netty是在0.9.x版本开始支持空间连接配置的,而Spring这边到目前最新的SpringBoot 2.2.2和SpringCloud Hoxton.SR1才支持配置。

禁用连接池

application.yml

spring:
  cloud:
    gateway:
      httpclient:
        pool:
          type: disabled

设置超时

application.yml

spring:
  cloud:
    gateway:
      httpclient:
        pool:
          type: elastic
          max-idle-time: PT2M

自己写代码

把下面代码放到能被扫描到的类内,自己初始化HttpClient。主要看创建ConnectionProvider的部分,我这里把最大空闲时间设置成了120s

@Bean
public HttpClient gatewayHttpClient(HttpClientProperties properties) {

    // configure pool resources
    HttpClientProperties.Pool pool = properties.getPool();

    ConnectionProvider connectionProvider;
    if (pool.getType() == DISABLED) {
        connectionProvider = ConnectionProvider.newConnection();
    }
    else if (pool.getType() == FIXED) {
        connectionProvider = ConnectionProvider.fixed(pool.getName(),
                pool.getMaxConnections(), pool.getAcquireTimeout(),
                Duration.ofSeconds(120));
    }
    else {
        connectionProvider = ConnectionProvider.elastic(pool.getName(),
                Duration.ofSeconds(120));
    }

    HttpClient httpClient = HttpClient.create(connectionProvider)
            .tcpConfiguration(tcpClient -> {

                if (properties.getConnectTimeout() != null) {
                    tcpClient = tcpClient.option(
                            ChannelOption.CONNECT_TIMEOUT_MILLIS,
                            properties.getConnectTimeout());
                }

                // configure proxy if proxy host is set.
                HttpClientProperties.Proxy proxy = properties.getProxy();

                if (StringUtils.hasText(proxy.getHost())) {

                    tcpClient = tcpClient.proxy(proxySpec -> {
                        ProxyProvider.Builder builder = proxySpec
                                .type(ProxyProvider.Proxy.HTTP)
                                .host(proxy.getHost());

                        PropertyMapper map = PropertyMapper.get();

                        map.from(proxy::getPort).whenNonNull().to(builder::port);
                        map.from(proxy::getUsername).whenHasText()
                                .to(builder::username);
                        map.from(proxy::getPassword).whenHasText()
                                .to(password -> builder.password(s -> password));
                        map.from(proxy::getNonProxyHostsPattern).whenHasText()
                                .to(builder::nonProxyHosts);
                    });
                }
                return tcpClient;
            });

    HttpClientProperties.Ssl ssl = properties.getSsl();
    if ((ssl.getKeyStore() != null && ssl.getKeyStore().length() > 0)
            || ssl.getTrustedX509CertificatesForTrustManager().length > 0
            || ssl.isUseInsecureTrustManager()) {
        httpClient = httpClient.secure(sslContextSpec -> {
            // configure ssl
            SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();

            X509Certificate[] trustedX509Certificates = ssl
                    .getTrustedX509CertificatesForTrustManager();
            if (trustedX509Certificates.length > 0) {
                sslContextBuilder = sslContextBuilder
                        .trustManager(trustedX509Certificates);
            }
            else if (ssl.isUseInsecureTrustManager()) {
                sslContextBuilder = sslContextBuilder
                        .trustManager(InsecureTrustManagerFactory.INSTANCE);
            }

            try {
                sslContextBuilder = sslContextBuilder
                        .keyManager(ssl.getKeyManagerFactory());
            }
            catch (Exception e) {
                logger.error(e);
            }

            sslContextSpec.sslContext(sslContextBuilder)
                    .defaultConfiguration(ssl.getDefaultConfigurationType())
                    .handshakeTimeout(ssl.getHandshakeTimeout())
                    .closeNotifyFlushTimeout(ssl.getCloseNotifyFlushTimeout())
                    .closeNotifyReadTimeout(ssl.getCloseNotifyReadTimeout());
        });
    }

    if (properties.isWiretap()) {
        httpClient = httpClient.wiretap(true);
    }

    return httpClient;
}

服务注册

上面说了这个问题是由于eureka-client包的BUG引起的,eureka-client默认清理30s的空闲连接,但是通过查看源码发现,这个包却把30s乘以了1000变成了30000s,这样可以认为连接永远不会被清理了,也就出现了上面所说的问题。

注意:eureka-client 1.9.14版本已经修复上面BUG,但是最新版SpringCloud依然依赖1.9.13所以我们要做的是强制升级eureka-client版本。

<dependency>
    <groupId>com.netflix.eureka</groupId>
    <artifactId>eureka-client</artifactId>
    <version>1.9.14</version>
</dependency>

额外问题

如果系统使用了Redis或者数据库,则可能也会遇到上面的问题,需要设置相应的超时时间才可以。

使用环境变量,让Spring Boot应用部署更加灵活

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容