数据库连接池初探

汪汪

数据库连接池初探

为什么需要连接池

MySQL连接原理

所谓的数据库连接操作实际上是MySQL客户端与MySQL服务端进行通信,再细化一点便是连接进程与MySQL服务进程之间的进程通信。常用的进程通信方式有管道共享内存TCP socketunix domain socket,而MySQL提供的连接方式也大抵如此。

  • TCP socket

    这应该是使用最普遍的一种MySQL连接方式,也是我们日常开发过程中使用的,随着微服务化的流行,数据库RDS通常与应用服务器分离,数据库连接几乎都采用这种在TCP连接上建立一个基于网络的连接请求来完成任务。这种连接方式的连接过程如下:

    1. 应用数据层向DataSource请求数据库连接
    2. DataSource使用数据库Driver打开数据库连接
    3. 创建数据库连接,内部可能创建线程,打开TCP socket
    4. 应用读/写数据库
    5. 如果该连接不再需要就关闭连接
    6. 关闭socket
  • 其它连接方式

    当客户端和服务端都在同一台服务器上的时候,还可以使用命名管道共享内存Unix域套接字来进行连接。这些方式都是本地通信,不经过网卡,不需要进行TCP握手与挥手,性能自然也比TCP Socket的方式要快的多,但我们用不上。

连接的资源占用

在使用TCP Socket的连接方式下,我们来看看MySQL连接需要占用的资源有哪些。

  1. TCP连接

    tcp连接和断开资源的消耗,如三次握手四次挥手所消耗的时间、协议栈的内存分配等等。

  2. 线程分配

    MySQL内部会为每个连接创建一个线程,为每个连接分配连接缓冲区和结果缓冲区。虽然内部维护了一个非常类似线程池的Threads_catched来避免频繁的创建线程和销毁线程,但它仅仅只是将用过的空闲连接给缓存下来放到池中,在连接不够的时候仍然存在创建连接消耗资源的操作。

资源池化

我们根据MySQL连接过程可以看到,如果每一个请求都需要创建新的数据库连接的话,那么每次DataSource都要通过驱动程序去创建一个新的MySQL连接。这么做的话将非常消耗资源。如果我们提前创建好这些连接,并把连接放在一个集合容器内,然后需要用去取连接,这样不同的请求便可以复用已经创建好的连接。这便是数据库连接池的基本思想,池化资源复用来节省系统资源消耗和降低请求时间。

连接池初探

从Class.forName到DataSource

在说连接池之前,先来了解一下JDBC驱动中一个非常重要的API。在比较老式的JDBC编程中,通常使用下面的模板代码来获得一个数据库连接:

/**
* 连接数据库
* @return
*/
public static Connection getConnection(){  
    Connection connection = null;
    String url = "jdbc:mysql://localhost:3306/chartroom";
    String user = "root";
    String password = "root";
    try {
        Class.forName("com.mysql.jdbc.Driver");
        connection = DriverManager.getConnection(url, user, password);
} catch (SQLException e) {
    e.printStackTrace();
} catch (ClassNotFoundException e) {
    e.printStackTrace();
}
    return connection;
}

在老式的JDBC编程中,都是通过DriverManager类来连接到数据源并提供Connection类用以操作数据库的。但在JDBC 2.0的 API 中新增了DataSource接口,它提供了连接到数据源的另一种方法。使用 DataSource对象现在已经是连接到数据源的首选方法。

而众多数据库连接池的实现,无论是tomcat连接池还是hikariCP,其实都是DataSource的一种具体实现,可以看作是一个代理。只不过各个连接池在对数据库连接管理的实现层面上有不同的逻辑。

连接池最佳实践之HikariCP

HikariCP可谓是性能极致的数据库连接池,目前在spring-boot 2.0中已被当作是默认连接池来使用。本文的目的在于分享对连接池的了解以及统一团队内的连接池选择,因此主要来说说HikariCP如何配置和如何监控,不探讨HikariCP内部实现细节,有时间的话可以再细说。

配置项

一些不常用的配置,比如isAllowPoolSuspensionisIsolateInternalQueries之类的这里不再提及。只说说几个经常使用以及争议比较大的配置。

配置项

  • 关于pool-size

