druid数据源参数探索

887 阅读12分钟

本文是基于对druid参数的理解上,更进一步对各种异常情况下进行压测。结合产线实际情况,给出一个建议值。建议先阅读: druid数据源源码解读及参数解释

目标

数据库一般都是应用的最底层服务,而数据源正是对该底层服务的一个初步封装,因此数据源的正确配置至关重要。目前,我们对数据源的要求如下:

  • 慢查询:隔离影响,避免占用过多资源
  • qps突增:请求在合理的超时时间拿到连接并执行
  • 网络抖动:短时抖动无感知,持续抖动快速失败
  • 数据库不可用:快速失败

压测方式

为了避免应用中其他组件影响分析,只针对druid数据源进行压测,花了点时间写了一个Jmeter插件来进行压测。

这里非常不建议使用手写多线程/countDownLunch等方式模拟请求。一是这样统计结果非常困难;另外,也很难控制qps。

自定义Jmeter插件

采用了最简单的方式:初始化时读取Jmeter参数,并创建DruidDataSource

后续所有请求直接通过该数据源获取连接,执行预设sql,归还连接,一气呵成。

Jmeter插件开发这里不过多涉及,搜索关键字AbstractJavaSamplerClient即可。

image.png

异常情况

慢查询

最常见的一种情况,我们的第一反应是通过配置connectionProperties中的socketTimeout,readTimeout控制sql执行超时时间。

这些本身不是数据源定义的配置项,数据源只是简单的把connectionProperties配置传递给jdbc Driver。 所以oracle/mysql对执行超时的配置项不同,本文以mysql为例。

com.mysql.cj.protocol.a.NativeSocketConnection#connect

            this.mysqlSocket = (Socket)this.socketFactory.connect(this.host, this.port, propSet, loginTimeout);
            int socketTimeout = (Integer)propSet.getIntegerProperty(PropertyKey.socketTimeout).getValue();
            if (socketTimeout != 0) {
                try {
                    this.mysqlSocket.setSoTimeout(socketTimeout);
                } catch (Exception var9) {
                }
            }

分析

根据上一篇的知识,我们针对慢查询可以做出以下分析:

  1. 配置sql执行超时时间,防止一个线程长时间占用数据库连接。

  2. 当数据库连接被慢查询占满时,其他线程不应等待过长时间。

用例一

配置:

keyvalue
socketTimeout1000
maxWait1500
maxActive8
failFastfalse
maxWaitThreadCount-1

请求线程:100个线程,1秒内启动

限制单线程最大样本量:600/min

慢sql: select sleep(2)

可以观察到,打印出的报错信息如下:

The last packet successfully received from the server was 1,003 milliseconds ago. The last packet sent successfully to the server was 1,003 milliseconds ago.
...
Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure
...
2021-03-13 19:23:12,618 WARN c.a.d.p.DruidDataSource: get connection timeout retry : 1
2021-03-13 19:23:12,617 ERROR c.a.d.p.DruidDataSource: discard connection

压测结果如下:

image.png

image.png

根据压测统计可以看出,请求的响应时间主要有以下几个节点:

  • 1000ms Line ->极少

      因为目前数据源最大连接数配置只有8,在请求进来后连接池马上被占满。
      所以,只有少数请求可以被`socketTimeout`影响:sql得到资源去执行,但是因为socket超时,并没有得到返回结果。
    
  • 1500ms Line->84.5%

    大多数请求都是1.5s:因为启动后连接池被瞬间占满,等待连接消耗1.5s后超时。这里是maxWait起作用
    
  • 2500ms Line->98.1%

    少部分请求在等待一段时间后得到了连接,但由于得到资源后去执行sql,最终因为socket超时结束。
    
  • 3000ms Line->99.7%

  • 3500ms Line->99.9

    这个两个时间线通过日志来看,基本可以判断是由于丢弃连接,并触发notFullTimeoutRetryCount引发的。
    

用例一结论

通过上面的压测统计,可以很容易的分析出以下结论:

  • socketTimeout确实起到了作用,使本来需要占用连接2s的sql在1.5秒就发生了超时异常。
  • 案例中绝大部分样本都是1.5秒返回,他们并没有去执行sql,而是在等待连接资源。

用例二

为了解决案例一中大量请求将时间耗费在等待连接的问题,本例中将减小maxWait配置。

更新数据源配置如下:

keyvalue
socketTimeout1000
maxWait500
maxActive8
failFastfalse
maxWaitThreadCount-1

其他采用与案例一相同的样本压测:

image.png

image.png

与案例一中压测结果相比的话,图形的形状大致相同,只是耗时有较大幅度的减小。

  • 500ms Line -> 89.1%

    效果明显,有接近90%的请求因为没有获得连接,在500ms内就因为获取不到连接超时返回。
    

