
本文由携程技术团队Aaron分享,,下文进行了排版和内容优化。
网络优化一直是移动互联网时代的热议话题,弱网识别作为移动端弱网优化的第一步,受到的关注和讨论也是最多的。本文从方案设计、代码开发到技术落地,详尽的分享了携程在移动端弱网识别方面的实践经验,如果你也有类似需求,这篇文章会是一个不错的实操指南。
Aaron:携程移动开发专家,关注网络优化、移动端性能优化。
自从2010年携程推出”无线战略“,并发布移动端APP以来,无线研发团队对于客户端网络性能的优化就一直没有停止过。经过这十几年持续不断的优化,目前携程的端到端网络性能已经处于一个相当不错的水平,大盘数据趋于稳定,优化也随之进入 ”深水区“,提升难度巨大。
结合线上的一系列客诉反馈,我们发现即使大盘的数据再优秀,用户网络表现不佳的个例case仍然层出不穷,排查后大部分被我们归因到"弱网”。这部分“弱网”长尾数据相比大盘均值仍有巨大的提升空间,如果可以针对性优化的话,对于提升整体用户体验和减少客诉都有非常明确的价值。
既然要优化“弱网”,那第一步一定是建立相应的“弱网识别模型”,准确识别出弱网场景,本文即探讨携程在弱网识别方面的技术探索,包含技术选型细节和关键的路径思考,欢迎沟通交流。

如上图所示,携程弱网识别模型的整个工作流程由数据采集、数据处理、结果输出三部分组成,接下来我们顺着流程来逐个剖析相关细节。
说到可以客观反映网络质量的指标,业内定义清晰且获得公认的有如下这些:
对于网络质量识别,业内做的比较早的是Google的NQE(Network Quality Estimator),国内大多数网络质量识别方案也都参考了Google NQE,NQE中识别模型的输入主要是HttpRTT、TransportRTT、DownstreamThroughput这三个指标。
对于HttpRTT、TransportRTT,在应用层和传输层都有很多方式可以采集到,且口径清晰,所以这两个指标被我们纳入采集范围。
对于DownstreamThroughput,我们实践过程中发现,该指标受到用户行为的影响很大,当用户集中操作大量发送网络请求的时候,该指标就偏高,当用户停止操作阅读数据时,该指标就会偏低甚至长时间得不到更新,考虑到指标的波动性,我们不将此指标纳入采集范围。
既然DownstreamThroughput被排除在外,那由他参与计算的BandwidthDelayProduct也被我们排除。
SignalStrength信号强度由于iOS无法准确获取,考虑到多端一致性,也被我们Pass。
NetworkSuccessRate网络成功率这个指标,可能很少被其他方案提及到,我们提出这个指标并将他纳入采集范围的主要原因是,基于RTT的网络识别模型,在遇到网络波动导致的用户大面积请求失败时,无法获取到有效的RTT值,导致识别的准确性和实时性都收到影响,引入网络成功率可以很好的弥补这个缺陷,最终线上生产环境验证也证明了该指标的必要性。
最终:携程的网络质量识别模型采集HttpRTT、TransportRTT、NetworkSuccessRate作为输入指标。
携程的网络请求,主要有Tcp代理通道、Quic代理通道、Http通道三种网络通道。
对于上述提到了三个输入指标,我们从如下网络行为中进行数据采集:
对于自定义的Tcp、Quic代理通道,按照上述口径在网络通道相关状态回调内统计数据即可,自定义实现参考价值不大,这里就不过多赘述。
对于标准的Http请求,我们可以通过获取系统网络框架返回的Metric信息或者监听请求的状态流转来获取网络指标。
1)对于iOS端:
iOS 10之后NSURLSession支持通过NSURLSessionTaskDelegate的协议方法URLSession:task:didFinishCollectingMetrics:获取到请求的Metric信息,详细信息见附录。
单次请求的Metric定义如下图:

解释一下:
2)对于Android端:
系统网络框架OkHttp支持添加EventListener来获取Http请求的状态流转信息,可以在各状态回调内记录时间戳来计算RTT,详细信息见附录。
单次请求的Events定义如下图:

