數據庫連接池爆滿如何排查
1. 什么是連接池
現如今在做程序開發的時候,尤其是涉及到數據庫的時候,都會用連接池來管理數據庫連接。其中Java里面有比較出名Druid,以Go語言里面的Gorm框架也會自帶連接池管理。數據庫連接池,其實就是存儲數據庫連接的池子,本質是一種資源復用技術。

2. 為什么要使用連接池
使用連接池的核心目的是提升應用程序的性能和效率。建立和關閉數據庫連接的過程通常是非常消耗資源的,尤其是在高并發環境或頻繁訪問數據庫的場景中,這開銷會對應用性能造成顯著的負面影響通過連接池,應用程序可以重復利用已有的數據庫連接,減少了每次連接建立和銷毀的開銷,從而加快系統的響應速度和提高了系統的吞吐能力。同時,連接池還能對連接數量進行有效管理,防止因連接過多而導致數據庫過載或性能下降的問題,從而保證系統運行的穩定性和資源的合理利用。
3. 連接池常見參數
參數 | 含義 |
max-active | 最大連接數(默認8) |
max-wait | 獲取連接時的最大等待時間 |
min-evictable-idle-time-millis | 連接保持空閑而不被釋放的最小時間 |
min-idle | 最小連接池數量 |
initial-size | 連接池初始化時建立物理連接的個數 |
time-between-eviction-runs-millis | 配置間隔多久才進行一次檢測,Destroy線程會檢測連接,如果連接空閑時間大于等于minEvictableIdleTimeMillis則關閉物理連接 |
4. 連接池爆滿排查路徑
連接池的使用雖然可以帶來很大程度上性能的話優化,但是在使用MySQL數據庫時,尤其是在高并發的場景下,數據庫連接數過多會導致連接池耗盡,進而影響應用程序的正常運行。所以對于數據庫連接池爆滿問題的排查,也相當重要,在排查這類問題的時候可以按照下圖的總體思路來進行。

5. 案例分析
(1) 數據模擬
現有一張用戶表t_user,要對這張表的數據做一些查詢操作,表結構如下:
CREATE TABLE `t_user` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵自增',
`name` varchar(32) NOT NULL DEFAULT '' COMMENT '姓名',
`create_time` varchar(32) NOT NULL DEFAULT '' COMMENT '創建日期,yyyy-MM-dd HH:mm:ss',
`age` int(4) NOT NULL DEFAULT 0 COMMENT '年齡',
PRIMARY KEY (`id`)
) ENGINE = InnoDB CHARSET = utf8;插入兩條記錄:
insert into t_user (name,age,create_time) values ("zhangsan",18,"2024-11-20 12:07:46");
insert into t_user (name,age,create_time) values ("zlisi",20,"2024-11-20 12:07:46");結果如下:

