记一次最近在项目上遇到了微服务超时问题,该项目使用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这边到目前最新的SpringBoot2.2.2
和SpringCloudHoxton.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版本。
com.netflix.eureka
eureka-client
1.9.14
额外问题
如果系统使用了Redis
或者数据库
,则可能也会遇到上面的问题,需要设置相应的超时时间
才可以。
使用环境变量,让Spring Boot应用部署更加灵活