
本文引用了颜向群发表于高可用架构公众号上的文章《聊聊HTTPS环境DNS优化:美图App请求耗时节约近半案例》的部分内容,感谢原作者。
1、引言
移动互联网时代,APP 厂商之间的竞争非常激烈,而良好的用户体验是必须优先考虑的,美图产品以高颜值著称,对产品的用户体验非常重视。从技术的角度来看,客户端的体验优化当中 DNS 优化是非常关键的一环,怎么降低 DNS 的耗时、怎么减少域名劫持等问题,都是大家需要重点解决的研发问题。
本文介绍美图APP在移动端DNS优化的实践(主要针对HTTPS协议),文章内容从DNS问题、原理到最终优化效果,讲解的较全面,值得学习和借鉴。
另外:如您想详细了解移动端DNS的各种杂症及主流解决方案,推荐详读《全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等》。
(原文链接:http://www.52im.net/thread-2172-1-1.html)
2、相关文章
《TCP/IP详解 卷1:协议 - 第14章 DNS:域名系统》
《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》
《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》
3、内容概述

DNS 服务作用于网络连接之前,将域名解析为 IP 地址供后续流程进行连接(原理详见:《TCP/IP详解 卷1:协议 - 第14章 DNS:域名系统》)。
DNS 查询时,会先在本地缓存中尝试查找,如果不存在或是记录过期,就继续向 DNS 服务器发起递归查询,这里的 DNS 服务器一般就是运营商的 DNS 服务器。
在这过程中,会产生一些不可控的问题。
美图的移动端产品在实际用户环境下会面临 DNS 劫持、耗时波动等问题(详见:《全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等》),这些 DNS 环节的不稳定因素,导致后续网络请求被劫持或是直接失败, 对产品的用户体验产生不好的影响。
为此,我们对移动端产品的 DNS 解析进行了优化探索,产生了相应的 SDK。在这过程中,我们参考借鉴了业内的主流方案,也进行了一些实践上的思考。
下面的内容会主要以 Android 平台来进行说明。
4、LocalDNS VS HTTP DNS
在长期的实践中,互联网公司发现 LocalDNS 会存在如下几个问题:
1)域名缓存:运营商 DNS 缓存域名解析结果,将用户导向网内缓存服务器;
2)解析转发 & 出口 NAT:运营商 DNS 转发查询请求或是出口 NAT 导致流量调度策略失效。
什么是LocalDNS?一般来说,LocalDNS就是指本地ISP运营商的DNS:

▲ 图中“局部DNS服务器”即是LocalDNS
为了解决 LocalDNS 的这些问题,业内也催生了 HTTP DNS 的概念(注:如您对LocalDNS、HTTP DNS这些概念还不了解,请务必先阅读《全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等》)。
HTTP DNS的基本原理如下:
原本用户进行 DNS 解析是向运营商的 DNS 服务器发起 UDP 报文进行查询,而在 HTTP DNS 下,我们修改为用户带上待查询的域名和本机 IP 地址直接向 HTTP WEB 服务器发起 HTTP 请求,这个 HTTP WEB 将返回域名解析后的 IP 地址。
比如 DNSPod 的实现原理如下:

