负载均衡后面的MySQL在“无声抖动”——数据库节点IO调度器乱序与排队延迟的调优复盘
一、现象:负载均衡没毛病,数据库自己“内讧”了
一套典型的业务架构:前端挂了4台云服务器,使用LVS Keepalived或自研四层网关,后端是主从架构的云数据库(MySQL 8.0,Percona分支),存储用NVMe SSD。压测时从负载均衡器上看流量分配无比均匀,每个DB节点的QPS差距在3%以内,但聚焦单条SQL耗时,发现p99.9极不稳定——从正常的8ms飙到200ms以上,而且这种抖动并非来自慢查询或锁等待。
排查时抓了数据库内部的等待事件,发现大量线程卡在io/sql/innodb/buf_flush_page和io/socket/accept上。这是个非常典型的信号,问题不在数据库上层逻辑,而在底层存储IO处理路径上。真正的原因是:NVMe SSD在纯随机小写场景下,Linux内核IO调度器默认参数把InnoDB的刷脏顺序打乱了,导致大量“过期”IO请求堆积在队列里疯狂重排。而负载均衡器还在持续把新请求无差别地打进来,每个新事务都要在IO队列后面排队,最终表现为所有连接都在等一块硬盘。
1. IO调度器带来的是什么问题?
先验证一下你当前环境用的调度器。NVMe设备默认通常是none,也就是noop,但这只是在virtio-blk或云盘上才“可能”没问题。如果你是裸金属服务器或物理机自建的云数据库,SSD走的NVMe原生驱动,内核版本低于5.0时如果没有显式设置,很多系统会落到mq-deadline。而mq-deadline对数据库这种需要稳定随机读写的负载,会产生两个副作用:写合并窗口和读优先级的饥饿补偿。
先做个快速判断,执行:
cat /sys/block/nvme0n1/queue/scheduler
# 如果输出包含 [mq-deadline] 或 [cfq] / [bfq],就说明调度器不是最优状态
# 期望输出:none
echo none > /sys/block/nvme0n1/queue/scheduler
但只改调度器还远远不够。即便切到none,请求直接进块设备驱动层,MySQL如果没用O_DIRECT打开redo和datafile,页面数据依然会经过Page Cache的“伪随机写放大”。这里有一个关键的隐藏参数组合:InnoDB的doublewrite buffer如果在NVMe上还是默认开启的状态,等于强制让每个脏页多写一遍,负载均衡送进来的并发连接越多,这一遍额外的写放大越致命。
很多DBA在云数据库上不敢关doublewrite,因为担心掉电页损坏从库扯皮。但你需要在NVMe直通、带电容掉电保护的硬件上(轻云互联的高频裸金属云数据库系列用的是intel P5510或类似读写型NVMe,且标配BBU),把逻辑写入路径彻底捋清楚:
# 在MySQL配置文件里确认以下参数,压测前统一调整
[mysqld]
innodb_flush_method=O_DIRECT
innodb_use_native_aio=ON
innodb_doublewrite=OFF
innodb_flush_neighbors=0
innodb_flush_neighbors=0尤其容易被忽略。默认值是1,意思是InnoDB刷脏时如果发现相邻页也在缓冲池里,会顺带一起刷出去。这个设计本来是减少传统机械硬盘的寻道时间,但放到NVMe上却是制造IO队列阻塞的元凶。
把上面四项设好之后,重启MySQL,再看性能数据。如果压测的QPS从2万涨到了4万以上,说明瓶颈确实在IO调度与刷盘路径上。
二、吞吐上去了,又暴露出连接风暴和线程切换问题
IO路径理顺之后,你的DB节点吞吐确实上来了,但负载均衡器后端的连接分布图开始变得诡异:主库连接数占用率仅为30%,从库却已经堆到80%满。业务侧访问从库的接口大多是一些带GROUP BY和ORDER BY的报表查询,往往一次查询要拖几十万行回内存排序。
从现象上看像是负载均衡策略不均,实际是MySQL的thread cache与连接池特性导致从库连接长时间“占着茅坑不拉屎”。这里我们需要把视角从“连接数”转换到“并发度”:一个数据库节点的性能上限不取决于它能接纳多少连接,而在同一时刻能真正在CPU上跑的活跃线程数。
常见误区:发现慢查询多,就到负载均衡上增加后端连接池上限。错。连接池撑大,意味着MySQL端要创建更多thread,每一次上下文切换的损耗都在加剧。
排查步骤:
SHOW GLOBAL STATUS LIKE 'Threads_running';
# 运行期观察如果Threads_running持续大于 CPU核心数 * 4,那多半是SQL本身太烂或者负载均衡把复杂查询跑到了同一节点
如果确认Nginx或HAProxy转发层有keepalive超时配置,比如timeout client 60s,那么一个空闲连接在MySQL端并不能释放,MySQL 8.0的默认wait_timeout是8小时。从库连接被打满后,max_connections就是最后一道防线。传统方案会直接调大max_connections,但这里有个后果:即使连接不活跃,每个连接还要占8MB的临时内存排序缓存。当几百个闲置连接挂在那边,系统可用内存被吃掉,后续SWAP到磁盘上时,DB的IO路径再次恶化——然后就变成了恶性循环。
应对“连接占用型”负载,光靠调max_connections不够。我们需要从负载均衡器本身的调度粒度上做改造。建议把负载均衡的proxy模式改成只读/只写分离的端口级调度,并且在HAProxy层开启option mysql-check以执行真实query串来判断DB健康状态,而不是只做TCP端口探测。轻云互联的网关集群上,我们也在四层LB上加了用户态协议探测,专门识别只读实例半死不活的情况。
2. 连接数管理的最佳实践配置
拿我们实际在压测环境里跑过一次的配置片段举例,效果非常好的方式是把extra_port管理通道打开,专门留给DBA和负载均衡的健康检查用:
[mysqld]
max_connections=1000
extra_max_connections=1200
# admin_port可以走独立socket,不占普通连接池
admin_port=33062
admin_address=127.0.0.1
performance_schema_max_thread_instances=2000
然后到HAProxy里,健康检查的探针走admin_port,业务请求走普通3306。
defaults
mode tcp
timeout connect 3s
timeout client 60m
timeout server 60m
backend mysql_ro_nodes
balance leastconn
option mysql-check user haproxy_probe
server db-ro1 192.168.1.21:3306 check port 33062 inter 3s fall 3 rise 2
server db-ro2 192.168.1.22:3306 check port 33062 inter 3s fall 3 rise 2
注意这里的balance leastconn:对于事务型短查询,leastconn比robing轮询好,因为数据库连接本身就是稀缺资源;对于报表型长查询,如果你不想让一个复杂报告把某个从库拖死,就需要切换为balance hdr(X-Real-DB-Group)这种支持业务标签的路由方式。
三、每个请求都经过负载均衡,一旦遇到连接池爆掉,你就会看到1000+个TIME_WAIT
压测进入第三天,新问题来了:从前端Nginx所在机器上执行ss -s,能看到TIME_WAIT状态的TCP连接有4万多个。TIME_WAIT本身不占CPU,但它背后的副作用是本地端口耗尽,导致负载均衡和新建立的数据库连接之间出现Cannot assign requested address。
出现这种情况时,很多人的第一反应是调小net.ipv4.tcp_max_tw_buckets。实际上这条命令只会降低机器维持TIME_WAIT的上限,一旦超过,新的连接会被直接丢弃,反而加重了问题。正确的姿势是开启TIME_WAIT复用和回收:
cat >> /etc/sysctl.conf <<'EOF'
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_timestamps = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
EOF
sysctl -p
同时,在应用层连接池组件(如HikariCP或Druid)里,设置maxLifetime小于数据库的wait_timeout,避免数据库主动断开后应用还在用旧连接发请求。给一个JDBC配置:
jdbc:mysql://192.168.1.10:3306/appdb?connectTimeout=3000&socketTimeout=5000&autoReconnect=true&initialSize=5&maxActive=50&maxIdle=10&minIdle=5&maxWait=60000
如果不是Java技术栈,换成Go的database/sql也类似:SetConnMaxLifetime(3 * time.Minute),SetMaxIdleConns(20)。避免每个HTTP请求都单独建立MySQL连接——这个问题在PHP-FPM下最常见,等于是每次请求都把数据库负载均衡器的accept队列踩一遍。
四、负载均衡与数据库之间的“唯一真实分割点”:事务边界
很多云数据库负载均衡的架构设计里,大家都默认“读写分离之后,从库只跑只读SQL就绝对安全”。但在实际业务中,如果一个事务内先SELECT再UPDATE,LB按单条SQL判断将select转发到从库,事务后续的update又发到主库,这时候从库上查到了旧数据,业务就出现幽灵读。
比较深的实践是:不要让负载均衡设备感知SQL语句类型,而让应用层自己决定连接走主库还是从库。具体方案是给业务方暴露两个端口,3306走主库,3307走从库。业务框架里通过注解或配置文件显式指定路由。然后可以在从库前置Proxy上做“事务内单连接一致性”的session绑定:
# 在ProxySQL或自研网关里,使用以下规则
mysql_query_rules:
- rule_id: 1
active: 1
match_pattern: "^SELECT .* FOR UPDATE"
destination_hostgroup: 0 # 强制到主库
apply: 1
- rule_id: 2
active: 1
match_pattern: "^SELECT "
destination_hostgroup: 1 # 普通select去从库
apply: 0
但最隐蔽的一招:用@@tx_read_only会话变量实现从库自动拒绝写操作。先像下面这样设置从库,确保连接进到从库后,任何SET AUTOCOMMIT=0后没加FOR UPDATE的普通SELECT都能走;一旦应用试图在这个连接上执行UPDATE,从库直接抛异常。
SET GLOBAL transaction_read_only = ON;
# 这行可以放到从库的my.cnf [mysqld]区:read_only=ON && transaction_read_only=ON
这种“强制约束”比LB上的规则可靠得多。DB本身不会因为网络的转发而骗你,但从库能做的只有拒绝,不会返回脏数据。
五、终极核对:用一张表判断你的DB是否被LB“拖累”
经过以上把IO调度、连接密度、复用策略和事务边界全部修正后,使用sysbench或jmeter做有负载均衡的混合压测,最终观察一个关键指标:performance_schema里的events_waits_summary_global_by_event_name。执行下面这条SQL就能看刷脏路径是否被外围影响:
SELECT EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1000000 AS total_ms
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE EVENT_NAME LIKE 'wait/io/file/innodb/innodb_data_file%'
ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;
如果SUM_TIMER_WAIT占压测总耗时的比例降到了整体运行时间的10%以内,说明负载均衡对数据库节点的影响已经降到最低。要是看到某一条的SUM_TIMER_WAIT比索引扫描还高,那就意味着IO调度问题的根没被拔掉。
这套组合拳最终落地在轻云互联的一批SATA和NVMe混合型云数据库物理节点上,把业务的分库分表压力均分稳定后,整体性能比默认内核配置高出4倍。而且整个过程不涉及应用代码改动,就是纯内核对齐和连接边界治理。