一次常规发布后,服务的业务逻辑没有明显变化,整体响应时间却出现了持续抬升。最终定位到的原因并不在数据库、缓存或远程接口,而是一行为了补充日志上下文而增加的代码:每个请求都调用 InetAddress.getLocalHost() 获取本机 IP。
这次排查提醒我,位于请求主链路上的“辅助逻辑”同样需要接受性能审视。日志、监控和链路标记并不天然廉价。
现象与第一条证据
问题出现后,首先对比了发布前后的代码差异,并通过线程堆栈观察运行状态。堆栈中出现了大量等待,调用位置集中在本机地址解析。
与其立刻把结论归因于 DNS,更重要的是继续回答两个问题:
- 这个方法内部是否存在共享锁?
- 缓存未命中时,它究竟会执行哪些系统操作?
沿着源码继续向下
InetAddress.getLocalHost() 并不是简单读取一个进程变量。典型路径包括:
读取 hostname
↓
进入共享缓存临界区
↓
缓存未命中或失效
↓
根据 hosts / DNS 解析 hostname
↓
返回地址并更新缓存
当机器的 hostname 无法从本地 hosts 文件正确解析时,请求会继续走名称服务。如果解析失败,结果可能无法稳定进入缓存,后续调用便会重复读取配置并发起查询。
在低频调用中,这段成本不明显;一旦它进入高并发请求过滤器,共享锁和外部名称解析会被同时放大。
用系统工具验证假设
排查没有停留在源码阅读,而是继续使用系统工具验证实际执行路径:
jstack:确认线程等待集中在哪个调用点;strace:观察进程是否反复读取 hosts、resolver 配置并访问名称服务;tcpdump:确认是否真的产生了 DNS 网络交互;- 小型基准:比较不同机器环境下重复调用的耗时差异。
源码告诉我们“可能发生什么”,系统调用和抓包则证明“线上实际发生了什么”。两种证据结合后,才能避免把环境差异误判成业务代码问题。
修复策略
本机地址属于生命周期较长的元数据,不应在每次请求中重新发现。修复分为两步:
- 服务启动时获取并缓存一次,业务请求只读取缓存结果;
- 避免依赖 hostname 的名称解析,按明确规则从网卡地址中选择目标 IPv4 地址。
示意代码如下:
static List<String> localIpv4Addresses() throws SocketException {
List<String> result = new ArrayList<>();
Enumeration<NetworkInterface> interfaces =
NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface network = interfaces.nextElement();
Enumeration<InetAddress> addresses = network.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress address = addresses.nextElement();
if (address instanceof Inet4Address && !address.isLoopbackAddress()) {
result.add(address.getHostAddress());
}
}
}
return result;
}
真实系统还要定义多网卡选择、容器网络、地址变化和获取失败时的降级策略,不能简单取列表中的第一个地址。
这次排查留下的方法
不要忽略辅助链路
日志字段、指标标签、鉴权信息和 Trace 上下文都位于主链路上。任何一次阻塞、锁竞争或网络访问,都会乘以请求量。
固定信息应在生命周期边界计算
如果一个值在进程生命周期内基本不变,应尽量在启动阶段计算;如果它会变化,也应使用独立刷新机制,而不是交给每个请求重复获取。
性能问题需要跨层证据
一次完整的定位通常会跨越应用指标、线程状态、运行库源码、系统调用和网络行为。只看其中一层,很容易得到“看起来合理、但无法闭环”的解释。
这类问题最有价值的地方,不是记住某个 JDK 方法有锁,而是建立一种习惯:对进入高频路径的每一步,都追问它的最坏成本和失败路径。