相比 LocalDNS,HTTP DNS 会具备如下优势:
1)根治域名解析异常:绕过运营商的 DNS,向具备 DNS 解析功能的 HTTP WEB 服务器发起查询;
2)调度精准:HTTP DNS 能够直接获取到用户的 IP 地址,从而实现准确导流;
3)扩展性强:本身基于 HTTP 协议,可以实现更强大的功能扩展。
那么,是否直接全部走 HTTP DNS 呢?
5、美图APP的DNS 优化策略探索
HTTP DNS 相比 LocalDNS 存在一些优势, 然而 HTTP DNS 本身也是存在一定的成本问题。
美图的产品线丰富,涉及的域名也较为广泛,为了适应各产品的实际场景,在实践中我们设计了较为灵活的策略控制。
首先,在策略上我们并未完全放弃 LocalDNS。
一个 App 涉及的域名众多,在策略上我们能够配置其核心 API 域名走 HTTP DNS,而对于非核心请求我们仍希望它先尝试走 LocalDNS, 在异常情况下才升级走 HTTP DNS。
那么如何判断 LocalDNS 的异常情况呢?
我们选择了几个指标来衡量一个 DNS 服务器的质量情况:
1)IP 记录的 TTL 时间:在 DNS 劫持发生的情况下,返回的 TTL 可能会有非常大的值;
2)解析耗时:如果一个 DNS 服务器解析耗时不理想,那么它也不是我们希望的;
3)返回的 IP 的可连接性:对返回的 IP 进行质量测试,如果连接状况不佳,那么这个 DNS 服务器有劫持的可疑。
在 Android 平台上,通过系统方法获得的解析结果信息是非常有限的,上面的指标有的将无法获取,因此在实践中我们会自己去构造 DNS 查询报文,向运营商的多个 DNS 服务器发起查询。
通过上面几个指标的综合评定,当 LocalDNS 表现不佳的时候,策略上我们将升级走 HTTP DNS,尝试让用户获取更好的 DNS 解析效果。
在 DNS 解析环节,还有一个我们比较关心的指标,那就是 DNS 解析的耗时:
1)LocalDNS 在过期的情况下,会发起递归查询,这个时间是不可控的,在部分情况下甚至能达到数秒级别;
2)HTTP DNS 相对会好一些,但正常来看,也会有200ms 左右的耗时。
这个时间能否再优化一些呢?
我们 SDK 在本地构建了自己的记录缓存池,每次通过 LocalDNS 或是 HTTP DNS 解析得到记录都存在缓冲池中。
当然,这个是普遍的做法,系统底层的 netdb 库也是这样实现。
区别在于我们做了一个小改动:对于过期的记录我们采用懒更新的策略,当查到过期的缓存记录时,先返回过期记录给用户,同时再异步重新发起 DNS 查询更新缓存记录。
这个小改动能够保证我们二次解析时都能命中本地缓存,极大地降低 DNS 解析耗时,不过它也带来了一定的风险性。
因此实践中:我们也会添加异步定期的 DNS 记录缓存池扫描功能,及时发现缓存中的过期记录并进行更新,也降低 App 命中过期记录的情况。
5、美图APP无侵入的 SDK 接入方式探索
在 DNS 优化的实践中,我们遇到最大的问题,倒不是策略层面设计问题,而是我们的 DNS SDK 运用到实际 App 产品业务上的姿势问题。
5.1 IP直连方案及各种坑
业内对 HTTP DNS 在实际业务中的接入方式多采用 IP 直连的形式,即原本直接请求 http://www.meitu.com,现在我们先调用 SDK 进行域名解析,拿到 IP 地址比如 1.1.1.1,然后替换域名为: http://1.1.1.1/。
这样操作之后, 由于 URL 中 HOST 已经是 IP 地址,网络请求库将跳过域名解析环节,直接向 1.1.1.1 服务器发起 HTTP 请求。
在实际操作中,对于 IP 直连的方案我们踩了不少的坑。
首先,对于 HTTP 请求,采用 IP 直连的方案后,我们还是需要进行的一个操作是手动配置 Header 中的 HOST :
URL htmlUrl = new URL("http://1.1.1.1/");
HttpURLConnection connection = (HttpURLConnection) htmlUrl.openConnection();
connection.setRequestProperty("Host","www.meitu.com");
HTTP 协议相对比较容易,只需要处理 HOST,那么 HTTPS 呢?
发起HTTPS请求首先需要进行 SSL/TLS 握手,其流程如下:
1)客户端发送 Client Hello,携带随机数、支持的加密算法等信息;
2)服务端收到请求后,选择合适的加密算法,连同公钥证书、随机数等信息返回给客户端;
3)客户端检验服务端证书的合法性,计算产生随机数并用证书公钥加密发送给服务端;
4)服务端通过私钥获取随机数信息,基于之前的交互信息计算得到协商密钥并通知给客户端;
5)客户端验证服务端发送的数据和密钥,通过后双方握手完成,开始进行加密通信。
在我们采用 IP 直连的形式后,上述 HTTPS 的第三步会发生问题,。
客户端检验服务端下发的证书这动作包含两个步骤:
1)客户端用本地保存的根证书解开证书链,确认服务端的证书是由可信任的机构颁发的;
2)客户端需要检查证书的 Domain 域和扩展域是否包含本次请求的 HOST。
证书的验证需要这两个步骤都检验通过才能够进行后续流程,否则 SSL/TLS 握手将在这里失败结束。
由于在 IP 直连下,我们给网络请求库的 URL 中 host 部分已经被替换成了 IP 地址,
因此证书验证的第二步中,默认配置下 “本次请求的 HOST” 会是一个 IP 地址,这将导致 domain 检查不匹配,最终 SSL/TLS 握手失败。
那么该如何解决这个问题?
解决 SSL/TLS 握手中域名校验问题的方法在于我们重新配置 HostnameVerifier, 让请求库用实际的域名去做域名校验。
代码示例如下:
finalURL htmlUrl = newURL("https://1.1.1.1/");
HttpsURLConnection connection = (HttpsURLConnection) htmlUrl.openConnection();
connection.setRequestProperty("Host","www.meipai.com");
connection.setHostnameVerifier(newHostnameVerifier() {
@Override
publicbooleanverify(String hostname, SSLSession session) {
returnHttpsURLConnection.getDefaultHostnameVerifier()
.verify("www.meipai.com",session);
}
});
我们又解决了一个问题,那么 IP 直连下, HTTPS 的问题都搞定了吗?
没有,HTTPS 还有 SNI 的场景要特殊处理。
SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。
它的基本工作原理如下:
1)服务端配置有多个域名和对应的证书。客户端在与服务器建立SSL链接之时,先发送自己要访问站点的域名;
2)服务器根据这个域名返回一个合适的证书。
跟上面 Domain 校验的情况类似,这里的网络请求库默认发送给服务端的 "要访问站点的域名" 就是我们替换后的 IP 地址。
服务端在收到这样一个 IP 地址形式的域名后将是一脸懵逼,找不到对应的证书,最后只好下发一个默认的域名证书回来。
接下来发生的是,客户端在检验证书的 Domain 域时,怎么也检查不通过,因为服务端下发的证书本来就不是对应该域名的。
最后 SSL/TLS 握手失败告终。
上述这个 SNI 场景下的问题,我们是否有办法解决呢?
可以解决,需用客户端重新定制 SSLSocketFactory , 不过修改的代码相对较多,这里就不列举了。
如果我们 SDK 要接入到 App 实际业务中,到 HTTPS SNI 场景处理这里,相信很多同学都崩溃了,接入的工作量其实也不低。
很多情况下可能就做了妥协,只有 Okhttp 场景才使用这个 SDK,因为 Okhttp 本身支持 DNS 替换,没有上面那些问题。
在美图的实践中,我们不仅仅希望 Okhttp 的请求才进行这个 DNS 优化,我们希望在 App H5 页面加载、播放器播放等场景也能应用相应的优化。
在这样的需求下,IP 直连的接入方案带来的接入工作量其实不低,甚至需要改动到部分*。
在最初的实践中,我们也的确尝试了落实 IP 直连 到各个模块,然而即使克服了改造的工作量问题,实际运行上还是会有不少坑。
5.2 美图最终使用的无侵入式 DNS SDK 集成方案
那么,有没有更合适的一种技术方案,能够降低 我们 DNS SDK 的接入工作量,也能兼顾各种使用场景,比如 HTTPS、RTMP 协议等?
基于这样的目标,我们在实践中尝试探索了一种对业务集成友好的无侵入式 DNS SDK 集成方案。下面我们以 Android 平台进行说明。
我们知道在 Java 层面上进行 DNS 解析的基本方式是调用如下方法:
InetAddress.getAllByName("www.meipai.com");
Android 平台上常用的 Okhttp、HttpUrlConnection 等网络请求库都会依赖这个形式的 DNS 解析。
我们深入分析 InetAddress 的运行流程,其大致如下:

在上述流程中我们可以知道,InetAddress 会有到 AddressCache 尝试获取已缓存记录的动作,而这里 AddessCache 是一个 static 的 map 结构变量。
因此,在这里我们来对它做点小手脚 :
1)模仿系统的 AddressCache 构造一个我们自己的 AddressCahce 结构,不过它的 get 方法被替换为从我们 SDK 获取解析记录;
2)通过反射的形式用我们修改后的 AddressCache 替换掉系统的 AddressCache 变量。
这个偷天换日的操作之后,HttpsUrlConnection 等 Java 层网络请求在进行 DNS 解析时就会是这样一个流程:

通过这个形式,我们能够完美解决 Java 层的 DNS SDK 接入问题,对于业务方来说,他们并不需要做任何 URL 替换操作,对应的 HTTPS 场景下的问题也不复存在。
Java 层的接入解决了, 那么 Native 层呢?
我们知道在 Android 平台上,像 WebView、播放器等模块他们进行网络连接的操作都是在 native 层进行的,并不会调用到 Java 层的 InetAddress 方法。
首先在 C/C++ 层,我们知道进行 DNS 解析会使用 getaddrinfo 或是 gethostbyname2 这两个函数。
另外我们还知道,在 Android 等 Linux 系统下,对于 .so 这类可共享对象文件会是 ELF 的文件格式。
因此从这些已知信息,我们可以得到下列一些情况:我们的 App 中 a.so 中直接使用到了系统 libc.so 中的 getaddrinfo 函数,那么根据 ELF 文件规范,在 a.so 的 .rel.plt 表中会有如下关系定义: getaddrinfo ==> 0xFFFFFF 。
.rel.plt 表中的映射关系为 a.so 的运行指出了 getaddrinfo 这个外部符号在当前内存空间中的绝对地址。
正常情况下,a.so 中执行到 getaddrinfo 的函数流程是这样的:

