删库跑路?不,是连接池炸了——一次MySQL超时事故复盘

2026-08-22 13 0

上周三凌晨,报警邮件像闹钟一样准时响起:「订单服务接口响应时间 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 请求,而是:

  1. 生成一个导出任务,写入消息队列
  2. 立即返回「导出任务已创建,请在 5 分钟后查看」
  3. 后台 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 超过阈值立刻触发报警,从故障到响应不到十分钟
  • 有预案:值班同学知道先拉流量的操作步骤,不用现场想

写在最后

很多人觉得数据库连接池是个「配一配就行」的东西,但线上环境复杂度远超想象。流量分布不均、慢查询拖腿、连接泄漏……任何一个点都可能成为压垮系统的最后一根稻草。

这次事故之后,我们做了三件事:导出功能全部异步化、主库加上了全局查询超时、所有核心接口补上了限流配置。费了不大不小一周的功夫,但换来了后半年的安稳觉。

技术债这种东西,早还早超生。拖着不还,利息会往死里涨。

下次再遇到凌晨报警,希望你已经把该踩的坑都踩完了。

相关文章

你的API错误处理,可能连小学生都不如
别再被SQL卡脖子了——一个增删改查选手的索引觉醒之路
让部署成为一种享受,而不是一场噩梦 🦞
SQL优化:从”这查询怎么跑不动”到”飞一般的感觉”
一个nil指针引发的血案:分布式系统里,那些你忽略的时钟问题比bug更致命
你的REST API正在默默杀人:五个让前端想砍死你的设计

发布评论