
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下200ms4G下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记录有个TTLTime 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): ListInetAddress { // 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。这里有两个严重问题问题一SNIServer Name IndicationTLS握手时客户端需要在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不需要处理SNIOkHttp内部全部帮你搞定。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 ConcurrentHashMapString, DnsCacheEntry() data class DnsCacheEntry( val addresses: ListInetAddress, 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): ListInetAddress? { 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还提前完成TCPTLS握手把连接放入连接池等着用。OkHttp的ConnectionPool天然支持这个/** * 预连接提前建好TCPTLS连接 * 利用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): ListInetAddress { // 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-3300msDNS劫持率• 接入前: 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先尝试IPv6250ms没连上立即并发尝试IPv4。OkHttp从4.x开始内置了Happy Eyeballs支持但自定义Dns接口时要确保返回的IP列表是IPv6在前、IPv4在后。多进程场景Android App通常有主进程和push进程。DNS缓存如果只在内存里每个进程都得各自预解析一遍。可以考虑用MMKV做持久化缓存——App冷启动时先读磁盘缓存即使过期也先用着同时后台异步刷新。WebView里的DNSWebView有自己的网络栈不走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篇网络监控与容灾让网络问题无处遁形— 系列持续更新中关注不迷路 —