综合 割接切了地址,旧库还有连接在跑:*sql.DB、DNS TTL、keepalive 三处滞留

2026-11-03 00:03:54

割接切了地址,旧库还有连接在跑:*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
JVMnetworkaddress.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 版本能力需要按实际环境核对。

复制全文 生成海报 割接窗口 MySQL Go connect pool DNS nginx

推荐文章

程序员茄子在线接单