上周三凌晨,报警邮件像闹钟一样准时响起:「订单服务接口响应时间 P99 超过 5 秒,部分用户下单失败」。
作为一个写了五年 CURD 的增删改查侠,我深知这种问题的套路——要么是数据库慢查询,要么是连接池见底,要么是网络抖动。但当你半夜爬起来打开监控大盘,看到连接数图表的时候,你会有一种想把服务器电源拔了的冲动:
MySQL 最大连接数:151
当前活跃连接数:151
可用连接数:0
是的,连接池被榨干了,一滴不剩。
事故现场还原
先交代一下背景。我们的订单服务跑在 K8s 里,用的是 Java + MyBatis + Druid 连接池。流量不大,白天 QPS 撑死三百多。按理说不该出现这种问题。
但是,业务方新上了个功能——「用户历史订单导表」。入口藏得深,但一点就触发。问题就出在这个导出功能上:
// 伪代码,大概长这样
public List<Order> exportOrders(Long userId) {
// 全表扫描,没错,就是全表扫描
return orderMapper.selectByUserId(userId);
}
// MyBatis XML 里是这样的
<select id="selectByUserId" resultType="Order">
SELECT * FROM orders WHERE user_id = #{userId}
<!-- 没有 LIMIT -->
</select>
一个用户几万条订单记录,这条查询一跑起来,MySQL 连接就被死死按住,最长能持有一分多钟。十个用户同时点导出,连接池就报废了。
你可能会说,这不就是加个 LIMIT 的事吗?道理是这么个道理,但问题在于——这种事每天都在生产环境里上演。区别只是,炸不炸而已。
连接池,你真的了解它吗?
很多人以为连接池就是「初始化 n 个连接,用完放回去」。如果这么简单,就不会有这篇文章了。
Druid 连接池有几个关键参数,面试必问,但真正线上能答对的没几个:
- maxActive:最大活跃连接数,决定你能扛多少并发
- minIdle:最小空闲连接数,热启动用的
- maxWait:获取连接超时时间,堵死之前的最后一道防线
- testWhileIdle:空闲时要不要检测连接有效性
- timeBetweenEvictionRunsMillis:空闲回收线程的运行间隔
我们当时的配置是 maxActive=151(跟 MySQL 默认最大连接数一样),maxWait=1000ms。听起来挺正常对吧?
但问题来了:MySQL 的 max_connections=151,而我们的 maxActive 也设成 151。这意味着什么?意味着正常情况下我们能打满 MySQL 的所有连接。但凡有任何一个连接卡住(比如那个该死的全表扫描导出),整个系统的吞吐就会塌陷。
正确的做法是:应用层的最大连接数要小于数据库最大连接数,留出余量给监控、备份等运维连接。一般推荐 maxActive 设为 max_connections 的 60%-80%。
# MySQL 这一侧
max_connections = 200
# 应用侧 Druid
maxActive = 120 # 保留 40% 余量
maxWait = 3000 # 适当放大超时,给慢查询一点耐心(但别太大)
除了调参,还能做什么?
调参是亡羊补牢,真正有效的手段是在架构层面解决。下面几种方案,从轻到重:
方案一:查询超时 + kill 掉慢查询
MySQL 本身有 query_timeout 和 innodb_lock_wait_timeout,但默认都很长。线上环境建议:
-- 单独会话设置
SET SESSION MAX_EXECUTION_TIME = 30000; -- 30秒超时
-- 全局设置(需要 SUPER 权限)
SET GLOBAL MAX_EXECUTION_TIME = 30000;
这个方案治标不治本,但能在一定程度上防止单个查询把连接池打爆。
方案二:读写分离 + 离线查询走从库
导出这种操作,读多写少,对一致性要求不高,完全可以走从库。更重要的是,从库的连接池和主库隔离,互不影响。
DruidDataSource masterDataSource; // 写操作
DruidDataSource slaveDataSource; // 读操作(包含导出)
// Dynamic DataSource,自动化路由
@Bean
public DataSource dataSource() {
return new DynamicDataSource(masterDataSource, slaveDataSource);
}
方案三:异步化 + 任务队列
这是最优雅的方案。用户点击导出,不阻塞 HTTP 请求,而是:
- 生成一个导出任务,写入消息队列
- 立即返回「导出任务已创建,请在 5 分钟后查看」
- 后台 worker 慢慢处理,处理完发邮件/站内信通知用户
这个方案有个额外的好处:并发导出会被消息队列的消费者数量自然限流,永远不会打爆数据库。
// 接口层:快速返回
@PostMapping("/export")
public Result<String> exportOrders(Long userId) {
String taskId = UUID.randomUUID().toString();
exportTaskQueue.send(new ExportTask(taskId, userId));
return Result.ok("导出任务已创建,任务ID:" + taskId);
}
// 后台 Worker:慢慢处理
@RabbitListener(queue = "order-export-queue")
public void handleExport(ExportTask task) {
// 走从库,带 LIMIT 分页查询
List<Order> orders = orderMapper.selectByUserIdPaged(task.getUserId(), 0, 1000);
// 生成 Excel,上传到 OSS
// 发送通知
}
事后复盘:我们做对了什么,做错了什么
先说做错的:
- 没有对导出接口做限流:任何 IO 密集型操作,理论上都应该有并发上限
- 没有在主库上设置超时:那条全表扫描查询在 MySQL 侧没有任何时间限制
- 监控指标不够细:我们当时只能看到活跃连接数,没有按 SQL 类型分解,看不出是哪条查询在搞事
再说做对的:
- 故障没有蔓延:连接池超时有熔断,接口快速失败,没有导致级联崩溃
- 报警及时:P99 超过阈值立刻触发报警,从故障到响应不到十分钟
- 有预案:值班同学知道先拉流量的操作步骤,不用现场想
写在最后
很多人觉得数据库连接池是个「配一配就行」的东西,但线上环境复杂度远超想象。流量分布不均、慢查询拖腿、连接泄漏……任何一个点都可能成为压垮系统的最后一根稻草。
这次事故之后,我们做了三件事:导出功能全部异步化、主库加上了全局查询超时、所有核心接口补上了限流配置。费了不大不小一周的功夫,但换来了后半年的安稳觉。
技术债这种东西,早还早超生。拖着不还,利息会往死里涨。
下次再遇到凌晨报警,希望你已经把该踩的坑都踩完了。