看了Joy一篇关于网络部分优化的文章,总结一下,方便以后查阅使用
目前客户端存在的网络问题主要有下面几方面:
1.网络成功率低,经常请求失败
2.用户反馈 DNS 劫持,数据被篡改,出现广告和请求超时等情况
3.网络延迟较长,且存在较多的长尾数据
4.经过数据分析,发现长连的时间明显比短连的时间少 100ms 左右(短连指的是,经过DNS解析、 TCP 握手、 SSL 握手等一系列的过程建立连接,长连指的是直接复用前者的连接通道)
5.网络经常出现抖动,本来大部分请求都是 100ms 左右,突然冒出来一两千毫秒的,甚至有10、20秒的延迟情况
6.HTTP 1.1的head of blocking 情况存在,一个网络抖动,很容易影响后续的请求,导致一连串的延迟较高请求(head of blocking:指的是在 HTTP 1.1 中,如果你发出1、2、3 三个网络请求,那么 Response 的顺序 2、3 要在第一个网络请求之后)
7.传输的 Payload 太大,延迟高,易超时
8.苹果要求HTTPS ,此时加入的 SSL 握手较耗时
下面分别针对上面的几个问题,看各个大厂是如何解决的:
网络成功率低,经常请求失败######
1.携程:
受TCP协议重传机制来保证可靠传输的机制启发,我们在应用层面也引入了重试机制来提高网络服务成功率。我们发现90%以上的的网络服务失败都是由于网络连接失败,此时再次重试是有机会连接成功并完成服务的;同时我们发现前面提到的网络服务生命周期处于1建立连接、序列化网络请求报文、发送网络请求这三个阶段失败时,都是可以自动重试的,因为我们可以确信请求还没有达到服务端进行处理,不会产生幂等性问题(如果存在幂等性问题,会出现重复订单等情况)。当网络服务需要重试时,会使用短连接进行补偿,而不再使用长连接。
实现了上述机制后,携程App网络服务成功率由原先的95.3%+提升为如今的99.5%+(这里的服务成功率是指端到端服务成功率,即客户端采集的服务成功数除以请求总量计算的,并且不区分当前网络状况),效果显著。
2.Peak老师的解决方案(非常完善了)
可靠性保障也是个容易被忽视的方面,在深入探讨之前,可以先将Request按业务属性分类。
1.第一类:关键核心的业务数据,期望能100%送达服务器。
2.第二类:重要内容请求,需要较高的请求成功率.
3.第三类:一般性内容请求,对成功率无要求。
之所以要将请求分为三类,是要在可靠性保障上做区分。理论上我们应该尽可能让所有的请求成功率达到最高,但客户端的流量,带宽,手机电量,服务器的压力等都是有限的资源,所以我们采取的策略是只对关键性的网络请求做高强度的可靠性保障。
第一类请求类似大家用微信时发送的消息,消息数据一旦从输入框发出,从用户来的角度感知这个消息数据是一定会到达对方的。如果网络环境差,网络模块会自动在后头悄悄重试,一段时间后仍无法成功就通过产品交互的方式告知用户发送失败了,即使失败,请求的数据(消息本身)一直存在客户端。
对于这类请求的处理方式第一步不是通过网络发送,而是持久化到Database当中。一旦入了DB,即使断网,断电,重启,请求数据依然还在,只需在App重启的时候还原请求数据,再次发送即可。我们用代码来进一步阐释
//定义请求可靠性类型
typedef enum : NSUInteger {
PPRequestReliability_PersistentToDB,
PPRequestReliability_Retry,
PPRequestReliability_Normal,
} PPRequestReliability;
//增加持久化接口
@interface PPRequest : NSObject
@property (nonatomic, assign) PPRequestReliability reliability;
@property (nonatomic, strong) NSNumber* rowID;
@property (nonatomic, strong) NSNumber* reliabilityStatus;
- (PPRequestRecord*)serializeToRecord;
- (PPRequest*)deserializeFromRecord:(PPRequestRecord*)record;
@end
第一类请求是PPRequestReliability_PersistentToDB,新增了rowID用作存储DB时的唯一标识,reliabilityStatus表示请求的当前状态(成功 or 失败 or 取消 or 进行中),我们再看下发送请求的流程。
@implementation PPRequestManager
- (void)sendRequest:(PPRequest*)req withDelegate:(id)delegate
{
if (req.reliability == PPRequestReliability_PersistentToDB) {
PPRequestRecord* record = [req serializeToRecord];
//save record to database
}
//send request
}
- (void)onRequestSucceed:(PPRequest*)req
{
//add to resend queue
[_resendQueue addObject:req];
//remove from db
}
- (void)onRequestFail:(PPRequest*)req
{
//add to resend queue
[_resendQueue addObject:req];
}
@end
如果判断为第一类请求,第一步我们先将请求持久化到DB当中。第二步发送请求,如果请求失败则将请求加入重试队列,成功则从重试队列中移除。重试队列背后也需要一套通用机制,比如多久重试一次,重试几次之后放弃。遇到最恶劣的场景,请求发送失败之后,App被kill。我们需要在App重启之后从DB当中重新load所有失败的请求再次重试。
- (void)resendPreviousFailedRequest
{
//load from db
//send requests
}
通过上述几步基本上可以使请求的可靠性得到极大的保障。但100%是无法实现的理想,失败的时候用户重装App,所有持久化的数据丢失,请求数据也就丢了,不过这种极端的场景非常少。
第二类请求的可靠性为PPRequestReliability_Retry,这类请求的例子可以是我们App启动时用户看到的首页,首页的内容从服务器获取,如果第一次请求就失败体验较差,这种场景下我们应该允许请求有机会多试几次,增加一个retryCount即可。
//PPRequest.h
@property (nonatomic, assign) int retryCount;
一般3次的重试基本可以排除网络抖动的情况。三次失败之后即可认为请求失败,通过产品交互告知用户。
第三类请求的重要性最低,比如进入Controller的UV采集打点。这类请求只需要做一次,即使失败也不会对产品体验产生什么负面影响。
用户反馈 DNS 劫持,数据被篡改,出现广告和请求超时等情况
1.携程
如果是首次发送基于HTTP协议的网路服务,第一件事就是进行DNS域名解析,我们统计过DNS解析成功率只有98%,剩下2%是解析失败或者运营商DNS劫持(Local DNS返回了非源站IP地址),同时DNS解析在3G下耗时200毫秒左右,4G也有100毫秒左右,延迟明显。我们基于TCP连接,直接跳过了DNS解析阶段,使用内置IP列表的方式进行网络连接。
携程App内置了一组Server IP列表,同时每个IP具备权重。每次建立新连接,会选择权重最高的IP地址进行连接。App启动时,IP列表的所有权重是相同的,此时会启动一组Ping的操作,根据Ping值的延迟时间来计算IP的权重,这么做的原理是Ping值越小的IP地址,连接后的网络传输延迟也应该相对更小。业界也有使用HTTP DNS方式来解决DNS劫持问题,同时返回最合适用户网络的Server IP。然而HTTP DNS的开发和部署需要不小的开发成本,我们目前没有使用。
内置Server IP列表也会被更新,每次App启动后会有个Mobile Config服务(支持TCP和HTTP两种网络类型服务)更新Server IP列表,同时支持不同产品线的Server IP列表更新。因此,传统DNS解析能够解决多IDC导流的功能也可以通过此方法解决。
2.老司机Peak老师的解决方法:
一个打包到app包里面的默认映射文件,这样可以避免第一次去服务器取配置文件带来的延迟。
有一个定时器能每隔一段时间从服务器获取最新的映射,并覆盖本地。
每次取到最新的映射文件之后,同时把上一次的映射文件保存起来作为替补,一旦出现线上配置失误不至于导致请求无法处理。
如果映射文件不能处理域名,要能回滚使用默认的DNS解析服务。
如果一个映射过后的ip持续导致请求失败,应该能从机制上保证这个ip地址不再使用。也就是需要一个无效映射淘汰机制。
无效的ip地址能及时上报到服务器,及时发现问题更新映射文件。
3.美团(IP直连方案)
程序启动的时候拉取"api.dianping.com"对应的所有的IP列表;对所有IP进行跑马测试,找到速度最快的IP。后续所有的HTTPS请求都将域名更换为跑马最快的IP即可。
举个例子,假如:经过跑马测试发现域名"api.dianping.com"对应最快的IP是"1.23.456.789"。
URL" http://api.dianping.com/ad/command?param1=123"将被替换为"http://1.23.456.789/ad/command?param1=123"
IP直连方案有下面几大优势:
- 摒弃了系统DNS,减少外界干扰,摆脱DNS劫持困扰。
- 自建DNS更新时机可以控制。
- IP列表更换方便。
此外,如果你的App域名没有经过合并,域名比较多,也建议可以尝试使用HttpDNS方案。参考:http://www.tuicool.com/articles/7nAJBb 对HTTPS中的证书处理:
HTTPS由于要求证书绑定域名,如果做IP直连方案可能会遇到一些麻烦,这时我们需要对客户端的HTTPS的域名校验部分进行改造,参见:http://blog.csdn.net/github_34613936/article/details/51490032 。
经过域名合并加上IP直连方案改造后,HTTP短连的端到端成功率从95%提升到97.5%,网络延时从1500毫秒降低到了1000毫秒,可谓小投入大产出。
4.腾讯(HTTPDns)
HttpDNS的原理非常简单,主要有两步:
A、客户端直接访问HttpDNS接口,获取业务在域名配置管理系统上配置的访问延迟最优的IP。(基于容灾考虑,还是保留次选使用运营商LocalDNS解析域名的方式)
B、客户端向获取到的IP后就向直接往此IP发送业务协议请求。以Http请求为例,通过在header中指定host字段,向HttpDNS返回的IP发送标准的Http请求即可。
(2)HttpDNS优势:
从原理上来讲,HttpDNS只是将域名解析的协议由DNS协议换成了Http协议,并不复杂。但是这一微小的转换,却带来了无数的收益:
A、根治域名解析异常:由于绕过了运营商的LocalDNS,用户解析域名的请求通过Http协议直接透传到了腾讯的HttpDNS服务器IP上,用户在客户端的域名解析请求将不会遭受到域名解析异常的困扰。
B、调度精准:HttpDNS能直接获取到用户IP,通过结合腾讯自有专利技术生成的IP地址库以及测速系统,可以保证将用户引导的访问最快的IDC节点上。
C、实现成本低廉:接入HttpDNS的业务仅需要对客户端接入层做少量改造,无需用户手机进行root或越狱;而且由于Http协议请求构造非常简单,兼容各版本的移动操作系统更不成问题;另外HttpDNS的后端配置完全复用现有权威DNS配置,管理成本也非常低。总而言之,就是以最小的改造成本,解决了业务遭受域名解析异常的问题,并满足业务精确流量调度的需求。
D、扩展性强:HttpDNS提供可靠的域名解析服务,业务可将自有调度逻辑与HttpDNS返回结果结合,实现更精细化的流量调度。比如指定版本的客户端连接请求的IP地址,指定网络类型的用户连接指定的IP地址等。
当然各位可能会问:用户将首选的域名解析方式切换到了HttpDNS,那么HttpDNS的高可用又是如何保证的呢?另外不同运营商的用户访问到同一个HttpDNS的服务IP,用户的访问延迟如何保证?
为了保证高可用及提升用户体验,HttpDNS通过接入了腾讯公网交换平台的BGP Anycast网络,与全国多个主流运营商建立了BGP互联,保证了这些运营商的用户能够快速地访问到HttpDNS服务;另外HttpDNS在多个数据中心进行了部署,任意一个节点发生故障时均能无缝切换到备份节点,保证用户解析正常。
网络延迟较长,且存在较多的长尾数据
1.携程
和HTTP协议中的Keepalive特性一样,最直接减少网络服务时间的优化手段就是保持长连接。每次TCP三次握手连接需要耗费客户端和服务端各一个RTT(Round trip time)时间才能完成,就意味着100-300毫秒的延迟;TCP协议自身应对网络拥塞的Slow Start机制也会影响新连接的传输性能。
携程App使用了长连接池的方式来使用长连接,长连接池中维护了多个保持和服务端的TCP连接,每次网络服务发起后会从长连接池中获取一个空闲长连接,完成网络服务后再将该TCP连接放回长连接池。我们没有在单个TCP连接上实现Pipeline和Multiplexing机制,而是采用最简单的FIFO机制,原因有二:
简化Mobile Gateway的服务处理逻辑,减少开发成本;
在服务端同时返回多个响应时,如果某个响应报文非常大,使用多个长连接方式可以加快接收服务响应报文速度。
如果发起网络服务时长连接池中的TCP连接都正在被占用,或者TCP长连接的网络服务失败,则会发起一个TCP短连接实现网络服务。这里长连接和短连接的区别仅仅是服务完成后是否直接关闭这个TCP连接。
2.美团(域名合并方案)
随着开发规模逐渐扩大,各业务团队出于独立性和稳定性的考虑,纷纷申请了自己的三级域名。App中的API域名越来越多。如下所示:
search.api.dianping.com
ad.api.dianping.com
tuangou.api.dianping.com
waimai.api.dianping.com
movie.api.dianping.com
…
App中域名多了之后,将面临下面几个问题:
- HTTP请求需要跟不同服务器建立连接。增加了网络的并发连接数量。
- 每条域名都需要经过DNS服务来解析服务器IP。
如果想将所有的三级域名都合并为一个域名,又会面临巨大的项目推进难题。因为不同业务团队当初正是出于独立性和稳定性的考虑才把域名进行拆分,现在再想把域名合并起来,势必会遭遇巨大的阻力。
所以我们面临的是:既要将域名合并,提升网络连接效率,又不能改造后端业务服务器。经过讨论,我们想到了一个折中的方案。
该方案的核心思想在于:保持客户端业务层代码编写的网络请求与后端业务服务器收到的请求保持一致,请求发出前,在客户端网络层对域名收编,请求送入后端,在SLB(Server Load Balancing)中对域名进行还原。
网络请求发出前,在客户端的网络底层将URL中的域名做简单的替换,我们称之为“域名收编”。
例如:URL " http://ad.api.dianping.com/command?param1=123" 在网络底层被修改为 " http://api.dianping.com/ad/command?param1=123" 。
这里,将域名"ad.api.dianping.com"替换成了"api.dianping.com",而紧跟在域名后的其后的"ad"表示了这是一条与广告业务有关的域名请求。
依此类推,所有URL的域名都被合并为"api.dianping.com"。子级域名信息被隐藏在了域名后的path中。
被改造的请求被送到网络后端,在SLB中,拥有与客户端网络层相反的一套域名反收编逻辑,称为“域名还原”。
例如:" http://api.dianping.com/ad/command?param1=123" 在SLB中被还原为 " http://ad.api.dianping.com/command?param1=123" 。SLB的作用是将请求分发到不同的业务服务器,在经过域名还原之后,请求已经与客户端业务代码中原始请求一致了。
该方案具有如下优势:
- 域名得到了收编,减少了DNS调用次数,降低了DNS劫持风险。
- 针对同一域名,可以利用Keep-Alive来复用Http的连接。
- 客户端业务层不需要修改代码,后端业务服务也不需要进行任何修改。
网络抖动
携程:
携程App引入了网络质量参数,通过网络类型和端到端Ping值进行计算,根据不同的网络质量改变网络服务策略:
- 调整长连接池个数:例如在2G/2.5G Egde网络下,会减少长连接池个数为1(运营商会限制单个目标IP的TCP连接个数);WIFI网络下可以增加长连接池个数等机制。
- 动态调整TCP connection、write、read的超时时间。
- 网络类型切换时,例如WIFI和移动网络、4G/3G切换至2G时,客户端IP地址会发生变化,已经连接上的TCP Socket注定已经失效(每个Socket对应一个四元组:源IP、源Port、目标IP、目标Port),此时会自动关闭所有空闲长连接,现有网络服务也会根据状态自动重试。
其他优化途径
数据格式优化,减少数据传输量和序列化时间
传输数据量越小,在相同TCP连接上的传输时间越短。携程App曾经使用自行设计的一套数据格式,后来和Google ProtocolBuffer对比后发现,特定数据类型下数据包大小会降低20-30%,序列化和反序列化时间可以降低10-20%,因此目前核心服务都在逐步迁移到到ProtocolBuffer格式。另外Facebook曾分享过他们使用FlatBuffer数据格式提高性能的实践,我们分析后不太适合携程的业务场景因而没有使用。