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 的记录,看请求什么时候换到新后端。不涉及任何真实域名,见文末配套实验。
一、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 安全属性,不是系统属性,有两种设置方式:
# $JAVA_HOME/conf/security/java.security,或者用 -Djava.security.properties=<文件> 追加
networkaddress.cache.ttl=10// 必须在第一次解析之前执行,之后再改不一定生效
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 HttpClient | backend-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 调用超时了,对方到底执行了没有。
请求在解析、路由、邻居解析、握手各阶段失败时,客户端分别看到什么,见 请求失败时,先看它停在了哪一步。
配套实验
- codesphere-labs/network/dns-jvm-cache:自建 CoreDNS 与两个后端;JVM 正向与负向缓存、JDK
HttpClient与HttpURLConnection在 DNS 切换前后的请求去向(验证记录)
参考资料