前段时间线上碰到一个很奇怪的故障:我们更换了第三方服务商的域名解析记录,把域名切到新的后端地址。一部分流量很快切过去了,但还有一部分机器,隔了大半天还在往老 IP 发请求,持续报连接失败。第三方那边确认解析已经生效,公共 DNS 也已经刷新,一开始怀疑是我们服务内部缓存了 IP,翻代码却没找到硬编码 IP 的地方。
查了很久才定位清楚,问题出在操作系统和 JDK 内置的 DNS 缓存上,这一层经常被大家忽略。
很多人的固有认知是,改完域名的 A 记录,TTL 到期之后所有客户端就自动刷新解析。这个逻辑只适用于递归 DNS 服务器本身。业务服务器作为解析客户端,本身还会有一层缓存。以 Linux 为例,默认不一定开启 nscd 或者 systemd-resolved,但应用运行时的 Runtime,往往自带独立 DNS 缓存,不跟着系统走。
就拿 JVM 来说,它的 DNS 缓存策略很容易踩坑。默认情况下,成功的解析结果会永久缓存,只有解析失败的时候才会短时间过期。也就是说,域名第一次解析拿到老 IP 之后,只要没有解析报错,哪怕上游 DNS 记录早就变了,JVM 进程会一直拿着旧 IP,进程不重启就不会更新。这就是这次故障最核心的原因。机器上的 nslookup 早就拿到新 IP 了,但是 Java 应用内部的缓存完全没刷新,继续访问旧地址。
除了 JVM,Go、Python 这些语言行为又不一样。Go 标准库默认不做长期 DNS 缓存,每次请求都会向系统 resolver 发起查询;Python 则依赖系统配置,如果开了 nscd,缓存时长由 nscd 控制。不同语言的差异很大,同一个域名,同一台机器上,不同服务解析出来的 IP 可能不一样,排查的时候很容易混乱。
还有一个坑是容器环境。很多业务跑在 K8s Pod 里面,容器里的 /etc/resolv.conf 配置了集群 DNS。集群 DNS 本身有缓存,加上节点层面的缓存,叠加之后实际生效的缓存时间,往往比域名设置的 TTL 要长。有时候域名 TTL 设成 60 秒,结果集群 DNS 缓存了几分钟,Pod 内的应用拿到的依旧是旧记录。更麻烦的是,很多 Pod 是长期运行的,不会频繁重建,缓存就一直保留。
另外就是 HTTP 客户端连接池的叠加效应。就算解析已经拿到新 IP,老的连接还在连接池里面活着。HttpClient、OkHttp 这类组件,会复用已经建立好的长连接。哪怕 DNS 已经刷新,只要旧连接没到空闲超时,请求依旧走老连接到老 IP。这就会出现一种现象:一部分请求走新 IP 正常,一部分请求走老 IP 报错,错误是断断续续的,不是全量失败,故障特征非常迷惑。当时我们就是一边有成功请求、一边报错,很长一段时间没往连接池方向想。
还有公共 DNS 的问题,很多测试的时候用手机、本地电脑 dig,看到解析更新了,就直接认为全量没问题。但线上服务器用的是内网 DNS、自建递归 DNS,和外面的公共 DNS 不是一套体系,刷新节奏完全独立。本地验证通过,不等于线上服务器的 DNS 已经同步。
这次故障处理完之后,我们梳理了几种处理手段,也划定了边界。
第一种,紧急止血。对于 JVM 应用,最简单粗暴的方式就是滚动重启实例,清空进程内的 DNS 缓存,适合紧急故障。但重启不是优雅方案,不能每次改解析都重启服务。可以配置 networkaddress.cache.ttl 参数,把成功解析的缓存时间缩短,和域名 TTL 对齐,从根源避免永久缓存。
第二种,区分 DNS 缓存和连接池缓存。DNS 刷新之后,还需要等待连接池空闲连接释放,或者主动关闭旧连接。如果是切换机房、切流量这种重要变更,除了等 TTL,还要评估客户端连接池的空闲超时时间,预留对应的等待窗口。
第三种,变更前做验证,不能只在本地 dig。要登录业务所在的 Pod、服务器,用应用同环境下的工具去解析,而不是只在宿主机或者自己电脑上验证。同时分开验证:DNS 解析结果、建立 TCP 连接、业务接口调用三层,逐层确认。
还有一个长期方案:核心外部依赖尽量不要直接裸域名调用,用服务发现或者网关收口。域名变更只改网关一层,下游业务不需要感知后端 IP 变化,从源头规避 DNS 缓存带来的批量切换风险。域名解析本质上是一层基础依赖,一旦链路长、缓存层级多,变更的实际生效时间很难精准控制。
很多时候排查网络类故障,大家第一反应看防火墙、看端口、看中间件配置,DNS 缓存这种底层环节,优先级放得很低。它不像超时、报错那样直接明显,故障往往是渐进式、分片式的,等到发现的时候,已经持续很久了。