...

  • 1500 Line -> 99.9%

    这里的效果已经和我们预期相同,最长时间为maxWait+socketTimeout。
    

用例二结论

当数据源所有连接被占用时,maxWait为第一道防线。但是,只依赖maxWait来防御还是有所粗糙,毕竟在慢sql充斥的情况下,有不少线程还是“白等”。

那么,有没有一种情况可以不等呢?请看案例三。

用例三

现在,我们希望如果发现当前等待获取连接的线程数过多的话,就直接失败,而不再去等待maxWait

更新数据源配置如下:

keyvalue
socketTimeout1000
maxWait500
maxActive8
failFastfalse
maxWaitThreadCount50

其他采用与案例一相同的样本压测:

image.png

image.png

  • 4ms Line -> 57.6%

    50%+的请求由于进入时已有太多的线程在等待,而直接快速失败,没有白白浪费时间。
    
  • ...

    其他耗时产生的原因与上例相同,这里不再重复。
    

用例三结论

正常情况下,数据源中不会出现过多的等待线程。所以,通过maxWaitThreadCount来控制快速失败也是一个不错的办法。

小结

一般来说,socketTimeout建议配置为系统中执行最慢的sql耗时。并且,如果在数据源层面已经允许了慢sql,那么,在业务层一定要配置限流。

maxWait则需要考虑多种因素:网络收敛时间,业务线程超时时间等,所以maxWait并不能一味的减小。

但是,在一些情况下,真的没必要再等maxWait,这时就可以使用maxWaitThreadCount来控制快速失败条件。

当然,maxWaitThreadCount的配置要根据业务线程池大小,数据库连接池大小:maxActive;最小空闲连接数minIdle等来计算配置。

网络抖动(延迟)

我们使用阿里开源的混沌工程:chaosblade来模拟网络的各种异常情况。

## 示例
## 增加网络延迟:2s
./blade create network delay --time 2000 --interface eth0 --local-port 3306

## 恢复网络延迟
./blade destroy network delay --interface eth0

分析

网络的影响范围就比较广了:创建连接,实际执行都会受到网络延迟,丢包的影响。这里,我们主要测试短暂的网络不稳定会对数据源带来怎样的影响,以及,如何减小影响。

用例四

keyvalue
socketTimeout1500
connectTimeout100
maxWait500
maxActive8
failFastfalse
maxWaitThreadCount50

请求线程:100个线程,1秒内启动

限制单线程最大样本量:600/min

测试sql: select sleep(0.001)

网络延迟:浮动,1s内延迟。

./blade create network delay --time 0 --offset 1000 --interface eth0 --local-port 3306

image.png

image.png

在设置connectTimeout=100,并设置网络延迟后,我们得到了一种新的异常:

The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[?:1.8.0_181]
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) ~[?:1.8.0_181]
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[?:1.8.0_181]
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423) ~[?:1.8.0_181]
	at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:61) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:105) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:151) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.exceptions.ExceptionFactory.createCommunicationsException(ExceptionFactory.java:167) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.protocol.a.NativeSocketConnection.connect(NativeSocketConnection.java:91) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.NativeSession.connect(NativeSession.java:144) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.jdbc.ConnectionImpl.connectOneTryOnly(ConnectionImpl.java:956) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:826) ~[druid_tuning-1.0-SNAPSHOT-jar-with-dependencies.jar:?]
	... 6 more
Caused by: java.net.SocketTimeoutException: connect timed out
	at java.net.PlainSocketImpl.socketConnect(Native Method) ~[?:1.8.0_181]

由于网络延迟的关系,大部分创建数据库连接的请求都无法成功。

并且,maxWait的重试机制在本例的情况下影响最明显:存在将近1/3的线程等了两次maxWait。

另外,有65.2%的请求由于设置了maxWaitThreadCount而快速失败。

用例四结论

socketTimeout指的是通过tcp发送数据,等待响应的超时时间。而connectTimeout指的是建立连接的超时时间。

当网络状况不佳,影响建立连接时,maxWait的实际等待时间会受到notFullTimeoutRetryCount的影响,造成实际等待时间翻倍。

案例五

五.一

keyvalue
socketTimeout1500
connectTimeout100
maxWait500
maxActive8
failFasttrue
maxWaitThreadCount50

其他与案例四相同,不同的是我们开启了failFast,依旧存在1s内的网络延迟。

再来一遍:

image.png

image.png

虽然快速失败的效果很明显,执行耗时大大减少。

但是,在用例四种,还有3.2%的样本成功,到这里,怎么就全部失败了呢?

原因很简单:因为我们的initialSizeminIdle配置均为0(默认配置)。

由于池中没有空闲连接,当请求进入后,只能新建连接,而在druid数据源发现创建连接失败后,马上开启快速失败,导致后续请求只是发出了要求创建连接的信号,并没有再等待连接。

五.二

