昨天有个老铁在群里吐槽,说他们线上系统在晚上8点到9点这个高峰期,数据库连接池直接爆了,网站卡得像PPT,用户投诉电话被打爆。作为在数据库坑里摸爬滚打多年的“老中医”,我不得不掏出我的独门秘方。今天咱们就聊聊这个让无数后端工程师头秃的问题——MySQL连接池耗尽,以及一套能救命的全栈优化方案。
一、 连接池耗尽的“罪魁祸首”:别再让数据库“加班”了
首先,咱们得搞清楚,连接池为什么会被耗尽?说白了,就是借出去的钱收不回来,或者借出去的钱太多,不够分了。
在高并发场景下,每个用户请求都可能占用一个数据库连接。如果连接没有被及时释放,或者并发量超过了连接池的最大配置,就会发生连接耗尽。这时候,新的请求就在排队等连接,网站自然就卡了。
常见的原因有这几种:
- 连接泄漏:这是最坑爹的。代码里开了连接,用完忘了关,或者异常没处理好,连接就“死”在那儿了。
- 慢查询:一个查询执行了十几秒,这个连接就一直被占用,其他请求只能干等。
- 连接池配置不合理:最大连接数设得太小,或者等待获取连接的超时时间太短。
- 大事务:一个事务里干了一堆事,耗时很长,连接一直被占用。
1.1 如何定位“泄漏”的连接?
别急着改代码,先用SQL看看现在连接都在干嘛:
-- 查看当前活跃连接数和最大连接数
SHOW STATUS LIKE 'Threads_connected';
SHOW VARIABLES LIKE 'max_connections';
-- 查看当前正在执行的查询,重点关注Time字段
SHOW FULL PROCESSLIST;
-- 如果发现有连接状态是“Sleep”,且时间很长,那可能就是连接泄漏了
-- 可以通过以下SQL杀掉长时间空闲的连接(谨慎操作,确认业务无影响)
-- KILL [CONNECTION|QUERY] connection_id;
举个例子,我之前的一个项目,就是有个地方在catch块里没有关闭连接,结果连接池慢慢被占满,系统越来越慢,最后直接崩了。排查的时候,用SHOW PROCESSLIST一眼就看出来了,一堆“Sleep”状态的连接,时间都是几分钟甚至十几分钟。
二、 读写分离:给数据库“减负”的利器
连接池耗竭的本质是“需求大于供给”。与其一味地增加连接数,不如优化请求的流向。读写分离,就是把读操作和写操作分开,分别由不同的数据库实例来处理。
通常,写操作(INSERT, UPDATE, DELETE)比较少,但比较重,需要保证强一致性,放在主库(Master)上。读操作(SELECT)非常多,可以容忍一定的延迟,放到从库(Slave)上。这样,大量的读请求就不会去挤主库的资源了。
2.1 架构示意图
+---------------------+
| 客户端/应用 |
+---------------------+
|
+----------+----------+
| |
+-------+ +-------+
| 写请求 | | 读请求 |
+-------+ +-------+
| |
v v
+----------+ +----------+
| MySQL主库 | <---同步-- | MySQL从库 |
| (Master) | | (Slave) |
+----------+ +----------+
2.2 如何实现读写分离?
有几种常见的方案:
- 应用层实现:在代码里硬编码,根据SQL语句是SELECT还是其他,决定连哪个数据库。这种方式灵活,但侵入性强,后期维护麻烦。
- 中间件实现(推荐):使用像MyCat、ShardingSphere这样的数据库中间件。中间件会自动根据SQL类型,将请求路由到对应的数据库。对应用层透明,配置也相对简单。
比如用ShardingSphere的配置,大概长这样:
# sharding-jdbc 配置示例
spring:
shardingsphere:
datasource:
names: master,slave
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://master-db:3306/your_db
username: root
password: your_password
slave:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave-db:3306/your_db
username: root
password: your_password
masterslave:
load-balance-algorithm-type: round_robin # 从库负载均衡策略
name: ms
master-data-source-name: master
slave-data-source-names: slave
props:
sql-show: true # 打印SQL,方便调试
这样,你只需要配置好主从同步,然后在应用里配置读写分离规则,剩下的路由工作就交给中间件了。
三、 索引策略:让查询“快人一步”
数据库连接被占用时间长,很多时候是因为查询太慢。优化索引,是提升查询性能最直接、最有效的手段。
3.1 索引优化的基本原则
- 最左前缀原则:复合索引
INDEX (a, b, c),查询条件要遵循a->a,b->a,b,c的顺序,跳过前面的字段,索引效果会大打折扣。 - 避免在索引列上做计算:
WHERE age + 1 = 25,这种写法会导致索引失效。应该写成WHERE age = 24。 - 使用覆盖索引:如果查询的列都在索引里,可以直接从索引中获取数据,不需要回表,速度飞快。比如
SELECT id, name FROM user WHERE status = 1,如果(status, id, name)有索引,就可以覆盖。 - 避免
SELECT *:只查需要的列,减少I/O,也有利于覆盖索引。
3.2 如何找到慢查询并优化?
开启慢查询日志,是排查问题的第一步。
-- 查看慢查询日志是否开启
SHOW VARIABLES LIKE 'slow_query_log%';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 设置超过1秒的查询记录为慢查询
然后,用EXPLAIN分析你的SQL:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
重点关注type字段:
system>const>eq_ref>ref>range>index>ALL
目标是让type尽可能靠前,最好是ref或range。如果看到type: ALL,那就是全表扫描,必须优化索引了。
3.3 一个真实的索引优化案例
我见过一个订单表,数据量几百万,查询总是很慢。原本的索引是(user_id),但查询条件经常是user_id + status + create_time排序。
优化前:
-- 执行时间:2.5秒,type: ref, Extra: Using filesort (排序很耗时)
SELECT * FROM orders WHERE user_id = 10086 AND status = 'PAID' ORDER BY create_time DESC LIMIT 20;
优化方案:创建复合索引(user_id, status, create_time)。
优化后:
-- 执行时间:0.05秒,type: ref, Extra: Using index condition (索引覆盖)
SELECT * FROM orders WHERE user_id = 10086 AND status = 'PAID' ORDER BY create_time DESC LIMIT 20;
速度提升了50倍!这就是索引的力量。
四、 缓存机制:把热点数据“搬”到内存里
即使优化了索引,有些查询还是慢,或者数据量实在太大,那就可以考虑引入缓存了。缓存的原理很简单:把经常访问的数据放在内存(如Redis)里,下次直接读内存,不用去查数据库。
4.1 缓存架构
+---------------------+
| 客户端/应用 |
+---------------------+
|
+----------+----------+
| |
+-------+ +-------+
| 读请求 | | 写请求 |
+-------+ +-------+
| |
v v
+----------+ +----------+
| Redis缓存 | <---失效/更新--> | MySQL数据库 |
+----------+ +----------+
4.2 缓存的使用策略
Cache-Aside Pattern(旁路缓存):
- 读:先查缓存,命中就返回;未命中,查数据库,然后写入缓存。
- 写:先更新数据库,再删除缓存。
- 这是最常用的方式,简单可靠。
Write-Through/Write-Behind:
- 写:先写缓存,再由缓存异步/同步写数据库。
- 一致性更好,但复杂度也更高。
4.3 用Redis做缓存的示例
假设我们有一个获取用户信息的接口:
// 伪代码,展示Cache-Aside模式
public User getUserInfo(Long userId) {
// 1. 先从Redis缓存中获取
String cacheKey = "user:" + userId;
User user = redisTemplate.opsForValue().get(cacheKey);
// 2. 如果缓存命中,直接返回
if (user != null) {
return user;
}
// 3. 缓存未命中,从数据库查询
user = userDao.selectById(userId);
// 4. 查询到数据,写入缓存,设置过期时间(防止缓存污染)
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, user, 10, TimeUnit.MINUTES);
}
return user;
}
// 更新用户信息时
public void updateUser(User user) {
// 1. 先更新数据库
userDao.updateById(user);
// 2. 再删除缓存(不是更新缓存,避免并发问题导致缓存不一致)
String cacheKey = "user:" + user.getId();
redisTemplate.delete(cacheKey);
}
通过缓存,大量对热点数据的读取请求直接被Redis拦截,不再打到MySQL,数据库的连接压力瞬间就小多了。
五、 综合实战:架构优化组合拳
单一的优化手段往往有局限性,真正的解决之道,是“组合拳”。
- 连接池调优:首先,检查并优化连接池配置。比如,HikariCP的最大连接数可以根据并发量和平均查询耗时来估算:
max_connections = 并发请求数 * 平均查询耗时(秒) + 缓冲连接数。同时,确保代码中连接的正确释放,避免泄漏。 - 读写分离:将读流量分散到从库,减轻主库压力。这是架构层面最直接的有效手段。
- 索引优化:针对慢查询,逐一优化索引。这是提升单条查询性能的根本。
- 缓存介入:对于高频读取、低频更新的热点数据,引入Redis缓存。这是“削峰填谷”的神器。
- 分库分表:如果数据量实在太大,单库无法承载,再考虑分库分表。这是最后的“杀手锏”,复杂度较高,要谨慎使用。
记住,优化不是一蹴而就的,要不断地监控、分析、调整。使用像Prometheus、Grafana这样的监控工具,时刻关注数据库的连接数、QPS、慢查询率等关键指标,才能防患于未然。
希望这篇干货能帮到正在被数据库连接池问题困扰的你。如果觉得有用,记得点赞收藏,关键时刻能救命!
