maxActive 和 max_connections 都设成 151:一次导出功能打满 MySQL 连接池的复盘
原文来源:
事发:凌晨报警
上周三凌晨,报警邮件准时响起:「订单服务接口响应时间 P99 超过 5 秒,部分用户下单失败」。
监控大盘上的数字很直接:MySQL 最大连接数 151,当前活跃连接数 151,可用连接数 0。连接池被榨干了。
订单服务跑在 K8s 里,技术栈是 Java + MyBatis + Druid 连接池。流量并不大,白天 QPS 撑死三百多,按理说不该出现这种情况。
原因:导出功能把连接按住了
业务方新上了一个功能——「用户历史订单导表」。入口藏得深,但一点就触发。
问题就出在这个导出上:一个用户几万条订单记录,这条查询一跑起来,MySQL 连接就被死死按住,最长能持有一分多钟。十个用户同时点导出,连接池就报废了。
当时的配置是 maxActive=151(跟 MySQL 默认最大连接数一样),maxWait=1000ms。单看数值挺正常,但 MySQL 的 max_connections=151,maxActive 也设成 151,意味着正常情况下应用能把 MySQL 的所有连接打满。但凡有任何一个连接卡住,比如那个全表扫描导出,整个系统的吞吐就会塌陷。
应用层的最大连接数要小于数据库最大连接数,留出余量给监控、备份等运维连接。一般推荐 maxActive 设为 max_connections 的 60%–80%。
方案一:查询超时 + kill 掉慢查询
MySQL 本身有 query_timeout 和 innodb_lock_wait_timeout,但默认都很长。线上环境建议:
-- 单独会话设置
SET SESSION MAX_EXECUTION_TIME = 30000; -- 30秒超时
-- 全局设置(需要 SUPER 权限)
SET GLOBAL MAX_EXECUTION_TIME = 30000;
这个方案治标不治本,但能在一定程度上防止单个查询把连接池打爆。
方案二:读写分离 + 离线查询走从库
导出这种操作读多写少,对一致性要求不高,完全可以走从库。更重要的是,从库的连接池和主库隔离,互不影响。
方案三:异步化 + 任务队列
- 生成一个导出任务,写入消息队列;
- 立即返回「导出任务已创建,请在 5 分钟后查看」;
- 后台 worker 慢慢处理,处理完发邮件或站内信通知用户。
这个方案还有个额外好处:并发导出会被消息队列的消费者数量自然限流,不会打爆数据库。
复盘:做错了什么
- 没有对导出接口做限流。任何 IO 密集型操作,理论上都应该有并发上限。
- 没有在主库上设置超时。那条全表扫描查询在 MySQL 侧没有任何时间限制。
- 监控指标不够细。当时只能看到活跃连接数,没有按 SQL 类型分解,看不出是哪条查询在搞事。
复盘:做对了什么
- 故障没有蔓延。连接池超时有熔断,接口快速失败,没有导致级联崩溃。
- 报警及时。P99 超过阈值立刻触发报警,从故障到响应不到十分钟。
- 有预案。值班同学知道先拉流量的操作步骤,不用现场想。
这次事故之后做了三件事:导出功能全部异步化、主库加上了全局查询超时、所有核心接口补上了限流配置。