WRITING / 2021.08.02

工程日志:一次本机 IP 获取引发的性能排查

从线程堆栈、JDK 源码到系统调用,复盘一个看似轻量的日志字段如何在高并发链路中制造锁竞争与 DNS 开销。

一次常规发布后,服务的业务逻辑没有明显变化,整体响应时间却出现了持续抬升。最终定位到的原因并不在数据库、缓存或远程接口,而是一行为了补充日志上下文而增加的代码:每个请求都调用 InetAddress.getLocalHost() 获取本机 IP。

这次排查提醒我,位于请求主链路上的“辅助逻辑”同样需要接受性能审视。日志、监控和链路标记并不天然廉价。

现象与第一条证据

问题出现后,首先对比了发布前后的代码差异,并通过线程堆栈观察运行状态。堆栈中出现了大量等待,调用位置集中在本机地址解析。

与其立刻把结论归因于 DNS,更重要的是继续回答两个问题:

  1. 这个方法内部是否存在共享锁?
  2. 缓存未命中时,它究竟会执行哪些系统操作?

沿着源码继续向下

InetAddress.getLocalHost() 并不是简单读取一个进程变量。典型路径包括:

读取 hostname
    ↓
进入共享缓存临界区
    ↓
缓存未命中或失效
    ↓
根据 hosts / DNS 解析 hostname
    ↓
返回地址并更新缓存

当机器的 hostname 无法从本地 hosts 文件正确解析时,请求会继续走名称服务。如果解析失败,结果可能无法稳定进入缓存,后续调用便会重复读取配置并发起查询。

在低频调用中,这段成本不明显;一旦它进入高并发请求过滤器,共享锁和外部名称解析会被同时放大。

用系统工具验证假设

排查没有停留在源码阅读,而是继续使用系统工具验证实际执行路径:

  • jstack:确认线程等待集中在哪个调用点;
  • strace:观察进程是否反复读取 hosts、resolver 配置并访问名称服务;
  • tcpdump:确认是否真的产生了 DNS 网络交互;
  • 小型基准:比较不同机器环境下重复调用的耗时差异。

源码告诉我们“可能发生什么”,系统调用和抓包则证明“线上实际发生了什么”。两种证据结合后,才能避免把环境差异误判成业务代码问题。

修复策略

本机地址属于生命周期较长的元数据,不应在每次请求中重新发现。修复分为两步:

  1. 服务启动时获取并缓存一次,业务请求只读取缓存结果;
  2. 避免依赖 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 方法有锁,而是建立一种习惯:对进入高频路径的每一步,都追问它的最坏成本和失败路径。