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的标准切入点:
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构建时注入:
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的限制),那需要自定义HostnameVerifier和SSLSocketFactory:
/** * 自定义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)。实现思路:
/** * 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天然支持这个:
/** * 预连接:提前建好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优化方案应该是这样的分层架构:
/** * 生产级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。
踩坑备忘录
最后分享几个我们踩过的坑,省得你再走一遍:
- HttpDNS自身的可用性
HttpDNS服务自己也可能挂。一定要有降级策略(系统DNS兜底),并且HttpDNS查询要设超时(建议2秒)。曾经有一次HttpDNS服务方升级导致响应变慢,我们的超时没设好,反而比直接用系统DNS更慢了。
- IPv6兼容
HttpDNS返回的可能是IPv4也可能是IPv6地址。确保你的解析逻辑能正确处理AAAA记录,并且在双栈环境下做Happy Eyeballs(先尝试IPv6,250ms没连上立即并发尝试IPv4)。OkHttp从4.x开始内置了Happy Eyeballs支持,但自定义Dns接口时要确保返回的IP列表是IPv6在前、IPv4在后。
- 多进程场景
Android App通常有主进程和push进程。DNS缓存如果只在内存里,每个进程都得各自预解析一遍。可以考虑用MMKV做持久化缓存——App冷启动时先读磁盘缓存(即使过期也先用着),同时后台异步刷新。
- WebView里的DNS
WebView有自己的网络栈,不走OkHttp。如果H5页面也有劫持问题,需要在WebView层面单独处理。Android的WebViewClient.shouldInterceptRequest可以拦截请求做DNS替换,但这会失去HTTP缓存等WebView内置优化,慎用。更好的方案是通过WebView的安全浏览配置+DNS-over-HTTPS来解决。
- 不要滥用预解析
预解析域名不是越多越好。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篇:网络监控与容灾:让网络问题无处遁形
— 系列持续更新中,关注不迷路 —
