云数据库前挂 Nginx stream 网关的四个静默坑:主备切换 IP 漂移、proxy_timeout 跟 SLB 会话老化对不上、半关闭缺席顶高 aborted_clients
为什么要在云数据库前面塞一层 Nginx stream
真实场景长这样:云数据库(RDS/PolarDB 那类托管实例)只给了一个内网地址,白名单最多加 200 条。业务侧一边是 VPC 里的 40 台应用机,一边是自建机房几台老服务,还有一堆临时排障用的跳板机。全往白名单里塞,三个月就乱了;主备切换、加只读实例,还得改一圈连接串。
所以中间加一层 TCP 网关,用 Nginx 的 stream 模块做四层转发。它不解析 SQL、不落盘、不做结果缓存,只干三件事:统一入口、统一白名单、把连接数和上游实例的变化挡在业务之外。
应用机(40台) ──┐
自建机房(3台) ──┼──> Nginx stream 网关 :3306 ──> 云数据库主实例 10.20.3.11:3306
跳板机 ──┘ └─> 只读实例 10.20.3.12:3306
这层网关不存储任何东西,但对网络抖动和连接状态极其敏感。我们把它放在轻云互联的机器上,主要是图线路稳定、带宽不缩水——四层转发最怕的就是网关自身丢包,一丢包整个 MySQL 协议流就废了,客户端表现为莫名其妙的 Lost connection during query。
第一步:把 stream 模块装出来,跑通最小可用配置
发行版自带的 nginx 包通常把 stream 拆成独立模块,不装就是 unknown directive "stream":
# RHEL/CentOS 系
dnf install -y nginx nginx-mod-stream
# Debian/Ubuntu 系
apt install -y nginx libnginx-mod-stream
# 确认模块在位
nginx -V 2>&1 | tr ' ' '\n' | grep stream
ls /usr/lib64/nginx/modules/ | grep stream
主配置里加载模块,然后把 stream 块单独拆出去:
# /etc/nginx/nginx.conf
load_module /usr/lib64/nginx/modules/ngx_stream_module.so;
worker_processes auto;
worker_rlimit_nofile 200000;
events {
worker_connections 65535;
multi_accept on;
}
include /etc/nginx/stream.d/*.conf;
# /etc/nginx/stream.d/clouddb.conf
stream {
log_format stream '$remote_addr:$remote_port [$time_local] $status '
'$bytes_sent/$bytes_received ${session_time}s '
'up=$upstream_addr up_conn=$upstream_connect_time';
access_log /var/log/nginx/stream-access.log stream;
upstream clouddb {
zone clouddb 64k;
least_conn;
server 10.20.3.11:3306 max_fails=0 max_conns=800;
server 10.20.3.12:3306 max_fails=0 max_conns=800 backup;
keepalive 64;
}
server {
listen 3306 so_keepalive=120s:30s:3 backlog=4096;
proxy_pass clouddb;
proxy_connect_timeout 2s;
proxy_timeout 20m;
proxy_socket_keepalive on;
proxy_half_close on;
proxy_next_upstream off;
proxy_buffer_size 16k;
error_log /var/log/nginx/stream-error.log warn;
}
}
上面这五行看着平平无奇,每一行背后都是一个曾经挂过的线上事故。下面逐个拆。
坑一:upstream 里的云数据库域名,只在 reload 那一刻解析一次
这是最狠的一个。写 server rm-xxxx.mysql.rds.example.com:3306;,nginx 只在启动/重载时做一次 A 记录解析,把 IP 焊死在共享内存里。云数据库主备切换,域名指向从 3.11 变成 3.13,网关这边毫无感知,新连接继续往旧 IP 打,upstream_connect_time 直接超时,业务侧全是 Connection refused。
先确认现象。切换后,网关上的 ESTAB 目标地址还是老 IP:
dig +short rm-xxxx.mysql.rds.example.com | sort
ss -tnp state established '( dport = :3306 )' | awk '{print $5}' | sort | uniq -c
两边对不上,就中招了。
解法 A:nginx ≥ 1.27.3,用 upstream 里的 resolve
开源版长期把 upstream server ... resolve 锁在商业版里,直到 1.27.3 才放出来,stream 和 http 一起放开。有这个版本就别折腾了:
upstream clouddb {
zone clouddb 64k;
resolver 100.100.2.136 100.100.2.138 valid=10s ipv6=off;
resolver_timeout 2s;
server rm-xxxx.mysql.rds.example.com:3306 resolve max_fails=0;
server rm-yyyy.mysql.rds.example.com:3306 resolve max_fails=0 backup;
keepalive 64;
}
zone 是必须的,动态解析的 IP 存在共享内存里,没 zone 直接起不来。valid=10s 决定主备切换后最坏多久恢复,别设太大。
解法 B:老版本,靠 reload 兜底
1.27.3 之前,唯一稳妥的办法是域名变了就 reload。用 cron 每两分钟比对一次,变了才 reload,避免无意义抖动:
# /etc/cron.d/db-dns-watch
*/2 * * * * root dig +short rm-xxxx.mysql.rds.example.com | sort -u > /tmp/db.ip.new; \
cmp -s /tmp/db.ip.new /tmp/db.ip.cur || { nginx -t && nginx -s reload && mv /tmp/db.ip.new /tmp/db.ip.cur; }
reload 对 stream 是安全的:老 worker 保持旧连接直到自然关闭,新连接走新配置。worker_shutdown_timeout 设个 10m,别让老 worker 无限期挂着吃 fd。
解法 C:变量 proxy_pass,别用这条路
网上很多文章教你 set $db_host ...; proxy_pass $db_host:3306; 绕开解析问题。确实每次连接都会重新解析,但代价是 upstream 块整个失效——keepalive 连接池没了、max_conns 没了、备份实例逻辑没了,每个新连接都重新建 TCP,云数据库侧 Threads_created 直接起飞。真要用变量,至少配上 proxy_connect_timeout 1s,把解析失败快速吐回去。
坑二:proxy_timeout 是"两次 IO 之间",跟 SLB 会话老化对不上
很多人以为 proxy_timeout 是连接总时长,其实它是"客户端和上游之间连续两次成功读/写之间的最大空闲时间",默认 10m。它管的是空闲,不是寿命。
真正的问题在中间那层:云数据库前面通常还有 SLB / NAT 网关,会话表项空闲老化时间经常是 300s 或 900s。应用连接池里躺着一条 20 分钟没动过的连接,中间设备的表项早被回收了,两端都以为连接还活着。下一次复用,应用拿到 MySQL server has gone away (2006),抓包一看,第一个 SYN 才发出去——连接在协议栈层面早就断了。
解法是两端都要有保活,光靠一端的配置没用:
# 网关侧:客户端连接开 keepalive,顺手把参数精确到秒
listen 3306 so_keepalive=120s:30s:3 backlog=4096;
# 网关侧:到上游也开 keepalive
proxy_socket_keepalive on;
注意 proxy_socket_keepalive on 只是给上游 socket 设了 SO_KEEPALIVE,具体参数走内核默认值。内核默认 tcp_keepalive_time = 7200,也就是两小时才探第一次,比 SLB 的 300s 老化慢了两个数量级,等于没开。必须一起调:
cat >> /etc/sysctl.d/99-keepalive.conf <<'EOF'
net.ipv4.tcp_keepalive_time = 120
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
EOF
sysctl --system
还要反向约束一次云数据库侧:wait_timeout / interactive_timeout 必须大于应用连接池的 maxLifetime,而 proxy_timeout 必须大于云数据库侧的空闲回收时间。三者的正确顺序是:
proxy_timeout(20m)> 云数据库wait_timeout(10m)> 应用连接池maxLifetime(8m)
让连接池先主动放弃,云数据库其次,网关最后。任何一层提前关,都会把一次正常的连接回收变成应用侧报错。
坑三:proxy_half_close 不打开,aborted_clients 替你记账
MySQL 客户端退出流程是:先发 COM_QUIT,再发 FIN。如果应用是 Ctrl-C 或者被 kill,那就只有 FIN,没有 COM_QUIT。
默认情况下,nginx 收到客户端 FIN 就立刻向上游发 FIN。但这时候上游可能还在回一个 200MB 的结果集,或者还在跑一条慢 SQL。上游写的时候发现对端已关,回 RST,云数据库侧就记一笔:
Aborted connection 88213 to db: 'order' user: 'app' host: '10.20.3.5'
(Got an error reading communication packets)
积少成多,云数据库控制台的 Aborted_clients 每天涨两万,运维以为是业务代码有问题,其实是网关层砍的。开启半关闭后,nginx 会把客户端的 FIN 记在心里(shutdown(SHUT_WR) 到上游),继续读上游直到它自己关:
proxy_half_close on; # stream, nginx 1.15.7+
验证方法很直接,抓 FIN/RST 的时序:
tcpdump -i eth0 -nn -c 50 'tcp port 3306 and (tcp[tcpflags] & (tcp-fin|tcp-rst) != 0)'
没开之前,客户端方向 FIN 和上游方向 FIN 的时间差是微秒级;开了之后,上游 FIN 会晚到,时间差等于结果集剩余传输时间。再配合隔一分钟跑两次这条:
mysql -h gateway -e "SHOW GLOBAL STATUS LIKE 'Aborted_clients'"
增量掉到个位数,就说明这条链路上没人乱砍连接了。
坑四:proxy_next_upstream 默认 on,一条 INSERT 可能落到两台机器
这个是最违反直觉的。ngx_stream_proxy_module 的 proxy_next_upstream 默认是 on。含义是:连接上游失败时,如果客户端已经把数据发过来了(比如那条 INSERT 的报文),nginx 会把这些数据先缓冲住,然后换下一个上游重放。
致命的地方在于四次握手完成了、SQL 也发过去了、上游也执行了、只是回包超时或者链路闪断——nginx 认为"上游失败",把缓冲的那段字节原样重放给备份实例。业务上就是一条 INSERT 执行两次、一笔扣款扣两次。
MySQL 协议层没法判断这是重放还是新语句,它就是一条正常的 COM_QUERY。所以这个坑只能从网关侧堵死:
proxy_next_upstream off; # 少一条重试,多一条命
upstream clouddb {
zone clouddb 64k;
least_conn;
server 10.20.3.11:3306 max_fails=0 max_conns=800;
server 10.20.3.12:3306 max_fails=0 max_conns=800 backup;
keepalive 64;
}
max_fails=0 同时关掉被动摘除。否则单台实例抖动一次,nginx 会把它摘掉 10 秒(fail_timeout),这段时间所有新连接全压到备份上,备份的连接数直接翻倍。代价是单实例故障时一定会有一批连接报错——这是对的,让应用层的幂等重试去处理,比网关偷偷重放安全一万倍。
如果这层网关是 Apache httpd,情况略有不同但同样有雷
有些团队的网关机是 httpd,用 mod_proxy 反代云数据库厂商的 HTTP Data API,或者给 Adminer / phpMyAdmin 做前置。这条路有两个高频问题:
ProxyTimeout默认 60 秒。导出百万行表的 SQL dump,页面直接 504,日志里是proxy: error reading status line from remote server。改成ProxyTimeout 1800,再配合ProxyBadHeader Ignore,不要动KeepAlive之外的东西。mod_dbd在 prefork 下是"每进程一个连接池"。DBDMax 100配MaxRequestWorkers 150,一台机器理论上能撬出 150 个到云数据库的连接,五台网关就是 750,max_connections直接打满报Too many connections。要么把 MPM 换 event,要么DBDMax收到 10 以内并开DBDKeep 2,别让它随请求数长。
上线前必须跑的验收清单
配置改完不做这四步验证,等于没改:
# 1. 上游地址是否真的跟随 DNS(切换云数据库主备后重点看)
dig +short rm-xxxx.mysql.rds.example.com | sort
ss -tnp state established '( dport = :3306 )' | awk '{print $5}' | sort | uniq -c
# 2. 网关并发连接与 fd 占用
ss -s
cat /proc/$(pgrep -o nginx)/limits | grep 'open files'
# 3. 云数据库侧是否还在被砍连接(间隔 60s 跑两次对比)
mysql -h gateway -e "SHOW GLOBAL STATUS LIKE 'Aborted_clients'"
# 4. stream 日志里上游建连耗时分布,超过 100ms 就要查网络
awk '{print $NF}' /var/log/nginx/stream-access.log | sort | uniq -c | sort -rn | head -20
最后提醒一句:这层网关自己不存数据,但对带宽和线路质量的要求比业务机还高。四层转发一旦网关侧出现丢包或抖动,MySQL 协议流就被打断,客户端只看到一个含糊的 Lost connection during query,排查方向全跑偏。所以网关机别跟业务抢带宽,选线路稳定的机型,轻云互联的大带宽机器在这类纯转发、长连接多的场景下比较省心,至少不用担心出口被限速导致的假性丢包。
四个坑里,域名解析和 proxy_next_upstream 是会造成数据错误的,优先级最高;keepalive 对齐和 半关闭是会造成误报和连接风暴的,属于必调但可以排期。别一次全改,按这个顺序灰度,出问题好回滚。