为了验证,我们调整initialSizeminIdle为8,其他相同在压测一次:

keyvalue
socketTimeout1500
connectTimeout100
maxWait500
maxActive8
failFasttrue
initialSize8
minIdle8
maxWaitThreadCount50

image.png

image.png

由于池中有较多的空闲连接,还是有一些请求可以得到机会去执行的,所以会有少量请求执行成功。

但是,结合日志和压测结果来看的话,failFast并没有起到太多作用,本次测试仅有4笔请求响应了快速失败。

因为在池中有空闲连接的情况,很少有机会去创建新连接,并且,快速失败在创建连接失败后才会翻转变量,这时候,很多请求已经在等maxWait了,只有后续新进去的请求才会响应。

所以,并不能第一时间触发快速失败,他们只能发现连接池满,等待。。。

用例五结论

当开启failFast的情况下,connectTimeout不能配置的过小,否则快速失败将过于敏感。

快速失败翻转后,仅对后续新进入的请求起作用,已经进入到等待的线程不会受到影响。

用例六

我们再测试一下瞬间的网络延迟有什么样的效果:

keyvalue
socketTimeout1500
connectTimeout100
maxWait500
maxActive8
failFastfalse
initialSize0
minIdle0
maxWaitThreadCount50

六.一

以下是没有任何干扰的情况下: image.png

image.png

通过日志可以看出,本次失败的全部是因为maxWaitThreadCount触发的快速失败。

六.二

在压测过程中,我们给他加一个500ms的延迟,持续500ms。

image.png

image.png

由于中间被施加了500ms延迟,会有大量的请求线程堆积,有近一半的请求是由于maxWaitThreadCount触发快速失败。剩下是等待maxWait超时失败。

用例六结论

当网络出现短时波动后,数据源等待线程会快速增长,并触发maxWaitThreadCount快速失败。

实际上,如果我将maxWaitThreadCount设置为-1(关闭maxWaitThreadCount快速失败): 执行的成功率会大大提高,耗时也只是略有增长。(毕竟延迟持续时间只有0.5s)

小结

网络延迟的表现情况看上去和慢sql类似,都是执行耗时变长。不同的是:网络延迟会影响创建连接,所以:connectTimeout应配置为网络最大延迟时间。

同时failFast是由创建连接触发的,当failFast触发后,可以无视maxWait超时时间,直接失败。

maxWaitThreadCount也是一种快速失败的方式,可以参考业务线程池配置,不宜配置过小,否则容易误伤(短时延迟的情况下)。

网络抖动(丢包)

分析

网络丢包在应用层看起来,和延迟类似,不过比相对固定延迟拥有更多的不确定性。

## 丢包20%
./blade create network loss --percent 20  --interface eth0 --local-port 3306

用例七

七.一

keyvalue
socketTimeout1500
connectTimeout1000
maxWait500
maxActive8
failFastfalse
initialSize0
minIdle0
maxWaitThreadCount50

请求线程:100个线程,1秒内启动

限制单线程最大样本量:600/min

测试sql: select sleep(0.001)

网络丢包:20%

image.png

image.png

同样的,由于maxWaitThreadCount快速失败的关系,有超过一半的请求都执行了快速失败。

七.二

同样的,我们再把maxWaitThreadCount关闭看一下:

image.png

image.png

通过日志可以看出,本次的失败原因主要是由于等待maxWait超时引起的。

小结

虽然在协议上,tcp延迟和丢包不同。但是,如果表现在应用层面,都是相同的,看到的异常都是超时。

数据库宕机

数据库宕机的话,最好的情况就是快速失败了。

分析

数据库宕机后,新建连接一定会超时(connectTimeout),而现有连接的话,会走socketTimeout超时。

并且,等待线程也会快速堆积,我们这里主要看恢复速度。

用例八

keyvalue
socketTimeout1500
connectTimeout1000
maxWait500
maxActive8
failFastfalse
initialSize0
minIdle0
maxWaitThreadCount50

请求线程:100个线程,1秒内启动

限制单线程最大样本量:600/min

测试sql: select sleep(0.001)

在压测期间,停掉数据库,并重新启动。

image.png

image.png

小结

数据库宕机后,应用表现也和网络延迟类似。读超时,连接超时。

总结

本文针对druid数据源中几个关键参数对数据库的实际影响做了测试,异常情况下压测结果基本和我们预期相符。

那么汇总一下建议配置:

配置项参考配置
socketTimeout应用中最慢sql执行时间
connectTimeout网络最长延迟
maxWait参考业务超时时间,网络稳定性
maxWaitThreadCount参考业务线程数,业务超时时间
failFast根据实际情况,如果正常情况下网络很稳定,可以考虑开启

另外几个连接数配置这里就不太好给建议了,要根据自己应用的业务线程数,超时时间等等综合考虑。