3. OkHttp 默认 DNS 解析实现:Dns 接口、系统调用与性能瓶颈

聊到 DNS 解析,很多人第一反应就是「哦,不就是域名转 IP 嘛」。但在 Retrofit + OkHttp 的体系里,这一层的效率直接决定了你首包到达的时间。我见过不少项目,优化了半天业务代码,结果发现每次请求都要等 DNS 几百毫秒 —— 这就是典型的「根上没弄对」。

这一节我们就把 OkHttp 默认怎么调 DNS、底层做了什么、以及它到底慢在哪,彻底说清楚。

Dns 接口:OkHttp 留给你的扩展点

OkHttp 并没有把 DNS 写死在核心代码里。它抽象了一个非常干净的接口:

public interface Dns {
    List<InetAddress> lookup(String hostname) throws UnknownHostException;

    Dns SYSTEM = hostname -> {
        // 底层调 InetAddress.getAllByName
        return Arrays.asList(InetAddress.getAllByName(hostname));
    };
}

你瞧,就这么一个方法。传入域名,返回 InetAddress 列表。默认实现 Dns.SYSTEM 直接委托给了 InetAddress.getAllByName。这个静态方法最终走到 getaddrinfo —— 标准的 POSIX 系统调用。

小提示:如果你做单元测试或者 mock,直接实现 Dns 接口就能绕开真实网络。我在项目里经常用这个方法写 DNS 缓存的测试用例。

系统 DNS 调用方式:阻塞、串行、不可控

为什么要单独拿出来说?因为 InetAddress.getAllByName 是 同步阻塞 的。没错,它发起一个 Socket 查询(通常是 UDP 到 53 端口),然后当前线程就挂在那等结果。网络好的时候几十毫秒,网络差或者 DNS 服务器响应慢的时候,几秒都有可能。

OkHttp 默认情况下,每个新域名都会走这个流程。而且,它不会跨请求复用缓存 —— 除非你显式配置了 DNS 缓存。你想想看,如果一个页面有 10 个不同的域名(CDN、图片、API 网关等),第一次打开时每个域名都要等一次 DNS,那启动时间直接就起飞了。

核心问题总结:

  • 调用方式是同步阻塞的
  • 没有内置的本地缓存(依赖系统 DNS 缓存,但不可控)
  • 没有超时设置(默认依赖系统超时,通常很长)
  • 不支持并发解析优化

存在的性能瓶颈:从 ARP 到 ANR

我们来拆解一下,这些「缺陷」在真实场景里到底引发了什么。直接看一个我遇到的典型案例:

有一段时间用户的 ANR 率突然升高,堆栈指向 OkHttp 的连接建立流程。查了半天,发现部分用户的网络环境下,DNS 解析耗时竟然超过 10 秒。为什么?因为他们公司的网络出口走了很慢的公共 DNS,而 OkHttp 默认没有任何兜底措施,直接卡死了主线程(如果使用的是同步请求)。

下面这张图能帮你理清整个调用链路和瓶颈点:

OkHttp 默认 DNS 解析流程与瓶颈 应用发起 HTTP 请求 OkHttp 连接管理器 Dns.SYSTEM.lookup() InetAddress.getAllByName() → getaddrinfo() 同步阻塞 返回 IP 列表 ⛔ 性能瓶颈 • 阻塞式 IO • 无本地缓存 • 无超时控制 • 不支持并发复用

看到红色节点那一层了吗?那就是性能黑洞。别小看这几百毫秒,在移动端网络波动时,它能拖垮整个用户体验。

具体瓶颈拆解

瓶颈维度 具体表现 对 Retrfit 的影响
阻塞调用 lookup() 内部同步等待 UDP 响应,线程挂起 主线程请求直接导致 ANR
无内置缓存 每次 lookup 都触发系统调用,不缓存结果 相同域名反复解析,浪费带宽和时间
超时不可控 依赖系统默认超时(通常 5-30 秒) 网络差时请求排队,队列积压超时
并发无优化 对同一个域名,多个线程会同时发起多次 lookup 无谓并发增加 QPS,可能触发限流

我曾经踩过的一个坑:线上有个页面同时请求三个子域名资源,第一次打开时每个子域名都触发一次 DNS 解析(由于系统 DNS 缓存还没建立),三次耗时加起来超过 3 秒。用户连续点击后退再进入,有些场景下系统 DNS 缓存会被清除,又来一遍。后来我们换成自建 DNS 缓存 + 预热,首屏时间直接降了 40%。

为什么 Retrofit 用户更应该关注?

Retrofit 是基于 OkHttp 的,所以所有请求的 DNS 解析都走上面这一套。而且 Retrofit 的 Converter 和 CallAdapter 往往让开发者忽略网络层细节。你以为只是序列化慢?其实很多时候是 DNS 在拖后腿。

我个人习惯在项目刚起步时就打印一下 DNS 耗时日志,很简单:

OkHttpClient client = new OkHttpClient.Builder()
    .dns(hostname -> {
        long start = System.nanoTime();
        List<InetAddress> addresses = Dns.SYSTEM.lookup(hostname);
        long cost = (System.nanoTime() - start) / 1_000_000;
        Log.d("DNS_DEBUG", hostname + " -> " + addresses + " cost=" + cost + "ms");
        return addresses;
    })
    .build();

把这段代码贴到你的 Retrofit 配置里跑两天,你就能发现有多少「隐藏的慢」。

一句话总结:OkHttp 的默认 DNS 实现是一个严格的同步阻塞模型,没有任何智能缓存和超时兜底。不优化它,你后面做的连接池、重试、拦截器优化都可能被它拖入泥潭。

你要记住,DNS 是网络请求的第一个关卡。我在接下来的章节会给你展示怎么用自建缓存、异步解析、自定义 Dns 实现等方式彻底解决它。但首先,你得承认 —— 默认的 Dns.SYSTEM 真的不够聪明。嗯,至少对性能敏感的 App 来说,它只能算及格。