(2) 工程模擬
假設現在有一個spring boot的web工程connPoolDemo,提供了兩個查詢接口,分別是根據ID查詢用戶全體信息以及根據ID查詢用戶的姓名。spring boot工程使用的連接池是Druid,工程配置application.yml如下:
server:
port: 8080
spring:
application:
name: @artifactId@
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:8086/camps?serverTimeznotallow=GMT%2B8&characterEncoding=utf8&&allowMultiQueries=true&useSSL=false
username: root
password: 123456
type: com.alibaba.druid.pool.DruidDataSource
druid:
web-stat-filter:
# 是否啟用StatFilter默認值true
enabled: true
# 添加過濾規則
url-pattern: /*
# 忽略過濾的格式
exclusions: /druid/*,*.js,*.gif,*.jpg,*.png,*.css,*.ico
stat-view-servlet:
# 是否啟用StatViewServlet默認值true
enabled: true
# 訪問路徑為/druid時,跳轉到StatViewServlet
url-pattern: /druid/*
# 是否能夠重置數據
reset-enable: false
# 需要賬號密碼才能訪問控制臺,默認為root
login-username: druid
login-password: druid
# IP白名單
allow: 127.0.0.1
# IP黑名單(共同存在時,deny優先于allow)
min-idle: 1 # 最小連接數
max-active: 2 # 最大連接數(默認8)
max-wait: 1000 # 獲取連接時的最大等待時間
min-evictable-idle-time-millis: 300000 # 一個連接在池中最小生存的時間,單位是毫秒
time-between-eviction-runs-millis: 60000 # 多久才進行一次檢測需要關閉的空閑連接,單位是毫秒
connect-timeout: 10000 # 默認是10s
socket-timeout: 1100000 # 默認是10s
mybatis:
mapper-locations: classpath:mapper/*.xml
#開啟駝峰命名
configuration:
map-underscore-to-camel-case: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意需要把上述配置中的這兩個配置connect-timeout和socket-timeout配置大一些,因為jdbc會根據這兩個配置來進行發包測試,默認是10s,如果超過10s,連接沒有響應,就會強行斷開連接。后面會模擬一個長連接占用的情況來占滿連接池,所以這里需要把這兩個參數設置的大一些。另外為了方便測試,我們把最大連接數配置小一點,上述yml文件中最大連接數max-active配置為2。
(3) 模擬長連接占用
connPoolDemo對外提供兩個接口,工程的controller代碼如下:
package com.camps.connpooldemo.controller;
import com.camps.connpooldemo.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.camps.connpooldemo.service.impl.ConnPoolServiceImpl;
@RestController
public class ConnPoolController {
// 示例:使用 HikariCP 作為連接池
@Autowired
private ConnPoolServiceImpl connPoolServiceImpl;
@GetMapping("/getUser")
public User getUser(@RequestParam("id") Long userID) {
return connPoolServiceImpl.getUser(userID);
}
@GetMapping("/getName")
public String getName(@RequestParam("id") Long userID) {
return connPoolServiceImpl.getName(userID);
}
}都是提供get請求,一個是通過ID獲取用戶全量信息,一個是通ID獲取用戶名。再來看對應的mapper代碼:
<?xml versinotallow="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.camps.connpooldemo.mapper.UserMapper">
<resultMap id="userMap"type="com.camps.connpooldemo.model.User">
<result column="id" property="id"/>
<result column="name" property="name"/>
<result column="age" property="age"/>
<result column="sleep_time" property="sleepTime"/>
<result column="create_time" property="createTime"/>
</resultMap>
<sql id="Vo_Column_List">
`id`,
`name`,
`age`,
`sleep_time`,
`create_time`
</sql>
<select id="getUser" resultType="com.camps.connpooldemo.model.User" resultMap="userMap">
select * from t_user where id = #{id} limit 1;
</select>
<select id="getName" resultType="com.camps.connpooldemo.model.User" resultMap="userMap">
select sleep(60) as sleep_time,name from t_user where id = #{id} limit 1;
</select>
</mapper>注意在mapper.xml中,根據用戶ID來獲取用戶名的sql,select sleep(60),name from t_user where id = #{id} limit 1;在這條sql又一個sleep(60),讓這次查詢延遲60s執行。用這個延遲執行來模擬后臺的一些長連接占用情況,以致連接被長期占用,短時間內無法釋放。
(4) 請求測試
啟動服務,服務啟動成功,控制臺如下:

由于我們使用的是Druid,并且在application.yml文件中配置了Druid的可視化信息,所以我們可以通過頁面的形式來查看我們的連接情況,訪問如下地址登陸:http://localhost:8080/druid/login.html,先登錄。

登陸的用戶名和密碼就是我們在上述application.yml文件中配置的druid的用戶名和密碼:;

登陸之后點擊上面的數據源頁簽可以看到數據源的地址,連接等信息,這里我們重點關注連接信息:

可以看到最大連接數以及最小空閑連接數跟我們在application.yml配置的都是一樣的。
模擬請求:
在瀏覽器開兩個頁簽同時訪問http://localhost:8080/getName?id=1這個地址,即發起兩次獲取用戶名的請求。

可以看到這兩個頁面都處于等待轉圈等待的情況,因為獲取姓名這個請求在后臺在Mysql執行查詢的時候需要延遲60s在執行,會處于等待狀態同時瀏覽器打開第三個頁簽發起獲取用戶信息的請求:http://localhost:8080/getUser?id=1,可以看到頁面直接報錯。

等待60s之后,前兩個請求用戶名的請求此時返回了數據zhangsan,如下圖所示:

(5) 連接池爆滿現象識別
看后臺程序,控制臺報錯如下:

獲取連接池超時,可以初步斷定問題為連接池滿了,獲取不到連接所致。
(6) 根因分析
進一步分析sql情況,查看當前數據庫連接的session情況,數據庫執行以命令:
show processlist;
可以看到當前兩條連接,都是執行的查詢操作,并且分別執行了45s和41s都是屬于慢查詢,再結合前面啟動服務時我們在頁面觀測到的當前服務配置的最大連接數是2,所以這兩個慢查詢就將連接池里的連接用完了,并且短時間內不會釋放,所以在第三個請求查詢用戶全量信息的發起的時候,會獲取不到連接,報了獲取連接超時錯誤。
(7) 方案優化
從上面的分析我們就知道了報錯的根本原因就是連接池連接耗盡導致的。而且連接池的最大連接數配置的是2,很小,這樣優化起來就很好做了,我們增大連接池的連接配置,重啟服務再次測試,現連接調整為100,配置完重啟可以看到線上顯示druid的連接池最大連接已經是100了:

接下來,再次重復上面的測試,在兩個頁簽中發起獲取用戶名的請求:http://localhost:8080/getName?id=1,在第三個頁簽中發起獲取用戶全量信息的請求:http://localhost:8080/getUser?id=1。此時可以看到前兩個頁面跟上次測試結果一樣,依舊是在等待:

而第三個頁面馬上返回了用戶信息,結果如下:

過一分鐘后前兩個頁面也會收到返回信息:zhagnsan,跟上次測試一樣。雖然這里通過調大數據庫連接池的配置參數,使問題得到了初步的解決。這里可以進一步分析,通過前面執行show processlist;查看數據庫連接信息的時候,我們發現兩條sql長時間占用連接,導致連接不能有效釋放,使得連接池被占滿,導致第三個請求才一直獲取不到連接而報錯,所以這里的這兩條sql也是需要優化的,具體分析show processlist的結果,初步看后面的sql語句。

再去代碼中確認,看下獲取用戶名請求對應的db曾操作,查看mapper.xml在select語句執行有個sleep(60)的操作,所以這里很明顯會慢,導致延遲60s后才會執行查詢操作。所以把這個地方去掉就可以了。這里只是個demo,為了展示連接池如何優化來做的,到具體的業務場景下,這里的慢sql分析還需要借助explain分析工具來進行分析。再去代碼中確認,看下獲取用戶名請求對應的db曾操作,查看mapper.xml。

確實在查詢的時候,有一個sleep的操作,去掉這個sleep(60)后,mapper.xml文件如下:
<?xml versinotallow="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.camps.connpooldemo.mapper.UserMapper">
<resultMap id="userMap"type="com.camps.connpooldemo.model.User">
<result column="id" property="id"/>
<result column="name" property="name"/>
<result column="age" property="age"/>
<result column="sleep_time" property="sleepTime"/>
<result column="create_time" property="createTime"/>
</resultMap>
<sql id="Vo_Column_List">
`id`,
`name`,
`age`,
`sleep_time`,
`create_time`
</sql>
<select id="getUser" resultType="com.camps.connpooldemo.model.User" resultMap="userMap">
select * from t_user where id = #{id} limit 1;
</select>
<select id="getName" resultType="com.camps.connpooldemo.model.User" resultMap="userMap">
select name from t_user where id = #{id} limit 1;
</select>
</mapper>重啟服務再次進行測試,前兩個頁面發起請求后可以立刻獲得返回數據zhangsan,第三個頁發起請求也可以馬上獲得用戶的全量信息。
6. 小結
數據庫連接池是現在后端開發中必然會使用的一項池化技術,主要是通過對數據庫的連接進行管理和復用,以此來減少頻繁的連接創建,從而減少不必要的性能消耗。但是隨著并發量的提高,連接池在使用上也很容易出現一些問題,最常見的就是連接耗盡,導致請求獲取不到連接而報錯。所以掌握好數據庫連接池問題的排查是非常有必要的,同時我們還應當做好數據庫的一些性能監控,這樣對于連接的使用情況就能比較清晰的觀測到,這樣在要出現問題的時候就會發出告警,從而可以進行及時干預,避免出現線上問題。
























