下游换了 IP 我却死连旧址:JVM DNS 缓存避坑

下游团队做了一次寻常的机房迁移:换了台机器、域名没变,只把 DNS 指向新 IP,并通知调用方切换无感知。可切换之后我们的服务开始疯狂报错,连接超时、连接被拒,日志全是连不上下游的红字。诡异的是同一台机器上我用 ping 和 curl 访问那个域名,解析到的明明是新 IP、访问也完全正常,可我的 Java 进程就是死活连着那个早已废弃的旧 IP 不撒手;把进程重启一下问题立刻消失。这个重启就好的特征把矛头指向进程内部的缓存,排查后真凶水落石出:JVM 默认会缓存 DNS 解析结果,而且在启用安全管理器等配置下这个缓存是永久的——一旦域名被解析过一次,JVM 就把那个 IP 死死记住,之后再也不重新解析,哪怕下游早换了地址。这篇文章从这次 DNS 都更新了我却连着旧 IP 的事故出发,讲透 DNS 与各层缓存:解析是一条多级缓存链路、用 networkaddress.cache.ttl 解开 JVM 永久缓存、负缓存在 failover 时的坑、连接池长连接也焊死旧 IP 要设存活时间、容器时代 IP 是流动的、对比系统解析与进程连接来排查,以及 hosts 文件优先级最高和一个域名多个 IP 的隐蔽偶发超时。

那天下游团队做了一次再寻常不过的机房迁移:他们的服务换了台机器,域名没变,只是把 DNS 解析指向了新的 IP,并提前通知了所有调用方"切换无感知,DNS 会自动生效"。我们的服务通过域名调用他们,按理说啥都不用动。结果切换之后,我们的服务开始疯狂报错:连接超时、连接被拒,日志里全是连不上下游的红字。诡异的是,同一台机器上,我用 pingcurl 去访问那个域名,解析到的明明是新 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"这类幽灵问题就能被快速定位。最后,拧成几条可直接照做的铁律:

  1. 显式设置 JVM 的 networkaddress.cache.ttl,别用可能是"永久"的默认值,设成 30~60 秒。
  2. 负缓存 negative.ttl 要设得很小,几秒甚至 0,否则故障切换时会被拖后腿。
  3. DNS 调好还不够,连接池也要设连接最大存活时间,强制淘汰连着旧 IP 的旧连接。
  4. 把这些当容器镜像的标准配置,云原生环境里 IP 是流动的,永久缓存就是灾难。
  5. 排查时对比"系统解析"与"进程实际连接",两者不一致就锁定进程内缓存。
  6. "命令行能连、程序连不上"先怀疑应用层缓存,而非急着甩锅网络或对方。
  7. "重启就好"是缓存类问题的强信号,别满足于重启, 要根治那层缓存配置。

一张 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

转账偶发死锁:MySQL Deadlock 成因与根治避坑

2026-5-30 11:42:47

技术教程

宿主机内存够却被 OOMKilled:容器 JVM 内存避坑

2026-5-30 11:53:05

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索