割接切了地址,旧库还有连接在跑:*sql.DB、DNS TTL、keepalive 三处滞留
割接窗口给了 30 分钟。数据追平、位点归零、新库开始接写,一切按文档走完。然后看旧库监控:QPS 从 8000 掉到 400,之后二十分钟都停在 300 上下,掉不下去。
数据侧确实切完了,连接侧没有。
站内 #8162 讲的是数据侧怎么判定能切(停写是否真停、位点、追平阈值、回滚预算);站内 #8021 讲割接阶段划分与回滚代价。这篇只处理一件事:窗口内应用侧为什么还在往旧库打,以及怎么在窗口前就把它拆掉。
两件事经常被当成一件
- 数据侧切换:源库没有新写入,增量延迟归零,反向链路就绪。
- 连接侧切换:应用的新连接落到新地址,旧连接该到期到期、该断就断。
前者是 DBA 的判断题,后者是代码和基础设施的判断题。前者的时间以秒计,后者以分钟计,而窗口预算通常按前者做的。
一、*sql.DB 换 DSN 不等于换连接
Go 的 database/sql 连接池挂在 *sql.DB 实例上,不在 DSN 字符串上。改配置文件、改环境变量、重启配置中心推送,都只影响新的 sql.Open。旧 *sql.DB 只要还有人持有引用,里面的空闲连接和正在用的连接都会继续往旧地址打。
切库的代码必须显式造新对象、关旧对象:
old := db
newDB, err := sql.Open("mysql", newDSN)
if err != nil {
return err
}
newDB.SetMaxOpenConns(50)
newDB.SetMaxIdleConns(20)
// 0 表示连接永不过期,割接窗口内这个默认值很致命
newDB.SetConnMaxLifetime(30 * time.Minute)
newDB.SetConnMaxIdleTime(5 * time.Minute)
if err := newDB.PingContext(ctx); err != nil {
newDB.Close()
return err
}
db = newDB // 需要保证是原子替换,或走 sync/atomic.Value
go old.Close() // 立刻释放空闲连接;在用连接会在归还时关闭
两个细节值得单说:
SetConnMaxLifetime 默认是 0,即连接永不过期。很多项目开局就写 0,结果连接池里躺着队列里最老那批连接,多久都不换。
old.Close() 关的是空闲连接,不是执行中的连接。文档写得很清楚:它阻塞到所有在用连接归还。所以调用方不要阻塞在这条语句上——放进 goroutine,或者干脆不关,靠窗口收尾时进程退出。
PHP-FPM 场景下没有常驻连接池,但 pdo_mysql 的持久连接(PDO::ATTR_PERSISTENT)会跨请求复用,原理一样:fpm 子进程里那条连接不会因为换了 DSN 就消失。
二、DNS TTL 没提前降,改记录等于没改
切地址最常见的方式是改域名解析。这一步的生效时间由切换之前的 TTL 决定,不是由你按回车的时间决定。
如果记录 TTL 是 600 秒,改完之后最长有 600 秒的窗口里,仍有一部分客户端在解析到旧 IP——取决于它什么时候缓存过期。
落地的做法是提前一天把 TTL 降下来:
- T-1 天:把业务域名 TTL 调到最小值(多数云解析最低一档是 60 或 600 秒,先看控制台实际可选项,不要按经验假设)。
- 降 TTL 之后要等一轮老 TTL 过期,缓存里才会换成新的短 TTL。降完立刻改记录,等于没降。
- 割接完成、观察稳定后再把 TTL 调回去。
客户端这一侧还有自己的缓存,不能只算权威 DNS:
| 位置 | 默认行为 | 处理 |
|---|---|---|
Go net | 每次 Dial 重新解析,进程内无全局缓存 | 基本不用管 |
| cgo resolver / nscd / systemd-resolved | 有缓存,受系统配置控制 | 割接前 resolvectl flush-caches 或重启 nscd |
| JVM | networkaddress.cache.ttl 默认 30 秒;装了 Security Manager 时变永久(-1) | 显式设置,别依赖默认 |
| 各类 HTTP 客户端 | 有的会缓存解析结果做连接复用 | 切库时重建客户端 |
JVM 那个默认值尤其值得写死在启动参数里,-Dsun.net.inetaddr.ttl=60。见过一个服务因为 Security Manager 配置,把 IP 缓存成了永久,改完 DNS 之后半小时没动静。
三、Nginx 和负载均衡把老连接挂住
Nginx 开源版的 upstream 里写域名,只在启动和 reload 时解析一次。没有 resolve 参数(那是商业版或第三方模块的能力)。所以 DNS 改了、记录生效了,老 worker 手里的 upstream 地址还是旧的,直到 reload。
upstream app_db_proxy {
server db-old.internal:3306; # 域名只在 reload 时解析
keepalive 32;
}
两个可选做法:改配置里的地址为新的再 nginx -s reload(灰度可控,推荐),或者接受开源版的限制,把 reload 当成切地址的必需步骤写进窗口清单。
连接复用这一层还有 keepalive:TCP 连接建立之后就不再查 DNS。Kubernetes 的 Service 在 iptables 模式下,已建立的连接由 conntrack 维持,Endpoints 更新不会打断它们。所以「改完 Service 立即生效」这个预期本身就不成立,老连接会按自己的生命周期慢慢走完。
四、旧库上那些连接,哪些是真残留
切完之后旧库上一定还有连接,这是正常的,不用把所有连接都当故障处理。先按账号和状态分一下:
SELECT user, host, db, command,
COUNT(*) AS conns,
MAX(time) AS longest_idle_s
FROM information_schema.processlist
WHERE user NOT IN ('dts_sync', 'monitor', 'backup')
GROUP BY user, host, db, command
ORDER BY conns DESC;
判读方式:
command = 'Sleep'且time很大:连接池里的空闲连接。它会一直保留,直到wait_timeout回收。MySQL 默认wait_timeout = 28800(8 小时),30 分钟的割接窗口内看不到它消失,完全正常。command = 'Query':还有真实查询在跑,这是没切干净或者有漏掉的调用方。看host定位到具体机器。- 只出现在迁移账号、监控账号下的:迁移链路和采集器,属于预期内。
要不要在窗口里 KILL 掉那些空闲连接?倾向是不主动杀。KILL 打在正在执行的查询上会把应用打出一个错误,而在窗口里排查一个奇怪的报错,比多等二十分钟贵得多。先把 wait_timeout 确认清楚,再决定窗口长度。
如果确实需要让旧库连接快点消失,可以在切换前把源库 wait_timeout 临时调小(比如 300 秒),而不是切完去杀连接。前提是这个实例上没有别的长连接业务。
窗口清单(按连接侧排序)
- T-7 天:把长连接的超时策略过一遍(
ConnMaxLifetime、wait_timeout、keepalive_timeout、networkaddress.cache.ttl),确认没有「永不过期」的默认值。 - T-1 天:降 DNS TTL,等待一轮老 TTL 过期。
- T-1 小时:应用侧预热连接到目标地址(
Ping或跑一次只读查询),确认网络、账号权限、SSL 都对。 - T-0:停写 → 追平 → 切地址 → reload / 重建客户端 / 滚动重启,动作要明确到「谁在哪台机器上执行哪条命令」。
- T+5 分钟:看旧库
processlist按账号分组,区分 Sleep 和 Query。 - T+15 分钟:go/no-go。真正该看的是错误率和业务指标,不是旧库 QPS 是否归零。
- T+30 分钟:回滚决策点。超过这个点再回滚,需要走反向同步,成本量级完全不同。
一点取舍
滚动重启是最干脆的连接侧切换方式:进程重启,所有连接重新建立,没有任何缓存残留。代价是重启期间可用容量下降、有请求失败。
改 DNS + reload 更平滑,但每多一层缓存就多一个「为什么还没生效」的排查点。
如果业务能接受一次短重启,建议直接重启,把不确定性换成一次已知的失败。如果不能接受,就把上面每一层缓存的生效时间写进窗口预算里,别按「改完就生效」排时间表。
文中的机制描述基于对应组件的文档行为;具体的 TTL 档位、wait_timeout 取值与 nginx 版本能力需要按实际环境核对。