Android 的 DNS 解析缓存,OkHttp 怎么控制

Android 的 DNS 解析缓存,OkHttp 怎么控制

Android 的 DNS 解析缓存,OkHttp 怎么控制


Android 的 DNS 解析缓存,OkHttp 怎么控制


去年一次线上切流,我们给 CDN 域名换了 A 记录指向备用集群。监控里 iOS 端的错误率在五分钟后开始回落,Android 端的超时报警却持续了近十五分钟。后端确认新 IP 已经全网生效,测试组在 Android 机上抓包,发现 SYN 包还在往旧的 42.x.x.x 发。OkHttp 的 EventListener 日志里,connectStart 的 remoteAddress 就是旧 IP,中间没有重新解析的迹象。问题明显卡在 DNS 层。


先排除连接池:不是 ConnectionPool 的锅


第一反应是 OkHttp 的 ConnectionPool 在复用旧连接。ConnectionPool 默认保持空闲连接 5 分钟,如果连接没断开,请求确实会复用旧链路,不会触发 DNS。为了验证,我临时写了一个测试版本,把 ConnectionPool 改成 new ConnectionPool(0, 1, TimeUnit.SECONDS),甚至每个请求都强制新建 OkHttpClient 实例。重新发起请求,日志里 dnsStart 之后直接 connectStart 到旧 IP,中间没有新的 DNS 查询包。这说明 TCP 连接复用不是元凶,真正的瓶颈在 DNS 解析结果本身就已经是旧 IP。


Java 层的 DNS 缓存,Android 和桌面 JVM 不是一回事


标准 Java 里,DNS 缓存由 InetAddress 内部的静态 Cache 管理。桌面 JVM 通常可以通过 Security.setProperty("networkaddress.cache.ttl", "0") 来关闭或缩短缓存。我尝试在 Application.onCreate 里加了这行,甚至也加了 System.setProperty("sun.net.inetaddr.ttl", "0") 这种历史兼容写法。打包测试后,结果很混乱:单元测试里有时候生效,集成到 App 里就失效;Android 10 上无效,Android 7 上似乎又有点用。


根本原因在于 Android 的 libcore 虽然逐步合并了 OpenJDK 代码,但 InetAddress 的缓存逻辑仍然保留了早期 Android 的改造。Android 8.0 开始全面转向 OpenJDK,可 InetAddressAddressCache 默认策略表现得很长,不像桌面 JVM 那样默认走 30 秒 TTL。Security.getProperty 读取的属性值,在解析器初始化时就已经被缓存,而 App 内的第三方 SDK(埋点、推送、甚至某些 Glide 的默认初始化)经常在 ContentProvider 阶段——也就是 Application.onCreate 之前——就抢先触发了域名解析。一旦某个域名被解析过,对应的缓存条目就按当时的 TTL 策略存放,后续修改 property 只能影响之后首次解析的域名,无法清掉已有的条目。这让全局关闭缓存的方案变得极其脆弱。


系统解析链路:从 Java 到 netd


Android 的 DNS 解析链路比想象中长。从 InetAddress.getAllByName 进去,先查 Java 层的 AddressCache,未命中再走 JNI 到 Native 的 android_getaddrinfofornetwork,经过 netd 守护进程,最后到 Bionic 的解析器。这个链路上任何一层都可能存在缓存。Java 层是最直接的因素,但 netd 作为系统服务,也有自己的缓存策略。


为了确认瓶颈在哪一层,我在测试机上做了对比实验:用 adb shell 执行 ndc resolver clearnetdns(需要 root)清空 netd 缓存,再观察 App 行为。发现清掉 netd 后,如果 Java 层 InetAddress 缓存还在,OkHttp 仍然不会重新发 DNS 请求;反过来,如果绕过 InetAddress 缓存,即使 netd 有缓存,重新调用 getAllByName 时也会正常走到 netd 层。这个实验确认了 Java 层的 AddressCache 是 App 能够触及、也必须绕过的主要瓶颈。


在 Android 7.0 以前,还可以通过反射拿到 InetAddress.addressCache 这个静态字段,调用 clear() 强行清空。但 Android 9.0(API 28)开始,Google 对非 SDK 接口的限制收紧,反射私有字段在 P 上被 greylist 限制,Q 上直接封死。所以"清缓存"这条路在 Modern Android 上彻底走不通。


OkHttp 的 Dns.SYSTEM 到底做了什么


回头看 OkHttp 4.10.0(我们当时用的版本)的 Dns 接口,默认实现 Dns.SYSTEM 简单到只有一行:


public List<InetAddress> lookup(String hostname) throws UnknownHostException {
    if (hostname == null) throw new UnknownHostException("hostname == null");
    return Arrays.asList(InetAddress.getAllByName(hostname));
}

这意味着 OkHttp 本身对 DNS 缓存没有任何干预能力,它把解析原封不动委托给系统,系统的缓存策略决定了 OkHttp 看到什么。所以想要控制 DNS 缓存,必须自己实现 Dns 接口,把解析和缓存逻辑都接管过来。


自定义 Dns:接管解析与缓存


我们最终实现了一个 TtlDnsCache 实现 Dns 接口。核心思路是:内部仍然用 InetAddress.getAllByName 做实际解析,但解析结果不直接返回,而是存在内存里,按域名维度设置 TTL。这样既能避免每次解析都去触达系统(减少 JNI 和系统调用开销),又能精确控制过期时间。关键代码如下:


class TtlDnsCache implements Dns {
    private final Dns system = Dns.SYSTEM;
    private final ConcurrentHashMap<String, List<InetAddress>> cache = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, Long> timestamps = new ConcurrentHashMap<>();
    private final long ttlMillis;

    TtlDnsCache(long ttlMillis) {
        this.ttlMillis = ttlMillis;
    }

    @Override public List<InetAddress> lookup(String hostname) throws UnknownHostException {
        List<InetAddress> addresses = cache.get(hostname);
        Long cachedAt = timestamps.get(hostname);
        if (addresses != null && cachedAt != null 
                && (SystemClock.elapsedRealtime() - cachedAt) < ttlMillis) {
            return addresses;
        }
        List<InetAddress> fresh = system.lookup(hostname);
        cache.put(hostname, fresh);
        timestamps.put(hostname, SystemClock.elapsedRealtime());
        return fresh;
    }
}

这里有几个细节。SystemClock.elapsedRealtime() 用来防止用户调系统时间导致缓存永不过期。ConcurrentHashMap 在多读少写的场景下足够,不需要额外加锁。Dns.SYSTEM 在内部被封装,保证每次透传都能拿到最新结果,只是被我们缓存了一层。更重要的一点是,这个自定义缓存完全绕过了 InetAddress 的静态缓存,不管系统层怎么存,我们只认自己的 TTL。


踩坑:InetAddress 的缓存清零时机


在采用自定义 Dns 之前,我还试过另一个方向:在 App 启动最早期关闭 Java 缓存。前面提到,很多 SDK 在 ContentProvider 初始化阶段就抢先解析了域名。ContentProvider.onCreate 的调用时机早于 Application.onCreate,如果你把 Security.setProperty 写在 Application 里,已经晚了。有人可能会想把代码挪到 attachBaseContext,但这仍然晚于 manifest 中声明的 ContentProvider 初始化。


另一个坑是 Security.setProperty("networkaddress.cache.negative.ttl", "0")。负缓存(即解析失败后的缓存)在 Android 上默认表现也很顽固。如果某次网络抖动导致域名解析失败,负缓存可能会让后续请求在几分钟内直接返回 UnknownHostException,而不是重新尝试解析。Security.setProperty 对负缓存的控制同样受制于初始化时机。与其跟系统搏斗,不如直接在应用层接管。


数据验证:缓存命中前后的耗时差异


我在实验室环境做了组对比测试。测试机 Pixel 4,Android 12,WiFi 网络,dns 服务器为内网 DNS。用同一个 OkHttpClient 连续请求同一个域名,间隔 5 秒,通过 EventListener 抓 dnsStartdnsEnd 的耗时。


  • 默认 Dns.SYSTEM:第一次 dnsEnd 耗时 143ms,后续连续 20 次请求均没有触发 dnsStart,说明命中了系统缓存。杀掉 App 重启后,第一次再次 151ms,之后继续命中。
  • 自定义 TtlDnsCache,TTL 设为 0(即每次透传):每次请求都触发 dnsStart,平均耗时 89ms,波动在 40ms~220ms 之间。
  • 自定义 TtlDnsCache,TTL 设为 60 秒:第一次 95ms,后续命中内存缓存,耗时在 0.1ms~0.3ms 之间,日志上 dnsStartdnsEnd 几乎贴在一起
  • kotlinx.serialization 的稳定性,生产环境敢用吗 2026-07-20
    AGP 8.0 的 R8 全模式,我迁移时遇到的坑 2026-07-20

    评论区