likes
comments
collection
share

接口访问超时引发的思考

作者站长头像
站长
· 阅读数 4

前言

最近很多云平台用户找过来问,为什么在平台发布的应用,总是偶发接口访问超时,而且超时时间都还蛮固定的。随着问的用户越来越多,发现这是一个共性问题,发生偶发接口访问超时的用户的应用,有如下的特点。

  1. 超时时间固定。通常接口会在60秒左右返回;
  2. 应用访问量不高。这些应用通常是内部系统应用,访问量很低;
  3. 超时的接口涉及数据库操作。所有偶发超时的接口,均有操作数据库的行为;
  4. 使用了数据库连接池且为TomcatJdbc

结合上述的共性问题,问题的关键点就指向了数据库连接池或者用户的SQL,但是用户提供了执行的SQL自证了不是那种写得很烂的SQL,所以重点排查工作就在数据库连接池上了,这一排查,就牵扯出了我对于TomcatJdbc数据库连接池配置的思考。

正文

一. 问题分析

要分析这个问题,首先需要说明一下公司内部私有云的集群网络结构。在搭建K8s集群时,集群内部节点间共同构成一个虚拟网络,示意图如下。

接口访问超时引发的思考

每个集群有一个SLB作为四层负载均衡设备,作为集群内部服务的统一入口。当集群内部的服务要访问集群外时,这里就涉及出站访问,那么此时需要进行NAT源地址端口转换,如下所示。

接口访问超时引发的思考

当为虚拟网络开启NAT时,会为NAT分配一个随机的物理IP地址,此时所有经过NAT的客户端出站请求,其源地址均会映射为这个物理IP以及一个随机物理端口,同时NAT会记录这个映射关系,如下所示。

接口访问超时引发的思考

又由于要防止物理端口耗尽,所以集群网络还有一个监测机制,就是如果在规定时间内,某个TCP连接没有出站的流量,那么就会将其在NAT的源地址映射规则移除,此时造成的结果就是这个TCP连接实际是断开了,但是TCP连接的两端都是感知不到的。

上面简要的介绍了一下公司内部私有云的集群网络的一个结构特点,正是由于上面说到的:如果在规定时间内,某个TCP连接没有出站的流量,那么就会将其在NAT的源地址映射规则移除,导致了开头应用接口偶发访问慢的问题,具体原因且听我慢慢说道。

出现问题的应用,有两个特点需要关注,其一是使用了TomcatJdbc数据库连接池,其二是应用访问量不高。

首先说一下数据库连接池,其作为池化技术,本质就是提前和数据库服务端建立连接然后把连接保存起来,当应用程序想要和数据库进行交互时,可以直接从连接池里获取连接,而不需要重新建连,达到了连接复用的作用。

其次再分析一下应用访问量不高,正是由于应用访问量不高,连接池里的连接会空闲下来,而连接空闲,表现在TCP连接层面就是没有请求的报文出去,也就是没有出集群的出站流量,那么说到这里,帅的人其实已经发现问题的所在了,因为在公司内部私有云的集群网络下,如果在规定时间内,某个TCP连接没有出站的流量,那么这个TCP连接就会被断开,并且这个断开是两端都无法感知到的,也就是数据库连接池认为这个连接还可用,数据库服务端也认为这个连接还可用,那么当后续请求到来时,应用程序就会从数据库连接池中拿到这个底层TCP连接已经断开的数据库连接

分析到这里,问题基本已经清晰了,接口访问超时,是因为访问接口时会触发数据库操作,从而会从TomcatJdbc数据库连接池中拿到一个底层TCP连接已经断开的数据库连接,到这里其实还没完全解释清楚为什么会慢,就算使用了一个不可用的数据库连接,按理也不应该阻塞60秒左右,要弄明白这个,我又去深入研究了一下TomcatJdbc的源码,总算是发现了问题所在。

完整的TomcatJdbc源码分析,会在后续的文章中给出,这里直接就给出导致慢的原因及对应部分的源码简析。慢的时间就是消耗在TomcatJdbc校验数据库连接上,当我们从TomcatJdbc数据库连接池获取出一个连接时,如果配置了testOnBorrowtrue,那么此时就会校验连接,而所谓校验连接,就是使用这个连接,给数据库发送一条简单的查询语句,这个语句通常为SELECT 1,简化版源码如下所示。

public boolean validate(int validateAction, String sql) {
    
    ......

    String query = sql;

    ......

    boolean transactionCommitted = false;
    Statement stmt = null;
    try {
        // 通过物理连接创建出Statement
        stmt = connection.createStatement();

        // 获取配置的校验连接超时时间
        // 默认的校验连接超时时间为-1
        int validationQueryTimeout = poolProperties.getValidationQueryTimeout();
        if (validationQueryTimeout > 0) {
            stmt.setQueryTimeout(validationQueryTimeout);
        }

        // 执行校验SQL
        stmt.execute(query);
        stmt.close();
        this.lastValidated = now;
        transactionCommitted = silentlyCommitTransactionIfNeeded();
        return true;
    } catch (Exception ex) {
        
        ......

    } finally {
        if (!transactionCommitted) {
            silentlyRollbackTransactionIfNeeded();
        }
    }
    return false;
}

在校验连接的时候,会通过物理连接创建出Statement,然后如果配置了validationQueryTimeout参数,则会给Statement设置查询超时时间,但是如果没有配validationQueryTimeout参数,则就不会给Statement设置查询超时时间,这个时候问题就来了,如果不给Statement设置查询超时时间,那么这个超时时间是多久呢。要搞懂这个问题,我们需要回顾一下数据库中的三种超时参数:Transaction Timeout(事务超时),Statement Timeout(语句查询超时)和Socket Timeout(套接字超时)。