解释一下:
依照上述方法收集到网络数据后,我们把数据封装成对应的结构体,注入识别模型,携程对于网络数据结构体的定义如下,方便大家参考。
typedef enum : int64_t { NQEMetricsSourceTypeInvalid = 0, // 0 NQEMetricsSourceTypeTcpConnect = 1 << 0, // 1 NQEMetricsSourceTypeQuicConnect = 1 << 1, // 2 NQEMetricsSourceTypeHttpRequest = 1 << 2, // 4 NQEMetricsSourceTypeQuicRequest = 1 << 3, // 8 NQEMetricsSourceTypeHeartBeat = 1 << 4, // 16 ...... } NQEMetricsSourceType; struct NQEMetrics { // 本次采集到的数据来源,可以是多个枚举值的或值 // 例如一次没有连接复用http请求,source = TcpConnect|HttpRequest,同时存在transportRTT和httpRTT NQEMetricsSourceType source; // 本次数据的成功状态,用作成功率计算 bool isSuccessed; // httpRTT,可为空 double httpRTTInSec; // transportRTT,可为空 double transportRTTInSec; // 数据采集时间 double occurrenceTimeInSec; };
7、数据处理实现
7.1数据过滤和滑动窗口
网络数据采集后,注入到识别模型内,需要一个数据结构来承载,我们采用的是队列。
进入队列前,我们需要先进行数据过滤,筛选掉一些无效的数据。
目前采用的筛选策略有如下这些:
数据过滤后加入队列,为了实时性和结果准确性,我们处理数据时,会根据两个限制逻辑来确定一个具体的滑动窗口,只让窗口内的数据参与计算。
具体窗口限制逻辑如下:
每次计算网络质量时,可以根据这两个限制来确定计算窗口,窗口外的数据可以实时清理出队列,减少内存占用。
上文提到的各种阈值设置,均可通过配置系统更新。
7.2 动态权重计算
弱网识别模型的原理简单来说就是将窗口内的一组数据经过一系列处理后,得出一个最终值,再用这个最终值与对应的弱网阈值比较来得出是否是弱网。
出于实时性的考虑,我们希望距离当前时间越近的数据权重越高,所以要用到动态权重的算法,这里我们比较推荐的是”半衰期动态权重“和”反正切动态权重“两种算法。
1)半衰期动态权重:
半衰期顾名思义,即每经过一个固定的时间,权重降低为之前的一半。
这里衰减幅度和周期都是可以自定义的,计算公式如下:
每秒衰减因子 = pow(衰减幅度, 1.0 / 衰减周期);衰减幅度为浮点型,取值范围 0~1,衰减周期为整形,单位为秒 动态权重 = pow(每秒衰减因子, abs(now - 数据采集时间))
以衰减幅度为0.5,衰减周期为60秒为例,对应的函数曲线如下:

横坐标为数据采集时间距今的时间差,纵坐标为权重,从图上可以清晰看到,随着时间差增大,权重无限趋近于0。
半衰期动态权重也是Google NQE采用的权重计算方案,Google采用的周期是每60秒降低50%,相关代码详见附录。
部分核心代码如下:
double GetWeightMultiplierPerSecond( const std::map<std::string, std::string>& params) { // Default value of the half life (in seconds) for computing time weighted // percentiles. Every half life, the weight of all observations reduces by // half. Lowering the half life would reduce the weight of older values // faster. int half_life_seconds = 60; int32_t variations_value = 0; auto it = params.find("HalfLifeSeconds"); if (it != params.end() && base::StringToInt(it->second, &variations_value) && variations_value >= 1) { half_life_seconds = variations_value; } DCHECK_GT(half_life_seconds, 0); return pow(0.5, 1.0 / half_life_seconds); } void ObservationBuffer::ComputeWeightedObservations( const base::TimeTicks& begin_timestamp, int32_t current_signal_strength, std::vector<WeightedObservation>* weighted_observations, double* total_weight) const { base::TimeDelta time_since_sample_taken = now - observation.timestamp(); double time_weight = pow(weight_multiplier_per_second_, time_since_sample_taken.InSeconds()); … }
2)反正切动态权重:
y=arctan(x)反正切函数在第一象限的取值范围为0~Pi/2,我们将arctan(x)取反,向上平移Pi/2,然后除以Pi/2,函数曲线即可在第一象限随着x增大y的取值从1趋近于0。
我们还可以使用一个斜率系数来控制权重降低的趋势快慢,公式推导过程如下:
动态权重 = (Pi / 2 - arctan(abs(now - 数据采集时间) * 斜率系数)) / (Pi / 2) = 1 - arctan(abs(now - 数据采集时间) * 斜率系数) / Pi * 2;斜率系数为浮点型,取值范围为0~1,系数越小,权重降低的越缓慢。
以斜率系数为1/20为例,对应的函数曲线如下:

