MySQL 连接池耗尽事故——从 300 连接到 10000 连接的雪崩现场还原

发布时间:2026/7/7 20:23:55
MySQL 连接池耗尽事故——从 300 连接到 10000 连接的雪崩现场还原 MySQL 连接池耗尽事故——从 300 连接到 10000 连接的雪崩现场还原一、事故全貌17:23 开始的 12 分钟17:23监控系统发出第一条告警某核心服务的 P99 响应时间从 120ms 跳升至 2800ms。紧接着MySQL 的连接数监控从日常的 300 飙升至 1200持续攀升。17:27MySQL 服务端连接数达到max_connections2000的上限新的连接请求被拒绝。17:29业务入口开始返回 HTTP 500。17:35运维介入执行流量降级和连接池重置服务恢复。从第一个异常信号到服务完全不可用间隔 12 分钟。这 12 分钟内MySQL 连接数从 300 膨胀到 10000包括积压的等待连接雪崩的速度远超人工介入的能力。事后复盘发现事故的根因链条涉及三个环环相扣的组件应用层连接池配置、慢查询放大效应、以及重试逻辑的正反馈循环。sequenceDiagram participant Client as 上游调用方 participant App as Go 应用服务 participant Pool as 连接池 (sql.DB) participant MySQL as MySQL Server Note over App: 阶段 1: 正常运行 (17:20) App-MySQL: 慢查询开始积累 (5s/次) Note over MySQL: 慢查询占用连接连接释放变慢 Note over App: 阶段 2: 连接池开始饱和 (17:23) Client-App: 请求速率不变300 QPS App-Pool: 获取连接 Pool--App: 等待空闲连接 (连接池已满) Note over Pool: MaxOpenConns200全部被慢查询占用 Note over App: 阶段 3: 连接池耗尽 (17:25) App-Pool: GetConn 阻塞超时 → 返回 error App-Client: HTTP 500 返回 Note over App: 阶段 4: 重试放大 (17:27) Client-App: 自动重试 (3次退避) App-Pool: 重试请求再次申请连接 Note over Pool: 连接请求积压等待队列暴增 App-MySQL: 连接数突破 max_connections MySQL--App: Too many connections 拒绝新连接 Note over MySQL: 阶段 5: 全面崩溃 (17:29) MySQL--App: 连接拒绝雪崩锁定 App-Client: 所有请求返回 500二、根因分析三个致死因素的叠加2.1 连接池配置罪MaxOpenConns 的保护性反噬Go 的database/sql包提供的连接池有两个核心参数SetMaxOpenConns最大打开连接数和SetMaxIdleConns最大空闲连接数。事故服务中的配置如下// 事故前配置——看似保守实则隐患重重 db.SetMaxOpenConns(200) // 最大 200 个连接 db.SetMaxIdleConns(50) // 空闲保持 50 个 db.SetConnMaxLifetime(0) // ⚠️ 连接永不过期这组配置的问题在于200 个最大连接看起来很保守防止打爆 MySQL但在慢查询场景下这 200 个连接会被全部占用。当所有连接都在等待慢查询返回时新的请求会在db.QueryContext的GetConn环节阻塞直到context.Context超时。更致命的是SetConnMaxLifetime(0)。MySQL 默认的wait_timeout是 28800 秒8 小时如果一个连接在 MySQL 端被关闭而 Go 连接池没有感知再次使用该连接时会触发 invalid connection 错误。连接池会尝试重新创建连接这在连接数已经接近上限的情况下会加剧争抢。2.2 慢查询放大效应事故的触发点是一条未加索引的查询。这张order_logs表积累了 2 亿行数据查询某时间区间内的订单状态变化时扫描了全表单次查询耗时 4-6 秒-- 事故触发的慢查询已脱敏 -- 问题status 和 created_at 上没有联合索引 -- 导致 2 亿行全表扫描单次查询 4-6 秒 SELECT order_id, status, created_at FROM order_logs WHERE status PENDING AND created_at BETWEEN 2024-06-01 AND 2024-07-01 ORDER BY created_at DESC LIMIT 100;这条查询的每秒调用频率约为 15 QPS。在正常响应时间50ms下15 QPS 只消耗约 1 个连接。但在 5 秒响应时间下15 QPS 会持续占用约 75 个连接15 × 5 75。加上其他正常请求的 80-100 个连接总连接数轻松突破了 200 的上限。2.3 重试逻辑的正反馈循环上游调用方的自动重试机制是雪崩的最终加速器。当调用方收到 HTTP 500 后会执行指数退避重试最多 3 次// 上游服务的重试逻辑——在降级的服务面前变成了攻击放大器 func callDownstreamWithRetry(ctx context.Context, req *Request) (*Response, error) { backoff : 100 * time.Millisecond for attempt : 0; attempt 3; attempt { resp, err : httpClient.Do(req) if err nil resp.StatusCode 500 { return parseResponse(resp) } // 退避重试100ms → 200ms → 400ms time.Sleep(backoff) backoff * 2 } return nil, fmt.Errorf(downstream unavailable after 3 retries) }这个重试逻辑在故障中产生了致命的正反馈每次失败的重试都会向已经满负荷的连接池再次发起连接请求而这些请求又会因为连接池耗尽而失败。失败的请求触发新的重试新请求再次失败。在这个恶性循环下下游积压的连接请求很快从数百膨胀到数千。三、修复措施的层次化防御修复不是换一个更大的MaxOpenConns数字那么简单。有效的防御需要三个层次第一层源头防慢查询。在order_logs的(status, created_at)上创建联合索引将查询时间从 5 秒降低到 12ms。消除慢查询后连接池的水位从 200 降到约 60回到了安全区间。第二层连接池配置优化。// 优化后的连接池配置——三重防护 db.SetMaxOpenConns(100) // 降低上限避免单个服务占用过多连接 db.SetMaxIdleConns(20) // 减少空闲连接降低 MySQL 负担 db.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活 30 分钟自动回收 db.SetConnMaxIdleTime(5 * time.Minute) // 空闲 5 分钟后自动关闭第三层调用方增加熔断。引入 Circuit Breaker当错误率超过 50% 时自动熔断停止发送请求 30 秒断开重试的正反馈循环// 使用 sony/gobreaker 实现调用方熔断 var cb gobreaker.NewCircuitBreaker(gobreaker.Settings{ Name: mysql-service, MaxRequests: 3, // 半开状态允许 3 个探测请求 Interval: 30 * time.Second, // 熔断 30 秒 Timeout: 10 * time.Second, // 请求超时 ReadyToTrip: func(counts gobreaker.Counts) bool { failureRatio : float64(counts.TotalFailures) / float64(counts.Requests) return counts.Requests 10 failureRatio 0.5 }, })四、从单一事故中提取通用模式这个事故反映了一个通用的故障模式在资源池化架构中慢操作的正反馈放大是最危险的故障模式。连接池、线程池、goroutine 池都遵循同样的逻辑——当池中所有资源被慢操作占用时新请求排队排队触发超时超时触发重试重试增加排队压力。防御这类故障的核心思路不是增大池的容量那只会延缓故障发生的时间而是缩短任何单一操作对池资源的占用时间。具体手段包括数据库查询强制设置Statement Timeout在应用层使用context.WithTimeout为每个数据库操作设置上限在连接池层设置ConnMaxLifetime防止僵尸连接长期占用。另一个从事故中学到的教训是所有自动重试都必须配备熔断机制。重试可以掩盖短暂的网络抖动但在下游已经过载的情况下重试就是自杀指令。五、总结这次 MySQL 连接池耗尽事故暴露了一个典型的级联故障链慢查询占用连接池 → 新请求排队等待 → 连接池耗尽返回错误 → 上游重试放大请求量 → MySQL 拒绝新连接 → 服务完全不可用。核心修复有三个层次通过索引优化消除慢查询源头5s → 12ms调整连接池配置MaxOpenConns 从 200 降到 100增加 ConnMaxLifetime 和 ConnMaxIdleTime 回收机制上游引入 Circuit Breaker 在错误率超 50% 时自动熔断以切断重试正反馈。通用防御原则资源池化的系统中慢操作是最危险的风险因子。确保每一个数据库操作都有 timeout 纪律应用层 context 数据库层 statement timeout确保每一条重试链路都有熔断保护。