DNS优化实战:从运营商DNS到HttpDNS的进化之路

Android网络优化系列 · 第2/5篇

从DNS到连接池,打造极速网络体验

第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路(本篇)

⏳ 第3篇:连接优化与复用:让每一次握手都物超所值

⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

为什么DNS是网络优化的第一刀

上一篇我们拆解了一次HTTP请求的完整链路,结论很清晰:网络耗时的大头不在服务端,而在链路本身。而链路的第一个环节就是DNS解析。

你可能觉得DNS解析很快------毕竟只是把域名翻译成IP嘛。但实际线上数据会让你吃惊:我们监控到的DNS解析P99耗时,在某些运营商网络下能到2000ms以上。更恐怖的是,这还不算DNS劫持导致的解析错误------你以为连的是你的CDN节点,实际被运营商劫持到了一个小水管服务器上。

上一篇里那个"WiFi下200ms,4G下8秒"的线上故障,最终定位就是DNS层面的问题。运营商LocalDNS缓存过期后,递归查询走了三跳才拿到结果,加上TTL设置不合理导致频繁重新解析。

所以这篇的核心命题是:如何把DNS解析从一个不可控的黑盒,变成一个可预测、可兜底、可优化的环节

运营商LocalDNS的四大坑

在聊解决方案之前,先把问题搞透。Android设备默认使用运营商提供的LocalDNS进行域名解析,这套机制存在几个根本性问题:

坑一:DNS劫持

部分运营商会劫持DNS查询,将你的域名解析结果替换为自己的广告服务器IP,或者将流量引导至自己的缓存服务器。这种行为在小运营商尤为常见。表现形式包括:

• 页面中间插入广告iframe

• 接口返回301重定向到一个你从没见过的域名

• HTTPS请求因为证书不匹配直接失败(这其实是好事,至少知道被劫持了)

坑二:解析调度不准

CDN厂商的智能调度依赖一个前提:DNS服务器向权威DNS发起递归查询时,权威DNS根据递归DNS的出口IP来判断用户地理位置,然后返回最近的CDN节点。

问题在于,很多运营商的LocalDNS不直接向权威DNS递归,而是转发给上级DNS。这样权威DNS看到的是上级DNS的IP,调度结果就偏了。最典型的场景:广东用户被调度到北京的CDN节点,多走了几千公里。

坑三:缓存策略混乱

DNS记录有个TTL(Time To Live)字段,告诉递归DNS这个记录可以缓存多久。但运营商LocalDNS经常不遵守:

• 有的会强制延长TTL,导致你在DNS上做的灰度切换/故障切换迟迟不生效

• 有的反而不缓存,每次都重新递归,解析耗时飙升

• 有的在TTL未过期时就清了缓存,导致无谓的查询放大

坑四:解析超时长、成功率低

LocalDNS本身也是个服务,也有过载的时候。高峰期DNS查询超时率上升,直接拉高你的首屏耗时。我们观测到的数据:某些三线城市的DNS解析失败率能到3-5%,超时(>1s)比例能到8%。

这四个问题的根源是一样的:你的DNS解析链路完全不受你控制。运营商的LocalDNS是一个黑盒,你既不能控制它的行为,也不能监控它的状态。

HttpDNS:把控制权拿回来

HttpDNS的思路很直接:既然运营商DNS不可控,那我不用它了。域名解析不走标准的UDP 53端口,而是通过HTTP(S)协议向一个可信的DNS服务器发请求。

核心原理:

• 客户端直接向HttpDNS服务器发起HTTP GET请求,参数是待解析的域名

• HttpDNS服务器进行权威解析,返回IP列表

• 客户端拿到IP后直接用IP访问目标服务器(IP直连)

• 整个过程绕开了运营商LocalDNS

带来的收益:

防劫持:走HTTPS通道,运营商无法篡改

调度精准:HttpDNS服务器能拿到客户端真实IP(或ECS扩展),做精确地理调度

实时性强:不依赖运营商的缓存策略,TTL你说了算

可监控:每次解析都有日志,解析成功率、耗时全可量化

OkHttp集成HttpDNS:从原理到代码

OkHttp提供了 Dns 接口,让你可以自定义DNS解析逻辑。这是接入HttpDNS的标准切入点:

kotlin 复制代码
class HttpDnsResolver(
    private val httpDnsService: IHttpDnsService
) : Dns {

    override fun lookup(hostname: String): List<InetAddress> {
        // 1. 先查HttpDNS
        val result = httpDnsService.getAddrByName(hostname)

        if (!result.isNullOrEmpty()) {
            // HttpDNS命中,将IP字符串转为InetAddress
            return result.mapNotNull { ip ->
                try {
                    InetAddress.getByName(ip)
                } catch (e: Exception) {
                    null
                }
            }.ifEmpty {
                // 解析出的IP全部无效,降级到系统DNS
                Dns.SYSTEM.lookup(hostname)
            }
        }

        // 2. HttpDNS未命中/超时,降级到系统DNS
        return Dns.SYSTEM.lookup(hostname)
    }
}