那么在这里,我们是否可以手动修改这个映射表内容,把 getaddrinfo 的内存地址替换成我们的 my_getaddrinfo 地址呢?
这样,a.so 在实际运行时会被拐到我们的 my_getaddrinfo 中?
实际上,确实是可行的。 我们尝试在 SDK 启动后,对 a.so 的 .rel.plt 表进行修改,达到接管 a.so DNS 的目的。
修改后的 a.so 运行流程如下:

通过上面的方式,我们能够比较完美地接管 App 在 Java 层 和 Native 层 DNS 过程,实现业务方无任何额外改动的情况下运用我们的 DNS SDK 优化效果。
6、SDK 上线后的效果表现
在实际运用中,我们取得了比较好的效果。得益于 DNS SDK 在命中本地缓存率上的策略优化,我们的移动端产品在网络请求中 DNS 解析环节耗时得到降低。
从实际监控数据来看,完整网络请求的耗时也能够降低 100ms 左右:

通过 HTTP DNS 的引入和 LocalDNS 优化升级策略,我们的网络请求成功率有提升,在未知主机等具体错误率表现出下降的趋势。
由于 SDK 层面本身做好了灵活的策略配置,我们通过线上监控和配置也让各产品在效益和成本之间取得一个最佳的平衡点。
附录:更多网络通信方面的精华文章
《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》
《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》
《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》
《P2P技术详解(一):NAT详解——详细原理、P2P简介》
《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》
《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》
《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》
《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》
《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》
《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》
《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》
《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》
《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》
《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》
《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》
《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》
《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》
《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》
《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》
《网络编程懒人入门(九):通俗讲解,有了IP地址,为何还要用MAC地址?》
《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》
《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》
《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》
《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》
《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》
《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》
《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》
《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》
《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》
《脑残式网络编程入门(五):每天都在用的Ping命令,它到底是什么?》
《脑残式网络编程入门(六):什么是公网IP和内网IP?NAT转换又是什么鬼?》
《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》
《全面了解移动端DNS域名劫持等杂症:技术原理、问题根源、解决方案等》
《美图App的移动端DNS优化实践:HTTPS请求耗时减小近半》
>> 更多同类文章 ……