那天下游团队做了一次再寻常不过的机房迁移:他们的服务换了台机器,域名没变,只是把 DNS 解析指向了新的 IP,并提前通知了所有调用方"切换无感知,DNS 会自动生效"。我们的服务通过域名调用他们,按理说啥都不用动。结果切换之后,我们的服务开始疯狂报错:连接超时、连接被拒,日志里全是连不上下游的红字。诡异的是,同一台机器上,我用 ping 和 curl 去访问那个域名,解析到的明明是新 IP、访问也完全正常——可我的 Java 进程,就是死活连着那个早已废弃的旧 IP 不撒手。
我盯着这个现象愣了好久:操作系统层面 DNS 明明已经更新了,为什么唯独我的 Java 应用还在用旧地址?把进程重启一下,问题立刻消失,连的就是新 IP 了。这个"重启就好"的特征,把矛头指向了进程内部的某种缓存。一番排查后,真凶水落石出:JVM 默认会缓存 DNS 解析的结果,而且在某些配置下,这个缓存是"永久"的——一旦某个域名被解析过一次,JVM 就把那个 IP 死死记住,之后再也不去重新解析,哪怕下游早就换了地址。
这就是一个潜伏极深、却又极其经典的坑:JVM 的 DNS 缓存,让应用对底层的 IP 变更"视而不见"。平时它毫无存在感,可一旦下游做 IP 迁移、或者用域名做failover切换,它就会让你的服务固执地连着一个已经死掉的地址,而你还以为是网络或对方的问题。这篇文章,就从这次"DNS 都更新了我却连着旧 IP"的事故出发,把 DNS 解析、各层缓存、以及它们的坑,一次讲透。
先摆几个关于 DNS 的想当然
动手复盘前,先把我自己曾经深信、后来被这次事故教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "DNS 改了,所有调用方就自动连新地址了" | 各层都有缓存, 应用可能长时间还连着旧 IP |
| "ping/curl 通了,说明我的程序也能连新 IP" | 命令行每次重新解析, 而长驻进程可能缓存了旧解析 |
| "DNS 缓存顶多几分钟,等等就好" | JVM 默认可能永久缓存, 不重启永远不更新 |
| "重启能解决,那就是个偶发小问题" | 重启只是清掉了进程内缓存, 根因没动, 下次照犯 |
| "DNS 就是把域名转成 IP,很简单" | 它是多级缓存的链路, 任何一级过期/不过期都会出问题 |
这些念头的共同病根,是把 DNS 解析想象成"每次用域名时都会实时去查最新 IP"的简单操作,却忽略了从操作系统到应用进程、层层叠叠的缓存。要看清这次事故,得先把 DNS 解析这条带着缓存的链路捋清楚。
第一件事:DNS 解析是一条多级缓存的链路
当程序要通过域名访问服务时,把域名转成 IP 的过程,并不是每次都老老实实地跑一趟完整的 DNS 查询。为了性能,这条链路上布满了缓存,从近到远大致是:应用进程内的缓存 → 操作系统的缓存 → 本地 DNS 服务器(如运营商或内网 DNS)的缓存 → 权威 DNS 服务器。任何一级有缓存且没过期,查询就在那一级直接返回,不再往下走。
每条 DNS 记录都带一个 TTL(Time To Live,生存时间),告诉各级缓存"这条记录可以缓存多久"。理论上,TTL 一到,缓存就该失效、重新去查最新的。下游迁移时所谓"DNS 自动生效",依赖的正是这个 TTL 机制——等各级缓存按 TTL 过期,自然就会解析到新 IP。可问题恰恰出在:有些缓存,并不老实遵守 TTL。我的 JVM 就是这样一个"不守规矩"的缓存层。下面这张图,把这条带缓存的链路画出来:
看懂这张图,我那次事故的位置就清楚了:命令行的 ping/curl 是短命进程,每次都从头走查询(顶多命中系统/本地 DNS 缓存,而那些缓存已按 TTL 更新),所以拿到的是新 IP;而我的 Java 进程是长驻的,它在第一级——JVM 进程内缓存——就把旧 IP 命中了,根本没机会往下走到已经更新的那几级。问题不在 DNS 链路的下游,而卡在了离应用最近的那一级缓存上。接下来,我们就重点解剖这一级。
第二件事:解开 JVM 的 DNS 缓存——networkaddress.cache.ttl
JVM 的 DNS 缓存,由两个安全属性控制:networkaddress.cache.ttl(成功解析的缓存秒数)和 networkaddress.cache.negative.ttl(解析失败的缓存秒数)。最坑的地方在于它们的默认值:当应用启用了安全管理器(SecurityManager)时,cache.ttl 默认是 -1,意思是"永久缓存,永不过期"——这正是我那次的元凶。即便没有安全管理器,默认值也可能是一个不算短的固定秒数。
# 方式一:在 JDK 的 java.security 文件里配置(全局生效)
# -1 表示永久缓存(危险!), 0 表示不缓存, 正数表示缓存的秒数
networkaddress.cache.ttl=30
# 解析失败也别缓存太久, 否则下游短暂抖动后恢复了你还连不上
networkaddress.cache.negative.ttl=5
更推荐在应用代码里显式设置,确保不依赖运行环境的默认值——尤其在容器化、依赖域名做服务发现的场景下,把它设成一个合理的较小值(比如 30 秒、60 秒),让 JVM 能及时感知到 IP 变化:
// 在应用启动的最早期(main 入口/初始化)显式设置, 别赌环境默认值
public class App {
static {
// 成功解析最多缓存 30 秒, 之后重新解析, 能感知 IP 变更
java.security.Security.setProperty("networkaddress.cache.ttl", "30");
// 解析失败只缓存 5 秒, 避免下游短暂故障后长时间连不上
java.security.Security.setProperty(
"networkaddress.cache.negative.ttl", "5");
}
public static void main(String[] args) { /* ... */ }
}
// 注意:必须在任何 DNS 解析发生之前设置, 否则可能不生效
这里有个容易踩的细节:这些属性必须在第一次 DNS 解析发生之前设置才可靠,所以要放在应用极早期的初始化阶段。把 TTL 从"永久"改成一个合理的小值后,我那次的问题就根治了——下游再换 IP,最多 30 秒后我的服务就能自动跟上,无需重启。对于任何依赖域名访问、且下游 IP 可能变化的 Java 服务,显式设置一个合理的 DNS 缓存 TTL,是必须做的一项基础配置。
第三件事:小心连接池——它可能把旧 IP 又"焊死"了一层
把 JVM 的 DNS TTL 调小,解决了"重新解析"的问题,但还有一个更隐蔽的坑:连接池。很多 HTTP 客户端、数据库客户端都用连接池复用长连接,而一条已经建立的 TCP 连接,是连到某个具体 IP 上的。即便 JVM 的 DNS 缓存过期了、重新解析出了新 IP,那些池子里早已建好的、连着旧 IP 的长连接,只要不被关闭重建,就会一直把流量往旧地址送。
// 连接池里的长连接绑定的是具体 IP, DNS 更新了它也不会自动迁移
// 关键配置:给连接设置最大存活时间, 强制定期重建
PoolingHttpClientConnectionManager cm =
new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
// 连接最长存活 60 秒, 到期强制关闭重建 → 重建时会重新解析 DNS
.setConnectionTimeToLive(60, TimeUnit.SECONDS)
// 定期清理空闲/过期连接
.evictExpiredConnections()
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
// 这样即便下游换了 IP, 旧连接也会在存活期到后被淘汰, 新连接连到新 IP
这一层的道理是:DNS 解析新鲜,只保证"新建连接"会连到新 IP;但已经建好的旧连接,需要靠'连接最大存活时间'之类的机制强制淘汰重建,才能跟着迁移。所以彻底解决 IP 变更跟随问题,要"两手抓":一手把 JVM DNS TTL 调小(保证重新解析),一手给连接池设置合理的连接存活时间(保证旧连接被淘汰)。只调 DNS 不管连接池,旧连接照样能把你坑得连半天旧 IP——这是很多人改了 DNS TTL 后"以为好了、其实没全好"的原因。
第四件事:别让"负缓存"在故障切换时坑你
前面提到的 negative.ttl(失败解析的缓存),藏着一个特别容易在故障切换时翻车的坑。设想下游用域名做主备切换:主节点挂了,运维把域名指向备节点。这本是为了快速恢复,可如果你的 JVM 把"主节点解析失败"这个结果缓存了很久(负缓存 TTL 过长),那么即便域名已经指向了健康的备节点,你的服务还会在那段时间里固执地认为"这个域名解析不了",白白错过恢复窗口。
// 负缓存 TTL 过长, 在 failover 场景下是灾难
// 默认负缓存可能是 10 秒, 但若被配成很大, 故障恢复后你还连不上
// 正解:把负缓存压到很小, 让解析失败能很快重试
java.security.Security.setProperty(
"networkaddress.cache.negative.ttl", "0"); // 失败不缓存, 每次重试
// 或设成 1~3 秒, 在"避免疯狂重试"和"快速感知恢复"之间平衡
这条很反直觉:很多人只关心"成功解析"的缓存,却忽略了"失败解析"的缓存同样致命。在依赖 DNS 做故障切换的架构里,负缓存 TTL 一定要设得很小,否则你精心设计的 failover,会被一层不起眼的负缓存拖住后腿。原则是:成功缓存可以有(几十秒),失败缓存要尽量小(几秒甚至不缓存),好让系统对"下游恢复"足够敏感。
第五件事:容器与微服务时代,DNS 变更是常态
这个坑在过去也许不常遇到,但在容器化和微服务时代,它的杀伤力被急剧放大了。因为在 K8s 这类环境里,Pod 的 IP 是动态的、频繁变化的:一次发布、一次扩缩容、一次故障重建,服务对应的 IP 就可能变。服务之间通过 Service 域名互相访问,而这些域名背后的 IP 时刻在变。如果你的应用还抱着"永久缓存 DNS"的老习惯,在这种环境里就是灾难——你会不断连向那些已经被销毁的 Pod IP。
# 在 K8s 里, Service 域名背后的 Pod IP 随发布/扩缩容不断变化
# 因此 JVM 镜像里务必配好较小的 DNS TTL
# 可以通过环境变量/启动参数统一注入, 避免每个应用单独改代码
env:
- name: JAVA_TOOL_OPTIONS
# 通过系统属性方式设置(部分 JDK 版本支持), 或在镜像的 java.security 改
value: "-Dsun.net.inetaddr.ttl=30 -Dsun.net.inetaddr.negative.ttl=5"
# 关键认知:容器时代 IP 是"流动"的, 永久缓存 DNS = 不断连向已死的 Pod
所以在现代基础设施下,这早已不是一个"偶尔遇到的边角问题",而是一项必须纳入标准配置的基础设施意识。无论是 JVM 的 DNS TTL,还是连接池的连接存活时间,都应该作为容器镜像、应用模板的默认配置项固化下来,而不是等到某次发布后服务连不上、再手忙脚乱地去查。把"IP 会变"当成默认假设,你的服务才能在动态的云原生环境里稳得住。
第六件事:怎么确认就是 DNS 缓存在作祟
最后说排查。这类问题最迷惑人的地方,是"命令行能连、程序连不上",很容易让你怀疑是代码或网络。确认它是 JVM DNS 缓存,有几个趁手的办法。第一,对比:在出问题的机器上 nslookup/dig 域名,看系统解析到的是不是新 IP;如果是新 IP 但程序还连旧的,基本就锁定进程内缓存了。第二,看连接:用 netstat/ss 看你的 Java 进程实际连的是哪个 IP。
# 1. 看系统层面域名解析到哪个 IP(命令每次都重新查, 反映最新)
dig +short downstream.example.com
nslookup downstream.example.com
# 2. 看你的 Java 进程实际连着哪个 IP(揪出它还连着旧地址)
ss -tnp | grep java
netstat -anp | grep <java_pid>
# 3. 若系统解析是新 IP、而进程连的是旧 IP, 基本坐实 JVM DNS 缓存
# 终极验证:重启进程, 若立刻连上新 IP, 那就是进程内缓存无疑
这套对比法的精髓,是把"系统层面的解析结果"和"进程实际的连接对象"放在一起比对:两者不一致,问题就在进程的缓存层。一旦确认,解法就是前面那几招——调小 DNS TTL、设置连接存活时间。到这儿,这次事故的来龙去脉就齐了。我把排查思路收成一张决策图:
把这套理解建立起来,"DNS 都改了我却连着旧 IP"这类幽灵问题就能被快速定位。最后,拧成几条可直接照做的铁律:
- 显式设置 JVM 的
networkaddress.cache.ttl,别用可能是"永久"的默认值,设成 30~60 秒。 - 负缓存
negative.ttl要设得很小,几秒甚至 0,否则故障切换时会被拖后腿。 - DNS 调好还不够,连接池也要设连接最大存活时间,强制淘汰连着旧 IP 的旧连接。
- 把这些当容器镜像的标准配置,云原生环境里 IP 是流动的,永久缓存就是灾难。
- 排查时对比"系统解析"与"进程实际连接",两者不一致就锁定进程内缓存。
- "命令行能连、程序连不上"先怀疑应用层缓存,而非急着甩锅网络或对方。
- "重启就好"是缓存类问题的强信号,别满足于重启, 要根治那层缓存配置。
一张 DNS 缓存排查速查表
把各级缓存、症状和对策汇成一张表,下次"域名改了却没生效"时对照着查。
| 缓存层 | 典型症状 | 对策 |
|---|---|---|
| JVM 进程内 DNS 缓存 | 命令行连新 IP, 程序连旧 IP, 重启即好 | 设小 networkaddress.cache.ttl |
| JVM 负缓存 | 下游恢复后程序仍报解析失败 | negative.ttl 设很小或 0 |
| 连接池旧连接 | DNS 已更新但部分流量仍到旧 IP | 设连接最大存活时间, 定期重建 |
| 操作系统 DNS 缓存 | 整机解析都是旧 IP | 刷新系统 DNS 缓存 |
| 本地/内网 DNS 服务器 | 多台机器都解析到旧 IP | 等 TTL 或联系 DNS 管理刷新 |
| hosts 文件硬编码 | 改 DNS 完全不生效, 重启也没用 | 检查并清理 /etc/hosts 旧记录 |
别忘了最"原始"的一层:hosts 文件
表里最后一行值得单独提醒,因为它最容易被遗忘、又最简单粗暴。/etc/hosts(Windows 下是 C:\Windows\System32\drivers\etc\hosts)是 DNS 解析的最高优先级:只要这个文件里给某个域名硬编码了一个 IP,系统就直接用它,压根不去查 DNS。我见过不止一次,某次为了临时测试,有人往 hosts 里加了一行域名映射,事后忘了删,结果几个月后下游迁移,这台机器死活连旧 IP——因为它根本没在走 DNS,改 DNS 当然不生效,重启进程也没用。
所以当你遇到"DNS 怎么改都不生效"的极端情况,且重启进程也无济于事时,一定要回头看一眼 hosts 文件——它是排查 DNS 问题时最该首先排除、却最常被跳过的一环。排查解析问题的正确顺序,是从最近、最高优先级的缓存开始,一层层往外推:hosts → 进程内缓存 → 系统缓存 → 本地 DNS → 权威 DNS。哪一层命中了旧值,问题就出在哪一层。
# 排查 hosts 是否有遗留的硬编码映射(它优先级最高, 会绕过 DNS)
cat /etc/hosts | grep downstream
# 若发现可疑的旧映射, 清理掉它, DNS 解析才会重新生效
# 顺带:确认系统解析顺序(hosts 在前还是 DNS 在前)
cat /etc/nsswitch.conf | grep hosts # 通常是 "files dns", files 即 hosts
再深一层:一个域名可能对应多个 IP
这次事故还顺带让我搞清了一个之前模糊的认知:一个域名,往往不是只对应一个 IP,而是对应一组 IP(多条 A 记录)。下游为了负载均衡和高可用,常常给同一个域名挂多个后端 IP,DNS 查询会返回这一组地址,由客户端或系统来选用其中之一。这本是好事,但它和缓存搅在一起时,会让问题更微妙。
// 一个域名可能解析出多个 IP, 看看你的域名背后到底有几个地址
InetAddress[] addrs = InetAddress.getAllByName("downstream.example.com");
for (InetAddress addr : addrs) {
System.out.println(addr.getHostAddress()); // 可能打印出好几个 IP
}
// 含义:下游做了多 IP 负载均衡; 若其中某个 IP 下线而缓存没更新,
// 你可能间歇性地连向那个已死的 IP, 表现为"偶发"超时, 更难排查
这解释了一类更隐蔽的现象:有时下游下线的不是全部、而是一组 IP 里的某一个,于是你的服务不是"完全连不上",而是"偶发性地超时"——因为只有当客户端恰好选中那个已死 IP 时才出错,选中健康 IP 时又一切正常。这种"时好时坏"比"全坏"更让人抓狂,而根子往往还是同一个:某一层缓存里,留着一个已经不该再用的 IP。
应对多 IP 场景,除了前面那套 DNS TTL + 连接池存活时间,还可以考虑:让客户端具备健康检查与故障转移能力(连某个 IP 失败时自动换下一个),或者干脆把负载均衡和健康检查交给专门的基础设施(如服务网格、负载均衡器)来做,而不是让每个应用自己用 DNS 硬扛。说到底,DNS 是个朴素的"域名转 IP"工具,它本身不擅长做实时的健康感知和故障转移;在对可用性要求高的场景,别把这些重任全压在 DNS 这一层上。认清一个工具的能力边界,把合适的活儿交给合适的组件,这本身就是一种工程成熟度。
写在最后
这次"DNS 都更新了我却连着旧 IP"的事故,给我最深的体会,是它揭示了缓存这把双刃剑的另一面。我们做工程,几乎本能地热爱缓存——它让一切变快,是性能优化的常青手段。可这次事故狠狠提醒了我:缓存的本质,是用"数据可能过期"去换"访问更快",而我们享受它的快时,常常忘了它另一半的代价——那个被缓存的值,可能早已不再是世界的真相。从 CPU 缓存到 DNS 缓存,从浏览器缓存到这次的 JVM 缓存,几乎所有的缓存类 bug,本质上都是同一个故事:你以为拿到的是最新的,其实拿到的是一个过期的副本。
所以这次经历让我养成了一个新的排查直觉:当一个东西"在别处明明是对的,在我这里却是错的、且重启就好",几乎可以肯定,中间有一层缓存,固执地攥着一个过期的值不放。顺着这个直觉,从离自己最近的那层缓存开始往外查,真凶往往很快就会现形。缓存给了我们速度,但也要求我们时刻保持一份清醒:它手里那份数据,到底新不新鲜?它会在该过期的时候,老老实实地过期吗?理解了每一层缓存的"保鲜期"和它的脾气,我们才能既享受缓存带来的飞快,又不被它某个早已馊掉的旧值,在某个意想不到的时刻绊一个大跟头。愿你我都能与缓存这位又快又"健忘"的伙伴,处出一种知根知底的默契。
—— 别看了 · 2026