Skip to content

DNS 改了,为什么 Java 服务还在连旧地址 ​

把一个域名的记录改到新机器上,记录的 TTL 是 5 秒。JVM 要过 30 秒才解析到新地址;解析到之后,用 HttpURLConnection 的那部分请求照样发给旧机器,一直发到旧机器主动断开连接为止。

切换机房、迁移服务、摘掉一台故障机器时,常见的做法是改 DNS 记录,等 TTL 过期,流量就会过去。对 Java 服务来说,这个「等 TTL 过期」并不成立:记录改了之后,还要经过 JVM 自己的解析缓存,以及 HTTP 客户端复用的连接,才会真正连到新地址。

本文在一个自建的 Docker 网络里复现这个过程:一个只负责 lab.test 的 CoreDNS 做权威 DNS,两个后端各自返回自己的名字,客户端在 JDK 21.0.12 容器里运行。实验中途改写 api.lab.test 的记录,看请求什么时候换到新后端。不涉及任何真实域名,见文末配套实验。

权威 DNS记录 TTL递归解析器、系统按记录 TTL 缓存JVM InetAddress 缓存默认 30 秒,失败 10 秒JDK HttpClient按解析结果建新连接HttpURLConnection复用连到旧地址的连接networkaddress.cache.ttl 可调对端关闭连接后才会重新解析实测:记录 TTL 5 秒,JVM 默认 30 秒后才解析到新地址;新加的记录,之前查过一次失败的话要等 10 秒
图 1 · 记录改了以后,Java 服务要依次越过几层:DNS 服务器与递归解析器按记录 TTL 缓存;JVM 的 InetAddress 缓存不看记录 TTL,默认成功结果 30 秒、失败结果 10 秒;最后是 HTTP 客户端复用的连接,HttpURLConnection 会一直用着连到旧地址的连接

一、JVM 不看记录的 TTL ​

记录的 TTL 设为 5 秒,改写记录后,多久 InetAddress.getByName 才返回新地址:

networkaddress.cache.ttl解析到新地址
未设置(默认)约 30.2 秒
5约 5.1 秒
0约 0.6 秒

默认设置下,JVM 把成功的解析结果缓存 30 秒,和记录的 TTL 无关。InetAddress 的文档只说默认缓存时间「由实现决定」,HotSpot 在没有安装安全管理器时的默认值就是这 30 秒。设为 0 时剩下的约 0.6 秒,来自实验里 CoreDNS 每秒重新加载一次记录文件,真实环境里还要加上递归解析器和操作系统的缓存。

这个值是 Java 安全属性,不是系统属性,有两种设置方式:

properties
# $JAVA_HOME/conf/security/java.security,或者用 -Djava.security.properties=<文件> 追加
networkaddress.cache.ttl=10
java
// 必须在第一次解析之前执行,之后再改不一定生效
Security.setProperty("networkaddress.cache.ttl", "10");

取值要在「切换要多快生效」和「DNS 查询量」之间权衡:设成 0 意味着每次新建连接都要查一次 DNS,DNS 服务器慢或者不可用时,建连也跟着慢或者失败。多数服务取 5—30 秒。

二、查不到的结果也会被缓存 ​

先解析一个还不存在的名字,得到 UnknownHostException;随后加上这条记录,约 10.2 秒之后才能解析到。

这是 networkaddress.cache.negative.ttl,文档写明默认 10 秒。典型的踩坑场景是「服务先启动、域名后配置」:服务启动时查了一次,失败了,运维随后加上记录,服务还要再失败 10 秒。发布流程里如果依赖新域名,要么先配好记录再启动服务,要么把这个值调小。

JDK 21 还提供了 networkaddress.cache.stale.ttl:缓存过期后重新查询失败时,可以继续使用过期的地址一段时间。DNS 服务器短暂不可用时,它能避免所有新建连接一起失败;代价是这段时间里记录的变化也不会生效。

三、解析到新地址,不等于连到新地址 ​

把 JVM 缓存设为 0,排除第一层的影响。客户端每 500ms 请求一次,第 3 秒改写记录,第 20 秒让旧后端开始在响应里带 Connection: close:

JDK HttpClientb-1backend-2HttpURLConnectionbackend-1(DNS 已经改了)backend-2第 3 秒 改写记录第 20 秒 旧后端开始关闭连接010 秒20 秒30 秒40 秒条形里写的是请求实际落在的后端
图 2 · 每 500ms 请求一次,JVM 缓存设为 0。第 3 秒改写记录后,HttpClient 的新请求马上换到 backend-2;HttpURLConnection 一直复用连到 backend-1 的连接,直到第 20 秒 backend-1 开始返回 Connection: close 才切过去
客户端改写记录后、旧后端关闭连接前旧后端关闭连接后
JDK HttpClientbackend-2 32 次,backend-1 1 次全部 backend-2
HttpURLConnection全部 backend-1(34 次)backend-2 38 次,backend-1 1 次

两种客户端都在复用连接,行为却完全不同。HttpClient 在解析结果变化后,新请求直接建立到新地址的连接,旧连接闲置后自然关闭。HttpURLConnection 的 keep-alive 连接按主机名和端口复用,只要这条连接一直在用、对端也不关,它就一直连着旧地址。这一段时间里,JVM 已经解析到新地址了,请求却一条也没过去。

HttpURLConnection 是 JDK 最老的 HTTP 客户端,很多框架默认用它,比如直接 new RestTemplate() 时使用的 SimpleClientHttpRequestFactory(通过 Spring Boot 的 RestTemplateBuilder 创建时,会优先选用 classpath 上的其他客户端,用的是哪一个要看实际配置)。数据库连接池、消息队列客户端这类长连接更是如此:连接建立之后就不再解析,直到连接因为超时、出错或者达到最大寿命而重建。

四、切换时怎么做 ​

让旧机器主动断开连接。 实验里真正让 HttpURLConnection 切过去的,是旧后端开始返回 Connection: close。改完 DNS 后,不要直接关掉旧机器,而是先让它进入「排空」状态:继续处理请求,但每个响应都告诉客户端关闭连接,或者优雅停机、逐步关闭空闲连接。等旧机器上的请求降到零再下线。

给长连接设最大寿命。 连接池一般都有这个配置,比如 HikariCP 的 maxLifetime。它决定了 DNS 切换后最坏要多久,所有连接才会重建到新地址。Kubernetes 的 Service 也是在建立连接时选择后端,长连接同样会让扩容出来的新 Pod 迟迟接不到流量,见一次请求在集群里经过了什么。

把 JVM 缓存调到和切换要求相符。 默认 30 秒对多数场景可以接受;要求更快切换时调小,同时确认 DNS 服务器扛得住查询量。

切换期间看两边的流量。 判断切换是否完成,要看旧机器上的请求数和连接数有没有降到零,而不是看 DNS 查询结果。上一节的实验里,DNS 查询结果早就是新地址了。

不要在 DNS 切换期间让旧机器直接消失。 旧机器突然下线,复用着旧连接的客户端会遇到连接被重置;请求已经发出去的那部分,能不能重试要看接口是否幂等,见 一次 HTTP 调用超时了,对方到底执行了没有。

请求在解析、路由、邻居解析、握手各阶段失败时,客户端分别看到什么,见 请求失败时,先看它停在了哪一步。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权