和前文的半衰期动态权重相同,横坐标为数据采集时间距今的时间差,纵坐标为权重,随着时间差增大,权重趋近于0,两种动态权重算法效果类似。
反正切动态权重的实现代码如下:
static double _nqe_getWeight(double targetTime) { …… double interval = now - targetTime; /// 曲率系数,数值越小权重降低的越缓慢 double rate = 20.0 / 1; return 1.0 - atan(interval * rate) / M_PI_2; }
从上图的代码实现可以看出,反正切相关的代码实现要简单很多,但是由于存在推导过程,所以理解起来比较困难,代码维护成本较高(数学功底对于程序员来说也是非常重要的),大家可以酌情自行选择。
携程最终采用的也是半衰期动态权重的方案,出于实时性考虑,最终线上验证后采用的衰减幅度为0.3,衰减幅度为60秒,供参考。
在确定了单条数据的权重之后,对于RTT的数值计算,我们第一个想到的是加权平均,但是加权平均很容易收到高权重脏数据的影响,准确性堪忧,所以我们改用了“加权中值”。
加权中值的计算方式是,将窗口内的数据按照数值大小升序排列,然后从头遍历数据,累加权重大于等于总权重的一半时,停止遍历,当前遍历到的数值即为最终的加权中值。
NQE对于TransportRTT和HttpRTT处理,也是使用的这种方式,相关代码详见附录。
部分核心代码如下:
std::optional<int32_t> ObservationBuffer::GetPercentile( base::TimeTicks begin_timestamp, int32_t current_signal_strength, int percentile, size_t* observations_count) const { …… // 此处的percentile值为50,即取中值 double desired_weight = percentile / 100.0 * total_weight; double cumulative_weight_seen_so_far = 0.0; for (const auto& weighted_observation : weighted_observations) { cumulative_weight_seen_so_far += weighted_observation.weight; if (cumulative_weight_seen_so_far >= desired_weight) return weighted_observation.value; } // Computation may reach here due to floating point errors. This may happen // if |percentile| was 100 (or close to 100), and |desired_weight| was // slightly larger than |total_weight| (due to floating point errors). // In this case, we return the highest |value| among all observations. // This is same as value of the last observation in the sorted vector. return weighted_observations.at(weighted_observations.size() - 1).value; }
对于成功率,我们的NQEMetrics结构体内定义了单次成功状态isSuccessed,单条数据的加权成功率为 (NQEMetrics.isSuccessed ? 1 : 0) * weight,整体的加权成功率为加权成功率总和除以总权重。
相关代码实现如下:
extern double _calculateSuccessRateByWeight(const vector &metrics, uint64_t types, const shared_ptr<NQEConfig> config) { …… uint64_t totalValidCount = 0; double totalWeights = 0.0; double totalSuccessRate = 0.0; for (const auto& m : metrics) { /// 过滤需要的数据 if ((m.source & types) == 0) { continue; } /// 累计总权重和总成功率 totalValidCount++; totalWeights += m.weight; totalSuccessRate += (m.isSuccessed ? 1 : 0) * m.weight; } /// 数据不足 if (totalValidCount < config->minValidWindowSize) { return NQE_INVALID_RATE_VALUE; } if (totalWeights <= 0.0) { return NQE_INVALID_RATE_VALUE; } return totalSuccessRate / totalWeights; }
网络质量识别不仅需要准确,实时性也非常重要,在网络质量切换时模型识别的时间越短越好。前文已经提到了TransportRTT、HttpRTT、NetworkSuccessRate三个核心指标的计算,但是在线上实际验证的过程中,我们发现在网络完全不可用成功率跌0后,识别模型对于网络状态的恢复感知很慢,原因是成功率的攀升需要较长的时间。
针对这个极端的case,我们引入了一个“成功率趋势”的新指标,来优化模型的实时性,在成功率未达阈值当时有明显趋势时,提前切换网络质量状态。成功率趋势是指一段时间内成功率连续上升或者下降的幅度,浮点类型,取值范围-1 ~ +1。
成功率趋势初始值为0,计算方式如下:
1)在每次更新成功率时,计算更新前后成功率的差值:
如果差值为正,则成功率向好:
如果差值为负,则成功率向坏:
2)当然还需要过滤一些毛刺数据,避免趋势变化过频:
具体代码实现如下:
void NQE::_updateSuccessRateTrend() { auto oldRate; auto newRate; if (oldRate < 0 || newRate < 0) { _successRateContinuousDiff = 0; return; } auto diff = newRate - oldRate; /// 数据错误,不做处理 if (abs(diff) > 1) { _successRateContinuousDiff = 0; return; } /// diff小于0.01,作为毛刺处理,不影响趋势变化 if (abs(diff) < 0.01) { _successRateContinuousDiff += diff; return; } /// 计算连续diff if (diff > 0 && _successRateContinuousDiff > 0) { _successRateContinuousDiff += diff; } else if (diff < 0 && _successRateContinuousDiff < 0) { _successRateContinuousDiff += diff; } else { _successRateContinuousDiff = diff; } }
在网络成功率和成功率趋势的加持下,我们的识别模型实时性大幅度提升。我们控制相同请求频率和请求数据量,线下模拟弱网切换进行测试。
测试结果如下:
所以,最终携程弱网识别模型计算的指标有TransportRTT、HttpRTT、NetworkSuccessRate、SuccessRateTrend(成功率趋势)四个。
识别模型对外输出的是一个网络质量的枚举值,Google NQE对于网络质量的定义如下,源码详见附录。
enum EffectiveConnectionType { // Effective connection type reported when the network quality is unknown. EFFECTIVE_CONNECTION_TYPE_UNKNOWN = 0, // Effective connection type reported when the Internet is unreachable // because the device does not have a connection (as reported by underlying // platform APIs). Note that due to rare but potential bugs in the platform // APIs, it is possible that effective connection type is reported as // EFFECTIVE_CONNECTION_TYPE_OFFLINE. Callers must use caution when using // acting on this. EFFECTIVE_CONNECTION_TYPE_OFFLINE, // Effective connection type reported when the network has the quality of a // poor 2G connection. EFFECTIVE_CONNECTION_TYPE_SLOW_2G, // Effective connection type reported when the network has the quality of a // faster 2G connection. EFFECTIVE_CONNECTION_TYPE_2G, // Effective connection type reported when the network has the quality of a 3G // connection. EFFECTIVE_CONNECTION_TYPE_3G, // Effective connection type reported when the network has the quality of a 4G // connection. EFFECTIVE_CONNECTION_TYPE_4G, // Last value of the effective connection type. This value is unused. EFFECTIVE_CONNECTION_TYPE_LAST, };
Google枚举定义的最大问题是理解成本比较高,其他开发同学看到这个所谓的“3G”、“4G”,他依然不知道网络是好是坏,是不是他认为的“弱网”。
所以我们在定义接口的时候,对于枚举的设计考虑最多的就是理解成本,结合开发同学最想知道的“是不是弱网”。
我们的接口定义如下:
typedef enum : int64_t { /// 未知状态,初始状态或者无有效计算窗口时会进入此状态 NetworkQualityTypeUnknown = 0, /// 离线状态,网络不可用 NetworkQualityTypeOffline = 1, /// 弱网状态 NetworkQualityTypeBad = 2, /// 正常网络状态 NetworkQualityTypeGood = 3 } NetworkQualityType;
这样是不是看起来简单明了多了,我们接下来就讲讲怎么计算得出这几个枚举的结果。
NetworkQualityTypeUnknown 是在初始化或者网络切换后的一段时间内,数据不足无法得出网络质量,会进入此状态。
NetworkQualityTypeOffline 的触发条件很单一,就是操作系统识别到无网络连接,具体的获取方式由各平台自行实现,例如iOS可以通过Reachability获取,官方Demo详见附录6。
NetworkQualityTypeBad 也就是我们最核心的“弱网”状态,计算方式是上文提到的TransportRTT、HttpRTT两个指标任一指标触发弱网阈值,或者NetworkSuccessRate和SuccessRateTrend同时满足弱网阈值。
NetworkQualityTypeGood 是指正常的网络质量状态,上述三种网络质量类型讲完后,这个类型就简单了,即非上述三种情况的场景,归类到Good,这也是设计上占比最高的网络质量类型。
识别模型的运转流程如下:

解释一下:
模型核心的计算逻辑,就是将加工后得到的各网络指标与对应的弱网阈值进行对比,从而获得是否进入弱网的结果。
关于弱网阈值的制定上,我们经历了如下两个阶段:
1)第一阶段:
主要参考NQE EFFECTIVE_CONNECTION_TYPE_2G 的阈值定义:
2)第二阶段:
我们通过线上的网络质量分布监控,和一些具体case的分析,不断迭代我们的阈值,我们需要制定一个识别准确率的指标来指引阈值的调整工作,达到逻辑准确与自洽。
理论上:我们希望识别模型的入参与当下计算出的网络质量类型所匹配,例如当前注入NQEMetrics数据的HttpRTT <= 1726ms ,那我们预期当前计算出的网络质量类型就是Good。但是弱网的决策逻辑是相对复杂的,需要考虑到各种因素,以
下两点会造成弱网状态下的入参数据不一定符合弱网阈值定义:
终上两点原因,弱网分类下必然有一定的非弱网数据,这里的误差数据占比与识别准确率负相关,误差数据占比越低,识别准确率越高。所以想到这里,我们的模型识别准确率的指标计算口径就有了:
模型弱网识别准确性 = 100% - 弱网状态下不符合弱网阈值定义原数据占比
有了这个指标指引,我们在模型上线后进行了数个版本的数据统计,通过各指标阈值的微调和case by case解决异常场景,误差数据从刚上线的15%+降低到10%以下,即模型识别准确率优化至90%以上。
最终携程90%准确率的模型对应的弱网阈值如下(不同业务场景的网络请求差别较大,仅供大家参考):
Tips:对于类似携程这种自定义的弱网识别模型,弱网标准也是考虑业务现状的定制标准,所以不需要太多和外部的弱网标准对齐,重点是自洽和符合业务预期。
考虑到识别模型要支持多平台(iOS、Android、Harmony等),所以我们在一开始实现方案时就采用了C++作为开发语言,天然支持了多平台,各平台只需要实现上层数据采集和注入模型的少量逻辑即可完成模型的接入。相同的代码实现和弱网标准,也方便我们在不同的平台间直接对标数据,发现各平台的问题针对性优化。
目前携程的网络质量识别模型,已经在iOS、Android平台完成接入并大面积投产,网络质量数据与集团的APM监控平台打通,形成了携程官方统一的网络质量标准,在网络排障、框架网络优化、业务网络优化等多种场景下扮演重要角色,弱网优化相关的内容我们会在后面相关的专题内继续分享,此处不再赘述。
最终网络质量相关的分布数据如下(数据为实验采集,不代表携程真实业务情况,仅参考)。
网络质量分布:

各网络质量下对应的请求性能数据:

网络质量识别模型的完成只是我们网络优化的开始,后续还有很多的工作需要我们继续努力。
未来一段时间我们会从以下几个方面继续推进:
1)持续推进各平台、各独立APP的网络质量识别模型接入,完成携程终端全平台的网络质量模型覆盖。
2)做好识别模型的防劣化工作,解决各业务场景的bad case,坚守现阶段识别准确率和实时性的标准水位。
3)推出携程内部的“网络性能白皮书”,从APP、系统平台、网络质量、成功率、全链路耗时等各维度解析公司内部各业务线的网络表现,形成内部的网络性能数据基线,为业务优化提供参考。
4)借助现有的弱网标准和识别能力,从网络框架侧和业务侧两个不同的角度进行弱网优化,提高整体网络表现;当下海外市场是业务发力的重点,海外场景的网络表现也明显弱于国内,我们会针对海外场景从弱网的角度进行重点优化。
携程目前已经针对弱网场景推出了一系列优化策略,部分策略已经取得非常不错的收益,后续我们会继续推进,也会持续分享输出。
[1] TCP/IP详解 - 第17章·TCP:传输控制协议
[2] 快速理解TCP协议一篇就够
[3] 新手入门一篇就够:从零开发移动端IM
[3] 腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)
[4] 深入浅出,全面理解HTTP协议
[5] 快速读懂Http/3协议,一篇就够!
[6] 技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解
[7] 冰山之下,一次网络请求背后的技术秘密
[8] 通俗易懂,理解移动网络的“弱”和“慢”
[9] 史上最全移动弱网络优化方法总结
[10] 爱奇艺APP网络优化实践(网络请求成功率优化篇)
[11] 美团点评的网络优化实践(大幅提升连接成功率、速度等)
[12] 淘宝移动端统一网络库的架构演进和弱网优化实践
[13] 爱奇艺APP跨国弱网通信的优化实践
[14] 得物自研移动端弱网诊断工具的技术实践
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。