超时参数说明
Transaction Timeout用于限制某个事务执行时间的上限。通常Transaction Timeout = N * Statement Timeout + 业务处理耗时,其中N表示事务中要执行的SQL
Statement Timeout用于限制某条SQL语句的最大执行时间
Socket Timeout数据库连接的Socket套接字读写数据的阻塞超时时间,通常Socket Timeout需要大于Statement Timeout的值,否则Statement Timeout就没有意义

在本文的场景中,重点关注Statement TimeoutSocket Timeout。首先是Statement Timeout,上面的源码分析中已经知道,当没有配置TomcatJdbc数据库连接池的validationQueryTimeout参数时,就不会设置Statement Timeout,此时按照JDBC的官方注释可以知道,这个时候Statement Timeout是不超时的,那么这会儿超时肯定就是Socket Timeout触发的超时,这是因为Socket是无法感知底层TCP连接的状态的,也就是就算TCP连接断开了,那么Socket也会阻塞在读写操作上,直到超时。那么到这里,已经基本接近答案了,接口访问超时,就是因为在对连接执行校验时阻塞在了Socket读写数据上直到超时,那么还有最后一个问题,为什么超时时间都是固定为60秒呢,这个原因就是公司内部给出的数据库连接串的最佳实践中,把socketTimeout配置为了60000,即60秒,就像下面这个连接串一样。

jdbc:mysql://xxx.xxx.xxx:3306/test?characterEncoding=UTF-8&failOverReadOnly=false&secondsBeforeRetryMaster=0&queriesBeforeRetryMaster=0&serverTimezone=Asia/Shanghai&useUnicode=true&useSSL=false&connectTimeout=10000&socketTimeout=60000

二. 问题解决

那么上述的问题如何解决,其实核心就在于TomcatJdbc数据库连接池的配置。

首先按照问题分析里的思路,首先应该想到要配置validationQueryTimeout参数,并且可以将validationQueryTimeout参数设置为一个较小的值,这样就能够快速的完成连接校验。其实这是一个正确但不完全正确的做法,因为当validationQueryTimeout参数生效的时候,说明已经是从数据库连接池里拿出了一个底层TCP连接已经断开的数据库连接,这时候去校验这个连接,无论validationQueryTimeout参数配置为了一个多么小的值,这里的校验耗时都是阻塞住了业务线程,这很明显不是一个优雅的做法。

那么更为优雅的做法是配置testWhileIdle,理解这个参数前,我们简要介绍一下TomcatJdbc数据库连接池的一个工作机制,首先TomcatJdbc数据库连接池有两个队列用于存放可用的连接(idle)和借出的连接(busy),其实这很好理解,可用的连接就放在idle队列中,业务线程获取连接时,就从idle队列中获取,每个被获取的连接,会从idle队列中转移到busy队列中,与此同时,TomcatJdbc数据库连接池还有一个定时任务,这个定时任务触发执行时,会做如下三件事情。

  1. 连接泄漏检测;
  2. 可用连接检测保活;
  3. idle队列大小调整。

重点关注连接泄漏检测可用连接检测保活。连接泄漏检测主要是针对busy队列,主要用于判断借出的连接有没有在规定时间内被归还,如果没有,则强制回收连接;空闲连接检测保活主要是针对idle队列,主要用于检测可用连接的状态,在检测时如果不可用,则重连一次数据库,如果可用则相当于当前连接与数据库做了一次保活。

那么到这里,testWhileIdle这个参数的作用就很明确了,当配置testWhileIdletrue时,就会每隔timeBetweenEvictionRunsMillis的时间对所有可用且校验间隔达到了validationInterval的连接进行校验和保活,至此,优雅程度已经达到百分之九十五了,还差百分五,这百分之五就在于timeBetweenEvictionRunsMillisvalidationInterval这两个配置项,假如我们知道连接空闲达到T时间时底层TCP连接会被断开,那么在配置testWhileIdletrue的前提下,还需要满足T > timeBetweenEvictionRunsMillis > validationInterval,这样才能避免空闲连接的底层TCP连接被断开。

其实吧,上面一通操作做完,也只是百分之九十九的优雅,最后百分之一,在于如果配置了testWhileIdletrue,并且也满足T > timeBetweenEvictionRunsMillis > validationInterval,那么这个时候,就不要配置如下两个参数为true了。

  1. testOnBorrow
  2. testOnReturn

上述参数均会导致在业务线程中花费时间去进行连接校验,虽然正常的连接校验起来很快,但是毕竟业务线程跑得能快一点是一点不是,好在上述参数默认情况下都是false,所以也不用显式的去进行配置。

后记

公司内部私有云的集群网络的特点,导致了TomcatJdbc数据库连接池里的数据库连接在空闲一定时间后其底层TCP连接就会断开从而不可用,然后TomcatJdbc数据库连接池在校验连接时就会阻塞住直到触发Socket Timeout,这种阻塞在很多时候都是致命的,所以需要结合TomcatJdbc数据库连接池的配置来对可用的空闲连接进行保活操作,这就需要配置testWhileIdletrue来异步的进行连接保活。

不单是TomcatJdbc数据库连接池,DruidHikariCP数据库连接池其实也有相同的问题,并且解决的核心思路也是配置连接保活。

其实不单是数据库连接池,只要底层是TCP连接的连接池化技术,都会存在这个问题,所以在使用各种连接池时,保活配置,一定要配上。