首先给出结论,连接池的大小并不是越大越好的。作者在项目wiki中有一篇关于连接池大小配置的文章,有兴趣可以读一读,About Pool Sizing。大意便是尽可能在CPU线程切换的消耗与io阻塞等待消耗中寻找平衡。其中有一点很让人思考,随着SSD硬盘的普及,磁盘IO的时间将大大缩短,当然也意味着更频繁的切换线程。文章中还给了一些测试报告,包括Oracle的实验和某PG项目的测试,结论是前者将连接池大小从2048逐渐减少到96时响应时间从100ms降到了2ms,而作者认为大小为96的连接池仍然太大了。后者则用一个大小为9的连接池运行在一台小型四核i7服务器上可以轻松处理3000个前端用户在6000 TPS下的简单查询,

那么连接池大小究竟该设置为多少呢?作者给出了一个"经验性"的结论是connections = ((core_count * 2) + effective_spindle_count),corecount为CPU的核心数、effectivespindle_count为数据库服务器磁盘列阵中的硬盘数。我觉得可以在公式的基础上做压测进行实验,然后根据实验结果的情况进行调整,但怎么看都不要过度配置连接池。

  • 关于fixed-pool-design

hikari作者比较倾向于连接池是固定大小的,一方面是因为如果将minIdle与maxPoolSize设置成不同的话,在流量激增的时候,请求会阻塞在getConnection()方法上会造成性能损失;另一方面作者认为即便设置成一样空闲的连接对整体的性能并不会有多大的影响,觉得设置min和max在必要的时候可以释放连接以释放内存给其他功能用的逻辑不成立,因为在高峰时仍然会达到maxPoolSize的量级消耗掉这部分内存,既然高峰时都能牺牲掉省下的这部分内存为何在空闲时又会需要。

我是比较赞同作者的观点的,尤其是在当下微服务盛行,每个服务连的数据库都是不同的,不会有多个服务的多个连接加起来导致数据库连接有压力。把min和max设置成一样当作固定大小的连接池来使用我觉得是十分合理的,不会在流量激增的时候出现阻塞导致拿不到连接抛出异常的问题,同时也提高了系统性能。

  • 其它配置

    1. hikari多了一个maxLifetime的配置,该配置用来指定所有连接的存活时间,即所有的连接在maxLifetime之后都得重连一次,保证连接池的活性。

    2. idleTimeout仅仅在连接池不是fixed的时候才生效,用来消除高峰流量激增时生成的多余空闲连接。

监控项

HikariCP本身设计以性能为主,也经常在网上被用来和阿里的Druid进行比较,后者号称为监控而生。但实际上HikariCP在拥有极佳性能的前提下,对监控的支持也并不算弱。HikariCP自身提供了IMetricsTracker接口,并给了micrometerdropwizardprometheus三种度量统计收集器的实现,并在运行时将连接池的各个状态值上传到初始化连接池时设置的MeterRegistry(可以看作是一个度量数据收集中心),想要监控只需要起个定时任务定时的从MeterRegistry把上传进去的值给取出来写到influxDB里结合监控系统就行了。

以下是hikari提供的监控指标:

监控指标

这些指标均会随着程序运行而产生不同的值。这里先对各个meter类型稍作解释:

  • Gauge 可以理解为上下浮动的度量,但存在上下界。
  • Counter 无限自增的度量,不存在上届,只要数据没被清除就会一直增加。
  • Timer 与时间有关的度量,比如请求的耗时之类。

这些指标中,我们尤其要关注当前排队获取连接的线程数,因为我们鼓励将连接池设置成fixed的,那么我们就要做好等待线程数的监控,以免真的连接池容量配的太少导致等待线程数过多而发生请求超时。

下面是监控打点代码:

/**
* 从meterRegistry中取出上传的merter数量写到influxdb中去
*/
public void doMonitor() {  
    for (Meter m : meterRegistry.getMeters()) {
        if (m instanceof Timer) {
            writeTimer((Timer) m);
        }
        if (m instanceof Gauge) {
            writeGauge(m.getId(), ((Gauge) m).value());
        }
        if (m instanceof Counter) {
            writeCounter(m.getId(), ((Counter) m).count());
        }
    }
}