云数据库负载均衡冷门翻车实录:连接池养蛊、假健康检查与内核TCP保活
先说一个真实事故:某天凌晨 2 点,线上云数据库集群的主库 CPU 突然冲到 100%,但所有监控图表里负载均衡器(LVS+Keepalived)的连接数曲线平滑得像心电图。查了半小时才发现,罪魁祸首是后端 MySQL 实例已经进入“半死不活”状态,TCP 端口照样能通,但查询全部卡在 `Waiting for table metadata lock` 上。而负载均衡器只检查端口存活,还在把新流量往这个“活尸体”上堆。
这不是个例。我手上有几台轻云互联的 BGP 多线服务器专门跑数据库集群,跨机房多活场景下,几乎把负载均衡相关的坑都踩了一遍。下面三个冷门操作,每一个都救过我的命。
一、把健康检查从“端口探活”升级成“SQL 语义探活”
默认情况下,HAProxy 或 LVS 的 TCP 健康检查只验证“IP:Port 能建立连接”。对 MySQL 来说,这个检查的欺骗性极强:实例锁死、`read_only=ON`、InnoDB 崩溃恢复中、连接数打满——这些状态下 3306 端口都正常响应。
怎么整?用 HAProxy 的 mysql-check,让负载均衡器在握手层就验证 MySQL 协议可用性:
listen mysql_proxy
bind 0.0.0.0:3306
mode tcp
balance leastconn
option mysql-check user haproxy_check post-41
server mysql1 192.168.1.10:3306 check inter 5s fall 3 rise 2
server mysql2 192.168.1.11:3306 check inter 5s fall 3 rise 2 backup
关键点:这个检查用户密码必须留空。HAProxy 发送的握手包中密码长度字段为 0,如果用户带了密码,检查永远失败。
CREATE USER 'haproxy_check'@'%' IDENTIFIED WITH mysql_native_password BY '';
GRANT SELECT ON *.* TO 'haproxy_check'@'%';
但这还不够。真正的冷门技巧是:如果你用 LVS/Keepalived,MISC_CHECK 里跑一条语义探活 SQL,直接检测 MySQL 的内部运行状态:
virtual_server 192.168.1.100 3306 {
delay_loop 10
lb_algo wrr
lb_kind NAT
protocol TCP
real_server 192.168.1.10 3306 {
weight 1
MISC_CHECK {
misc_path /etc/keepalived/check_mysql_running.sh
misc_timeout 3
}
}
}
#!/bin/bash
# 语义探活:如果Threads_running超过300,或存在锁等待,立即摘除节点
RUNNING=$(mysql -uroot -p'xxx' -N -e "SHOW GLOBAL STATUS LIKE 'Threads_running'" | awk '{print $2}')
if [ "$RUNNING" -gt 300 ]; then
exit 1
fi
LOCKED=$(mysql -uroot -p'xxx' -N -e "SELECT COUNT(*) FROM information_schema.INNODB_TRX WHERE trx_state='LOCK WAIT'")
if [ "$LOCKED" -gt 10 ]; then
exit 1
fi
exit 0
这个脚本能拦截掉 90% 的“假健康节点”。别再只用 nc -zv IP 3306 了,那是给初学者用的。
二、连接池寿命必须与拉黑时间联动,否则就是养蛊
很多团队配置完负载均衡器就撒手不管了,结果客户端连接池里的旧连接还在持续复用。假设 HAProxy 检测到后端故障并把节点摘除,但业务侧连接池已经创建了 1000 条到该节点的 TCP 长连接。此时负载均衡器完全来不及转发,这些旧连接直接打到一个正在重启的 MySQL。业务端报错 MySQL server has gone away,然后连接池疯狂重建连接,瞬间把剩下的健康节点也打爆。
冷门解法:把客户端连接池的 ConnMaxLifetime 缩短到负载均衡的健康检查周期乘以一个系数。以 Go 为例:
db, _ := sql.Open("mysql", "user:pass@tcp(lb.example.com:3306)/app")
db.SetMaxOpenConns(200)
db.SetMaxIdleConns(50)
// 核心:连接最大存活时间,必须小于LB摘除节点的感知时间
db.SetConnMaxLifetime(3 * time.Minute)
// 连接空闲超过60s就丢弃,避免堆积死连接
db.SetConnMaxIdleTime(60 * time.Second)
为什么是 3 分钟?因为 HAProxy 默认 inter 5s fall 3,差不多 15 秒能感知故障并摘除。但如果客户端还在用旧连接,TCP 层要等超时才能发现连接断开。3 分钟的 ConnMaxLifetime 能保证即使连接被断了,也最多影响一小部分请求,而不是让整个连接池的旧连接同时失效,导致雪崩。
更重要的是:千万别在代码里用无限长的连接池。云数据库背后的负载均衡器升级、重启、切换主备时,所有长连接都会被重置,无限长的连接池就是一颗定时炸弹。
三、内核 TCP Keepalive 参数:让死连接在负载均衡层现形
这是最冷门也最容易被忽略的一层。业务连接池和 MySQL 之间跨了负载均衡器,一旦 LB 后端节点出现半开连接(比如数据库突然断电、网线松动、内核 panic),客户端和 LB 都认为连接还活着。默认情况下,Linux tcp_keepalive_time 是 7200 秒,也就是 2 小时后才第一次探测。在这 2 小时内,所有查询都卡在写 socket 上,直到应用层超时。
把数据库服务器和负载均衡器的内核参数都调成下面这样:
# 在 LB 和所有数据库节点上执行
cat >> /etc/sysctl.conf <<'EOF'
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 5
net.ipv4.tcp_keepalive_probes = 3
EOF
sysctl -p
参数含义:60 秒没有数据包就发送探测包,之后每 5 秒发一次,连续 3 次无响应就判定连接死亡。要知道,对于跨机房部署(比如轻云互联的BGP多线链路),TCP 探测包消耗的带宽几乎可以忽略,但它能让你在 75 秒内就砍掉一条死连接,而不是傻等 2 小时。
有个坑必须提醒:如果你用的是 LVS NAT 模式,光改后端节点不够,LVS 所在的 Director 也要改。另外 Keepalived 的 virtual_server 里可以加 alpha 和 omega 选项,让它在健康检查失败时直接发送 RST 杀掉旧连接,而不是留着等超时:
virtual_server 192.168.1.100 3306 {
delay_loop 5
alpha # 节点下线时发送RST切断已有连接
omega
protocol TCP
lb_algo wrr
lb_kind NAT
}
但注意:开启 alpha 后,故障切换瞬间会中断该节点上所有正在执行的事务。如果你们的业务对事务中断敏感,建议配合下面的“优雅摘除”方案。
四、摘节点前先做“优雅止血”,别直接 kill 连接
维护云数据库节点时,绝大部分人的操作顺序是:停 MySQL → 等 LB 健康检查失败 → 摘除节点。这是错误的。停 MySQL 会立刻让负载均衡器上所有到这个节点的活跃连接直接中断,主从同步可能因此中断。
正确的冷门操作顺序:
# 1. 先在 MySQL 层把节点设为只读,让负载均衡器上的写流量自然转移
SET GLOBAL read_only = ON;
SET GLOBAL super_read_only = ON;
# 2. 查看当前还有多少读写连接,等待排空
SHOW GLOBAL STATUS LIKE 'Threads_running';
-- 等待 Threads_running 降到 10 以下
# 3. 此时再去降 LB 权重或摘除节点
# 4. 最后才执行干净关闭
mysqladmin -uroot -p shutdown
这套流程能让负载均衡器平滑地把连接迁移到其他节点,而不是一刀切。特别是对跨机房的从库读流量分发,先 SET GLOBAL read_only = ON 能让写连接快速失败,客户端连接池自动重连到主库,整个过程对外没有任何感知。
顺带说一句,上面提到的 LVS/Keepalived 配置在轻云互联的裸金属服务器上跑得非常稳,他们的机房网络对 TCP 长连接的跟踪表项处理得不错。如果你打算做跨地域多活,这个方法值得直接在业务中上线。
总结起来就三句话:别信端口探活,要信 SQL 探活;连接池寿命要短,要能随节点故障快速自愈;内核 TCP Keepalive 一定要调,那是最后一道防线。 这三板斧下去,你的云数据库负载均衡才能真正扛得住流量高峰。