然后在OkHttpClient构建时注入:

scss 复制代码
val client = OkHttpClient.Builder()
    .dns(HttpDnsResolver(httpDnsService))
    .build()

看起来很简单对吧?但真正难的在后面------IP直连时HTTPS怎么处理。

IP直连的HTTPS兼容:SNI与证书校验

当你用HttpDNS拿到IP后直接构建请求,URL变成了 https://1.2.3.4/api/data。这里有两个严重问题:

问题一:SNI(Server Name Indication)

TLS握手时,客户端需要在ClientHello里携带SNI字段告诉服务器"我要访问哪个域名",这样服务器才能返回正确的证书。如果URL里是IP而不是域名,SNI字段就是IP地址------这会导致服务端找不到对应证书,TLS握手失败。

问题二:证书校验

HTTPS证书是颁发给域名的,不是IP。客户端验证证书时会检查证书的CN或SAN是否匹配请求的Host。如果Host是IP,校验必然失败。

解决方案的关键:URL里用域名而不是IP,让OkHttp自定义Dns接口在底层完成域名→IP的映射。这样TLS层面看到的仍然是域名,SNI和证书校验都正常。

这正是前面代码方案的精妙之处------我们重写的是 Dns 接口,而不是去改URL。OkHttp在连接时会先调 dns.lookup(hostname) 拿IP,然后用这个IP去建连,但TLS握手和证书校验仍然用原始hostname。完美。

但如果你的场景必须走IP直连(比如某些SDK的限制),那需要自定义 HostnameVerifierSSLSocketFactory

kotlin 复制代码
/**
 * 自定义HostnameVerifier,在IP直连场景下用原始域名做校验
 */
class HttpDnsHostnameVerifier(
    private val originalHost: String
) : HostnameVerifier {

    override fun verify(hostname: String, session: SSLSession): Boolean {
        // hostname此时是IP,用原始域名去校验证书
        return HttpsURLConnection
            .getDefaultHostnameVerifier()
            .verify(originalHost, session)
    }
}

/**
 * 自定义SSLSocket,连接后设置SNI为原始域名
 */
class HttpDnsSslSocketFactory(
    private val delegate: SSLSocketFactory,
    private val originalHost: String
) : SSLSocketFactory() {

    override fun createSocket(
        socket: Socket,
        host: String,
        port: Int,
        autoClose: Boolean
    ): Socket {
        // 用原始域名创建socket,确保SNI正确
        val sslSocket = delegate.createSocket(socket, originalHost, port, autoClose)
                as SSLSocket
        // 设置SNI
        val params = sslSocket.sslParameters
        params.serverNames = listOf(SNIHostName(originalHost))
        sslSocket.sslParameters = params
        return sslSocket
    }

    // ... 其他createSocket重载省略,逻辑类似
}

我的建议:除非有特殊限制,优先用Dns接口方案,不要碰IP直连。Dns接口方案对业务层完全透明,不需要改URL,不需要处理SNI,OkHttp内部全部帮你搞定。

DNS预解析与预连接策略

HttpDNS解决了"解析质量"的问题,但还有一个性能点容易被忽略:解析时机

默认行为是"用时解析"------用户点击按钮 → 发请求 → 开始DNS解析 → 等待 → 拿到IP → 建连。如果能把DNS解析提前到"用户还没点按钮"的时候,就能省掉这段等待。

这就是DNS预解析(DNS Prefetch)。实现思路:

kotlin 复制代码
/**
 * DNS预解析管理器
 * 在App启动/页面进入时提前解析关键域名
 */
object DnsPrefetchManager {

    private val scope = CoroutineScope(
        Dispatchers.IO + SupervisorJob()
    )

    // 需要预解析的域名列表,按优先级排序
    private val prefetchDomains = listOf(
        "api.yourapp.com",      // 主API
        "cdn.yourapp.com",      // CDN资源
        "img.yourapp.com",      // 图片服务
        "tracker.yourapp.com",  // 埋点上报
    )

    // 本地DNS缓存(域名 → IP列表+过期时间)
    private val cache = ConcurrentHashMap<String, DnsCacheEntry>()

    data class DnsCacheEntry(
        val addresses: List<InetAddress>,
        val expireAt: Long,   // 过期时间戳
        val staleAt: Long     // 陈旧时间戳(过期后仍可用,但需异步刷新)
    )

    /**
     * App启动时调用,批量预解析
     */
    fun prefetchOnAppStart() {
        scope.launch {
            prefetchDomains.forEach { domain ->
                launch {
                    try {
                        val ips = httpDnsService.getAddrByName(domain)
                        if (!ips.isNullOrEmpty()) {
                            cache[domain] = DnsCacheEntry(
                                addresses = ips.map { InetAddress.getByName(it) },
                                expireAt = System.currentTimeMillis() + 300_000, // 5min
                                staleAt = System.currentTimeMillis() + 600_000   // 10min
                            )
                        }
                    } catch (e: Exception) {
                        // 预解析失败不影响正常流程
                        Log.w("DnsPrefetch", "Prefetch failed for $domain", e)
                    }
                }
            }
        }
    }

    /**
     * 查询缓存,支持stale-while-revalidate策略
     */
    fun resolve(hostname: String): List<InetAddress>? {
        val entry = cache[hostname] ?: return null
        val now = System.currentTimeMillis()

        return when {
            now  {
                // 未过期,直接返回
                entry.addresses
            }
            now  {
                // 已过期但未陈旧,返回旧值 + 异步刷新
                scope.launch { refreshAsync(hostname) }
                entry.addresses
            }
            else -> {
                // 完全过期,需要重新解析
                cache.remove(hostname)
                null
            }
        }
    }

    private suspend fun refreshAsync(hostname: String) {
        val ips = httpDnsService.getAddrByName(hostname)
        if (!ips.isNullOrEmpty()) {
            cache[hostname] = DnsCacheEntry(
                addresses = ips.map { InetAddress.getByName(it) },
                expireAt = System.currentTimeMillis() + 300_000,
                staleAt = System.currentTimeMillis() + 600_000
            )
        }
    }
}

注意这里用了stale-while-revalidate策略------灵感来自HTTP缓存的同名机制。当缓存过了"新鲜期"但还在"陈旧期"内时,直接返回旧结果(保证速度),同时后台异步刷新(保证最终一致性)。这比简单的TTL过期后阻塞等待重新解析体验好得多。

预连接(Pre-connect)是预解析的进一步延伸:不仅提前解析IP,还提前完成TCP+TLS握手,把连接放入连接池等着用。OkHttp的ConnectionPool天然支持这个:

scss 复制代码
/**
 * 预连接:提前建好TCP+TLS连接
 * 利用OkHttp的连接池,后续请求直接复用
 */
fun preConnect(url: String) {
    scope.launch {
        try {
            // 发一个HEAD请求触发连接建立
            val request = Request.Builder()
                .url(url)
                .head()
                .build()
            client.newCall(request).execute().close()
        } catch (e: Exception) {
            // 预连接失败不影响业务
        }
    }
}

预连接的最佳实践是在页面路由确定后、数据请求发出前的间隙触发。比如用户点了"订单详情"按钮,页面跳转动画大约300ms,这段时间完全可以预连接订单接口的域名。

完整方案:分层容错的DNS架构

把前面的点串起来,一个生产可用的DNS优化方案应该是这样的分层架构:

kotlin 复制代码
/**
 * 生产级DNS解析器:本地缓存 → HttpDNS → 系统DNS
 * 每一层都是上一层的兜底
 */
class ProductionDnsResolver(
    private val httpDnsService: IHttpDnsService,
    private val prefetchManager: DnsPrefetchManager
) : Dns {

    override fun lookup(hostname: String): List<InetAddress> {
        // Layer 1: 本地缓存(含stale-while-revalidate)
        prefetchManager.resolve(hostname)?.let { cached ->
            return cached
        }

        // Layer 2: HttpDNS实时查询
        try {
            val result = httpDnsService.getAddrByNameWithTimeout(
                hostname, 2000 // 2s超时
            )
            if (!result.isNullOrEmpty()) {
                val addresses = result.mapNotNull { ip ->
                    runCatching { InetAddress.getByName(ip) }.getOrNull()
                }
                if (addresses.isNotEmpty()) {
                    // 写入缓存供后续使用
                    prefetchManager.updateCache(hostname, addresses)
                    return addresses
                }
            }
        } catch (e: Exception) {
            // HttpDNS失败,降级
            Log.w("DNS", "HttpDNS failed for $hostname, fallback to system", e)
        }

        // Layer 3: 系统DNS兜底
        return Dns.SYSTEM.lookup(hostname)
    }
}

这个三层架构保证了:

最快路径(80%+场景):本地缓存命中,解析耗时≈0ms

次快路径(15%场景):HttpDNS实时解析,耗时约50-200ms

兜底路径(5%场景):系统DNS,耗时不确定但至少能解析

永远有结果:不会因为某一层故障导致整个解析链路断掉

实战效果:首请求耗时减少200ms+

我们在一个日活500万的App上落地了上述方案,接入前后的AB测试数据:

DNS解析耗时

• P50: 180ms → 0ms(缓存命中)

• P95: 800ms → 60ms

• P99: 2100ms → 180ms

首屏接口总耗时

• P50: 420ms → 280ms(-140ms)

• P95: 1800ms → 650ms(-1150ms)

• P99: 4200ms → 900ms(-3300ms)

DNS劫持率

• 接入前: 0.8%(约4万次/天)

• 接入后: 0.01%(仅兜底到系统DNS时可能发生)

最显著的改善在长尾------P99从4.2秒降到0.9秒,这意味着之前那些"卡白屏"的用户体验被彻底解决了。而且DNS劫持基本消失,接口异常率从0.8%降到了0.05%以下。

值得注意的是,P50从420ms降到280ms只少了140ms,但P99少了3300ms。这说明DNS优化的最大价值不是让"已经快"的请求更快,而是把"特别慢"的请求拉回正常水平。这也是很多团队忽略DNS优化的原因------看P50觉得"也还行",但用户骂的都是P99。

踩坑备忘录

最后分享几个我们踩过的坑,省得你再走一遍:

  1. HttpDNS自身的可用性

HttpDNS服务自己也可能挂。一定要有降级策略(系统DNS兜底),并且HttpDNS查询要设超时(建议2秒)。曾经有一次HttpDNS服务方升级导致响应变慢,我们的超时没设好,反而比直接用系统DNS更慢了。

  1. IPv6兼容

HttpDNS返回的可能是IPv4也可能是IPv6地址。确保你的解析逻辑能正确处理AAAA记录,并且在双栈环境下做Happy Eyeballs(先尝试IPv6,250ms没连上立即并发尝试IPv4)。OkHttp从4.x开始内置了Happy Eyeballs支持,但自定义Dns接口时要确保返回的IP列表是IPv6在前、IPv4在后。

  1. 多进程场景

Android App通常有主进程和push进程。DNS缓存如果只在内存里,每个进程都得各自预解析一遍。可以考虑用MMKV做持久化缓存------App冷启动时先读磁盘缓存(即使过期也先用着),同时后台异步刷新。

  1. WebView里的DNS

WebView有自己的网络栈,不走OkHttp。如果H5页面也有劫持问题,需要在WebView层面单独处理。Android的WebViewClient.shouldInterceptRequest可以拦截请求做DNS替换,但这会失去HTTP缓存等WebView内置优化,慎用。更好的方案是通过WebView的安全浏览配置+DNS-over-HTTPS来解决。

  1. 不要滥用预解析

预解析域名不是越多越好。HttpDNS有QPS限制和成本(按查询次数计费),预解析只做高频域名(通常3-5个就够了)。低频域名走按需解析+系统DNS兜底即可。

小结

这篇的核心结论:

• 运营商LocalDNS有劫持、调度不准、缓存混乱、超时率高四大问题

• HttpDNS通过HTTP协议绕开运营商,解决前三个问题

• OkHttp的Dns接口是接入HttpDNS的最优方式(对比IP直连方案)

• DNS预解析+stale-while-revalidate把解析耗时降到≈0

• 三层容错架构(本地缓存→HttpDNS→系统DNS)保证100%有结果

• 关注P99而不只是P50------DNS优化的价值在长尾

DNS解决了"找对人"的问题,但找到人之后还有连接建立的开销。下一篇我们聊连接优化与复用------TCP/TLS握手的成本怎么最小化,HTTP/2多路复用怎么配,连接池怎么调。那是又一个能砍掉几百ms的大头。

Android网络优化系列 · 第2/5篇

从DNS到连接池,打造极速网络体验

第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路(本篇)

⏳ 第3篇:连接优化与复用:让每一次握手都物超所值

⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

--- 系列持续更新中,关注不迷路 ---

相关推荐
程序员陆业聪1 小时前
连接优化与复用:让每一次握手都物超所值|Android网络优化系列(3)
android
zhangphil2 小时前
Android Bitmap.Config.HARDWARE属性产生的来源和控制权
android
YF02113 小时前
深度解构Android OkDownload断点续传
android·数据库·okhttp
Co_Hui3 小时前
Android: Service基本使用
android
恋猫de小郭3 小时前
Android Studio 放着没怎么用,怎么也会越来越卡?
android·前端·flutter
Kapaseker3 小时前
Compose 动画 — 显隐的艺术
android·kotlin
黄林晴3 小时前
Android官方发布 AppFunctions,让系统AI直接调用你的APP
android·agent
2501_915909064 小时前
完整指南:如何将iOS应用上架到App Store
android·ios·小程序·https·uni-app·iphone·webview
赏金术士6 小时前
Retrofit + Kotlin 协程(Android 实战教程)
android·kotlin·retrofit