微信Mars
原文出处:微信Mars:客户端跨平台组件的开发经验
4G,甚至是未来的5G,移动网络正在快速发展,但这并不意味着网络库不再重要。Mars源于微信的业务需要,结合移动互联网的不稳定特性,做了大量的优化工作, 并且这些优化都是经过了微信长时间的验证和使用的。
12月9日,在中国技术开放日广州站,我们将为大家带来Mars的详细介绍。本次大会完全免费,欢迎有兴趣的小伙伴们报名。
12月9日,由InfoQ主办的中国技术开放日广州站将在广州四季酒店举行(欢迎广州的同学免费报名参加,报名地址见文末阅读原文)。我们邀请了来自腾讯、阿里、百度、唯品会的技术专家前来分享一线移动开发实践。本次开放日上,来自微信的高级工程师周志杰将分享《微信Mars:移动互联网下的高质量网络连接探索》,对于这个话题,我采访了周志杰,了解微信Mars是什么,以及微信在跨平台组件开发上的一些经验总结。
Mars 是微信官方的终端基础组件,是一个使用 C++ 编写的业务性无关,平台性无关的基础组件。目前已接入微信Android、iOS、Mac、Windows、WP 等客户端。现正在筹备开源中,它主要包括以下几个部分:
COMM:基础库,包括socket、线程、消息队列、协程等基础工具;
XLOG:通用日志模块,充分考虑移动终端的特点,提供高性能、高可用、安全性、容错性的日志功能;
SDT:网络诊断模块;
STN:信令传输网络模块,负责终端与服务器的小数据信令通道。包含了微信终端在移动网络上的大量优化经验与成果,经历了微信海量用户的考验。
其中STN(信令传输网络)为核心模块。
Mars最早起源于微信的实际发展需要。由于微信多平台、亿级用户,微信基础功能的修改影响重大。但不同平台不同的实现导致众多平台上很难统一,一方面不利于质量把关,可能存在重大隐患,一方面也不利于微信对基础研究的深入。因此,微信开始了跨平台基础组件的开发,并且在微信中沉淀多年。随着基础组件的发展与稳定,他们希望通过开源的力量进一步的提升微信的基础,并且也期待行业的互相交流与促进,因此启动了Mars项目。
微信的网络层设计到很多不同的业务,在微信中使用到的绝大部分功能,都是基于Mars提供的网络组件,包括文本消息、语音消息、朋友圈等核心功能。只有少部分业务不在范围内,例如VOIP、Webview等。微信中使用到的网络服务,大体分为两种类型:信令网络、数据网络。其中STN负责信令网络,CDN组件则负责数据网络。但由于CDN涉及到腾讯内部的CDN服务,因此在Mars的开源列表中并没有包含CDN部分。
SDT网络诊断模块提供了最基础的检测能力,需要由使用者自己组合使用。各个平台的Reachability API能反映的情况有局限,无法应对复杂的网络诊断,事实上有一些开源项目就是为了替换系统API。微信由于自身特点对网络诊断要求很高,因此将它作为基础的跨平台组件进行开发维护。
STN是微信的信令传输网络,也可以认为是小数据传输网络,是微信日常中使用最频繁的网络通道。在开源设计上,STN抽象了微信的使用模型,使得STN成为业务无关的网络通道,并且应用到了包括微信在内的多个腾讯内部应用中。与AFNetworking、Retrofit、Okhttp等框架不同,STN不仅是跨平台的网络解决框架,更是着重了“移动互联网“这一特点,结合移动互联网的不稳定特性,做了大量的优化工作,并且这些优化都是经过了微信长时间的验证和使用的。除此之外,STN中还包含了很多其他方面的实用设计,包括自定义DNS、容灾设计、负载考量、APP的前后台考量、休眠机制考量、省电机制等等。
在实际的使用上,STN也提供了比其它网络框架更多的空间:
数据监控:STN提供了许多数据回调接口,使得应用可以基于这些网络数据,建立统计分析、监控等工具来管理应用的实际网络情况;
问题定位:STN中含有很多关键日志,并且日志的设计已经帮助微信定位了很多的网络层问题。结合Mars提供的XLOG日志机制,应用可以运行时也打开日志,非常方便定位;
参数配置:STN中将很多参数抽离为配置,使得应用可以根据自己的情况进行修改。
网络通道上,目前STN提供了长连、短连两种类型的通道,用于满足不同的需求。使用STN后,应用开发者只需关注业务开发。移动互联网的各种“坑”则可以交由STN帮 你处理。
微信Mars跨平台组件开发经验
客户端的跨平台组件一般是指由C++等编写的代码,编译为各个平台的二进制链接库,然后通过平台提供的SDK调用和打包进App内。跨平台组件一般都是底层功能,特点是与平台无关、与UI界面无关,像第三方的游戏引擎、数据库等一般都以跨平台形式出现。
跨平台组件可以实现一份代码到处运行,减少开发和维护的精力,因此,当移动开发团队扩大、业务规模膨胀的时候,将一些通用的、易出问题的底层功能提取出来做成跨平台组件很有必要。
网络层其实和系统以及UI界面的耦合很小,除了硬件设备相关、WebView等需要依赖系统的部分外,网络层几乎都可以实现跨平台,一份代码。但微信会将能抽象成通用模型、通用能力的部分考虑以组件的形式跨平台。
跨平台组件在开发过程中需要注意的地方,也和一般的移动开发有所不同。微信在Mars的开发中,会以高可用、高性能、低负载、容灾性等目标来要求。
跨平台组件开发的难点在于,跨平台方案需要考虑各种平台上的API兼容性问题,在技术选型上需要付出更多的努力;需要考虑不同平台的特性,例如休眠机制等,根据不同的特性进行设计;跨平台的基础组件需要将问题进行更加本质的思考,避免复杂方案,避免方案的频繁变更等。
另外,跨平台组件由于对应用的影响很大,对它的稳定性和测试都要求更高。不过,只要设计良好,跨平台组件并不会对应用的稳定性造成威胁。从目前微信的使用情况来看,跨平台组件带来的Crash在总体中占比较小。测试方面,微信在各平台上有相应的测试工具,同时也有较多的自动化测试,能保障组件的稳健性。同时针对组件的某些特性,他们也有设计专门的测试场景。
Mars将在不久的将来进行开源,并且微信会持续的维护Mars开源项目。
原文出处:微信终端跨平台组件 mars 系列(一) - 高性能日志模块xlog
前言
mars 是微信官方的终端基础组件,是一个使用 C++ 编写的业务性无关,平台性无关的基础组件。目前已接入微信Android、iOS、Mac、Windows、WP 等客户端。现正在筹备开源中,它主要包括以下几个部分:
comm:可以独立使用的公共库,包括 socket、线程、消息队列等
xlog:可以独立使用的日志模块
sdt:可以独立使用的网络诊断模块
stn:可以独立使用的信令分发网路模块
本文章是 mars 系列的第一篇:高性能跨平台日志模块。
正文
对于移动开发者来说,最大的尴尬莫过于用户反馈程序出现问题,但因为不能重现且没有日志无法定位具体原因。这样看来客户端日志颇有点“养兵千日,用兵一时”的感觉,只有当出现问题且不容易重现时才能体现它的重要作用。为了保证关键时刻有日志可用,就需要保证程序整个生命周期内都要打日志,所以日志方案的选择至关重要。
常规方案
方案描述: 对每一行日志加密写文件
例如 Android 平台使用 java 实现日志模块,每有一句日志就加密写进文件。这样在使用过程中不仅存在大量的 GC,更致命的是因为有大量的 IO 需要写入,影响程序性能很容易导致程序卡顿。选择这种方案,在 release 版本只能选择把日志关掉。当有用户反馈时,就需要给用户重新编一个打开日志的安装包,用户重新安装重现后再通过日志来定位问题。不仅定位问题的效率低下,而且并不能保证每个需要定位的问题都能重现。这个方案可以说主要是为程序发布前服务的。
来看一下直接写文件为什么会导致程序卡顿

当写文件的时候,并不是把数据直接写入了磁盘,而是先把数据写入到系统的缓存(dirty page)中,系统一般会在下面几种情况把 dirty page 写入到磁盘:
定时回写,相关变量在/proc/sys/vm/dirty_writeback_centisecs和/proc/sys/vm/dirty_expire_centisecs中定义。
调用 write 的时候,发现 dirty page 占用内存超过系统内存一定比例,相关变量在/proc/sys/vm/dirty_background_ratio( 后台运行不阻塞 write)和/proc/sys/vm/dirty_ratio(阻塞 write)中定义。
内存不足。
数据从程序写入到磁盘的过程中,其实牵涉到两次数据拷贝:一次是用户空间内存拷贝到内核空间的缓存,一次是回写时内核空间的缓存到硬盘的拷贝。当发生回写时也涉及到了内核空间和用户空间频繁切换。
dirty page 回写的时机对应用层来说又是不可控的,所以性能瓶颈就出现了。
这个方案存在的最主要的问题:因为性能影响了程序的流畅性。对于一个 App 来说,流畅性尤为重要,因为流畅性直接影响用户体验,最基本的流畅性的保证是使用了日志不会导致卡顿,但是流畅性不仅包括了系统没有卡顿,还要尽量保证没有 CPU 峰值。所以一个优秀的日志模块必须保证流畅性:
- 不能影响程序的性能。最基本的保证是使用了日志不会导致程序卡顿
我觉得绝大部分人不会选择这一个方案。
进一步思考
在上个方案中,因为要写入大量的 IO 导致程序卡顿,那是否可以先把日志缓存到内存中,当到一定大小时再加密写进文件,为了进一步减少需要加密和写入的数据,在加密之前可以先进行压缩。至于 Android 下存在频繁 GC 的问题,可以使用 C++ 来实现进行避免,而且通过 C++ 可以实现一个平台性无关的日志模块。
方案描述:把日志写入到作为 log 中间 buffer 的内存中,达到一定条件后压缩加密写进文件。
这个方案的整体的流程图:

这个方案基本可以解决 release 版本因为流畅性不敢打日志的问题,并且对于流畅性解决了最主要的部分:由于写日志导致的程序卡顿的问题。但是因为压缩不是 realtime compress,所以仍然存在 CPU 峰值。但这个方案却存在一个致命的问题:丢日志。
理想中的情况:当程序 crash 时, crash 捕捉模块捕捉到 crash,然后调用日志接口把内存中的日志刷到文件中。但是实际使用中会发现程序被系统杀死不会有事件通知,而且很多异常退出,crash 捕捉模块并不一定能捕捉到。而这两种情况恰恰是平时跟进的重点,因为没有 crash 堆栈辅助定位问题,所以丢日志的问题这个时候显得尤为凸显。
在实际实践中,Android 可以使用共享内存做中间 buffer 防止丢日志,但其他平台并没有太好的办法,而且 Android 4.0 以后,大部分手机不再有权限使用共享内存,即使在 Android 4.0 之前,共享内存也不是一个公有接口,使用时只能通过系统调用的方式来使用。所以这个方案仍然存在不足:
如果损坏一部分数据虽然不会累及整个日志文件但会影响整个压缩块
个别情况下仍然会丢日志,而且集中压缩会导致 CPU 短时间飙高
通过这个方案,可以看出日志不仅要保证程序的流畅性,还要保证日志内容的完整性和容错性:
不能因为程序被系统杀掉,或者发生了 crash, crash 捕捉模块没有捕捉到导致部分时间点没有日志, 要保证程序整个生命周期内都有日志。
不能因为部分数据损坏就影响了整个日志文件,应该最小化数据损坏对日志文件的影响。
mars 的日志模块xlog
前面提到了使用内存做中间 buffer 做日志可能会丢日志,直接写文件虽然不会丢日志但又会影响性能。所以亟需一个既有直接写内存的性能,又有直接写文件的可靠性的方案,也就是 mars 在用的方案。
mmap
mmap是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。

为了验证 mmap 是否真的有直接写内存的效率,我们写了一个简单的测试用例:把512 Byte的数据分别写入150 kb大小的内存和 mmap,以及磁盘文件100w次并统计耗时

从上图看出mmap几乎和直接写内存一样的性能,而且 mmap 既不会丢日志,回写时机对我们来说又基本可控。 mmap 的回写时机:
内存不足
进程 crash
调用 msync 或者 munmap
不设置 MAP_NOSYNC 情况下 30s-60s(仅限FreeBSD)
如果可以通过引入 mmap 既能保证高性能又能保证高可靠性,那么还存在的其他问题呢?比如集中压缩导致 CPU 短时间飙高,这个问题从上个方案就一直存在。而且使用 mmap 后又引入了新的问题,可以看一下使用 mmap 之后的流程:

前面已经介绍了,当程序被系统杀掉会把逻辑内存中的数据写入到 mmap 文件中,这时候数据是明文的,很容易被窥探,可能会有人觉得那在写进 mmap 之前先加密不就行了,但是这里又需要考虑,是压缩后再加密还是加密后再压缩的问题,很明显先压缩再加密效率比较高,这个顺序不能改变。而且在写入 mmap 之前先进行压缩,也会减少所占用的 mmap 的大小,进而减少 mmap 所占用内存的大小。所以最终只能考虑:是否能在写进逻辑内存之前就把日志先进行压缩,再进 行加密,最后再写入到逻辑内存中。问题明确了:就是怎么对单行日志进行压缩,也就是其他模块每写一行日志日志模块就必须进行压缩。
压缩
比较通用的压缩方案是先进行短语式压缩,短语式压缩过程中有两个滑动窗口,历史滑动窗口和前向缓存窗口,在前向缓存窗口中通过和历史滑动窗口中的内容进行匹配从而进行编码。

比如这句绕口令:吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。中间是有两块重复的内容“吃葡萄”和“吐葡萄皮”这两块。第二个“吃葡萄”的长度是 3 和上个“吃葡萄”的距离是 10 ,所以可以用 (10,3) 的值对来表示,同样的道理“吐葡萄皮”可以替换为 (10,4 )

这些没压缩的字符通过 ascci 编码其实也是 0-255 的整数,所以通过短语式压缩得到的结果实质上是一堆整数。对整数的压缩最常见的就是 huffman 编码。通用的压缩方案也是这么做的,当然中间还掺杂了游程编码,code length 的转换。但其实这个不是关注的重点。我们只需要明白整个压缩过程中,短语式压缩也就是 LZ77 编码完成最大的压缩部分也是最重要的部分就行了,其他模块的压缩其实是对这个压缩结果的进一步压缩,进一步压缩的方式主要使用 huffman 压缩,所以这里就需要基于数字出现的频率进行统计编码,也就是说如果滑动窗口大小没上限的前提下,越多的数据集中压缩,压缩的效果就越好。日志模块使用这个方案时压缩效果可以达到86.3%。
既然 LZ77 编码已经完成了大部分压缩,那么是否可以弱化 huffman 压缩部分,比如使用静态 huffman 表,自定义字典等。于是我们测试了四种方案:

这里可以看出来后两种方案明显优于前两种,压缩率都可以达到 83.7%。第三种是把整个 app 生命周期作为一个压缩单位进行压缩,如果这个压缩单位中有数据损坏,那么后面的日志也都解压不出来。但其实在短语式压缩过程中,滑动窗口并不是无限大的,一般是32kb ,所以只需要把一定大小作为一个压缩单位就可以了。这也就是第四个方案,这样的话即使压缩单位中有部分数据损坏,因为是流式压缩,并不影响这个单位中损坏数据之前的日志的解压,只会影响这个单位中这个损坏数据之后的日志。
对于使用流式压缩后,我们采用了三台安卓手机进行了耗时统计,和之前使用通用压缩的的日志方案进行了对比(耗时为单行日志的平均耗时):
通过横向对比,可以看出虽然使用流式压缩的耗时是使用多条日志同时压缩的 2.5 倍左右,但是这个耗时本身就很小,是微秒级别的,几乎不会对性能造成影响。最关键的,多条日志同时压缩会导致 CPU曲线短时间内极速升高,进而可能会导致程序卡顿,而流式压缩是把时间分散在整个生命周期内,CPU 的曲线更平滑,相当于把压缩过程中使用的资源均分在整个 app 生命周期内。
xlog 方案总结
该方案的简单描述:
使用流式方式对单行日志进行压缩,压缩加密后写进作为 log 中间 buffer的 mmap 中
虽然使用流式压缩并没有达到最理想的压缩率,但和 mmap 一起使用能兼顾流畅性 完整性 容错性的前提下,83.7%的压缩率也是能接受的。使用这个方案,除非 IO 损坏或者磁盘没有可用空间,基本可以保证不会丢失任何一行日志。
在实现过程中,各个平台上也踩了不少坑,比如:
iOS 锁屏后,因为文件保护属性的问题导致文件不可写,需要把文件属性改为 NSFileProtectionNone。
boost 使用 ftruncate 创建的 mmap 是稀疏文件,当设备上无可用存储时,使用 mmap 过程中可能会抛出 SIGBUS 信号。通过对新建的 mmap 文件的内容全写'0'来解决。
……
日志模块还存在一些其他策略:
每次启动的时候会清理日志,防止占用太多用户磁盘空间
为了防止 sdcard 被拔掉导致写不了日志,支持设置缓存目录,当 sdcard 插上时会把缓存目录里的日志写入到 sdcard 上
……
在使用的接口方面支持多种匹配方式:
类型安全检测方式:%s %d 。例如:xinfo(“%s %d”, “test”, 1)
序号匹配的方式:%0 %1 。例如:xinfo(TSF”%0 %1 %0”, “test”, 1)
智能匹配的懒人模式:% 。例如:xinfo(TSF”% %_”, “test”, 1)
总结
对于终端设备来说,打日志并不只是把日志信息写到文件里这么简单。除了前文提到的流畅性 完整性 容错性,还有一个最重要的是安全性。基于不怕被破解,但也不能任何人都能破解的原则,对日志的规范比加密算法的选择更为重要,所以本文并没有讨论这一点。
从前面的几个方案中可以看出,一个优秀的日志模块必须做到:
不能把用户的隐私信息打印到日志文件里,不能把日志明文打到日志文件里。
不能影响程序的性能。最基本的保证是使用了日志不会导致程序卡顿。
不能因为程序被系统杀掉,或者发生了 crash,crash 捕捉模块没有捕捉到导致部分时间点没有日志, 要保证程序整个生命周期内都有日志。
不能因为部分数据损坏就影响了整个日志文件,应该最小化数据损坏对日志文件的影响。
上面这几点也即安全性 流畅性 完整性 容错性, 它们之间存在着矛盾关系:
如果直接写文件会卡顿,但如果使用内存做中间 buffer 又可能丢日志
如果不对日志内容进行压缩会导致 IO 卡顿影响性能,但如果压缩,部分损坏可能会影响整个压缩块,而且为了增大压缩率集中压缩又可能导致 CPU 短时间飙高。
mars 的日志模块 xlog 就是在兼顾这四点的前提下做到:高性能高压缩率、不丢失任何一行日志、避免系统卡顿和 CPU 波峰。
原文出处:微信终端跨平台组件 mars 系列(二) - 信令传输超时设计
前言
mars 是微信官方使用 C++ 编写的业务性无关、平台性无关的终端基础组件,目前在微信 Android、iOS、Windows、Mac、Windows Phone 等多个平台中使用,并正在筹备开源,它主要包含以下几个独立的部分:
COMM:基础库,包括 socket、线程、消息队列、协程等基础工具;
XLOG:通用日志模块,充分考虑移动终端的特点,提供高性能、高可用、安全性、容错性的日志功能;(详情点击:高性能日志模块xlog )
SDT:网络诊断模块;
STN:信令传输网络模块,负责终端与服务器的小数据信令通道。包含了微信终端在移动网络上的大量优化经验与成果,经历了微信海量用户的考验。
本篇文章将为大家介绍 STN(信令传输网络模块),由于 STN 的复杂性,该模块将被分解为多个篇章进行介绍,本文主要内容为微信中关于读写超时的思考与设计。
读写超时与设计目标
TCP/IP中的超时设计
微信信令通信主要使用 TCP/IP 协议,数据经过应用层、传输层、网络层、链路层(见图1)。其中,链路层与传输层,协议提供了超时重传的机制。

链路层的超时与重传
在链路层,一般使用混合自动重传请求(即 HARQ)。HARQ 是一种结合 FEC(前馈式错误修正)与 ARQ(自动重传请求)的技术,原理如图2所示。

通过使用确认和超时这两个机制,链路层在不可靠物理设备的基础上实现可靠的信息传输。这个方案需要手机和 RNC 都支持,目前在EDGE、HSDPA、HSUPA、UMTS和 LTE 上都已实现支持。
传输层的超时与重传
传输层(即 TCP 层)提供可靠的传输,然而,TCP 层依赖的链路本身是不可靠的,TCP 是如何在不可靠的环境中提供可靠服务的呢?答案是超时和重传。TCP 在发送数据时设置一个定时器,当定时器溢出还没有收到 ACK,则重传该数据。因此,超时与重传的关键之处在于如何决定定时器间隔与重传频率。
传统 Unix 实现中,定时器的间隔取决于数据的往返时间(即 RTT),根据 RTT 进行一定的计算得到重传超时间隔(即RTO)。由于网络路由、流量等的变化,RTT 是经常发生变化的,RTT 的测量也极为复杂(平滑算法、Karn 算法、Jacbson 算法等)。在《TCP/IP详解》中,实际测量的重传机制如图3所示,重传的时间间隔,取整后分别为1、3、6、12、24、48和多个64秒。这个倍乘的关系被称为“指数退避”。

在移动终端中,RTO 的设计以及重试频率的设计是否与传统实现一致呢?对此我们进行了实测,实测数据如下:
图4所示为OPPO手机TCP超时重传的间隔,依次为[ 0.25s,0.5s,1s,2s,4s,8s,16s,32s,64s,64s,64s …]:

而 SamSung 中 TCP 超时重传的间隔依次为[0.42s, 0.9s, 1.8s, 3.7s, 7.5s, 15s, 30s, 60s, 120s, 120s …],见图5。

经过多次实际测试我们可以看出虽然由于不同厂商的 Android 系统实现,RTO 的值可能会有不同的设定,但都基本符合“指数退避”原则。
接下来再看 iOS 系统中,TCP RTO 的实验数据,图6所示为实验中第一次的数据[ 1s,1s,1s,2s,4.5s,9s,13.5s,26s,26s … ]。

上面的数据看起来并不完全符合指数退避,开始阶段的重试会较为频繁且 RTO 最终固定在 26s 这一较小的值上。
进行第二次测试后发现数据有了新的变化[1s,1s,1s,2s,3.5s,8.5s,12.5s,24s,24s …],如图7所示。

RTO 终值由26秒缩减至24秒,最终经过多次测试并未发现 iOS 中 TCP RTO 的规律,但可以看出 iOS 确实采用了较为激进的超时时间设定,对重试更为积极。
读写超时的目标
通过上述的调研与实验,可以发现在 TCP/IP 中,协议栈已经帮助我们进行了超时与重传的控制。并且在 Android、iOS 的移动操作系统中进行了优化,使用了更为积极的策略,以适应移动网络不稳定的特征。
那是否意味着我们的应用层已经不需要超时与重传的控制了呢?其实不然。在链路层,HARQ 提供的是节点之间每一数据帧的可靠传输;在传输层,TCP 超时重传机制提供的是端与端之间每个 TCP 数据包的可靠传输;同理,在微信所处的应用层中,我们仍然需要提供以“请求”为粒度的可靠传输。
那么,应用层的超时重传机制应该提供怎样的服务呢?
首先,我们来看一下应用层重传的做法。在应用层中,重传的做法是:断掉当前连接,重新建立连接并发送请求。这种重传方式能带来怎样的作用呢?回顾 TCP 层的超时重传机制可以发现,当发生超时重传时,重传的间隔以“指数退避”的规律急剧上升。在 Android 系统中,直到16分钟,TCP 才确认失败;在 iOS 系统中,直到1分半到3分半之间,TCP 才确认失败。这些数值在大部分应用中都是不为“用户体验”所接受的。因此,应用层的超时重传的目标首先应是:
- 在用户体验的接受范围内,尽可能地提高成功率
尽可能地增加成功率,是否意味着在有限的时间内,做尽可能多的重试呢?其实不然。当网络为高延迟/低速率的网络时,较快的应用层重传会导致“请求”在这种网络下很难成功。因此,应用层超时重传的目标二:
- 保障弱网络下的可用性
TCP连接是有固定物理线路的连接,当已 Connect 的线路中,如果中间设备出现较大波动或严重拥塞,即使在限定时间内该请求能成功,但带来的却是性能低下,反应迟钝的用户体验。通过应用层重连,期待的目标三是:
- 具有网络敏感性,快速的发现新的链路
我们总结应用层超时重传,可以带来以下作用:
减少无效等待时间,增加重试次数:当 TCP 层的重传间隔已经太大的时候,断连重连,使得 TCP 层保持积极的重连间隔,提高成功率;
切换链路:当链路存在较大波动或严重拥塞时,通过更换连接(一般会顺带更换IP&Port)获得更好的性能。
微信读写超时
方案一:总读写超时
在TCP层的超时重传设计中,超时间隔取决于RTT,RTT即TCP包往返的时间。同理,在微信的早期设计中,我们分析应用层“请求”的往返时间,将其RTT分解为:
请求发送耗时 - 类比TCP包传输耗时;
响应信令接收耗时 - 类比ACK传输耗时;
服务器处理请求耗时 - TCP接收端接收和处理数据包的时间相对固定,而微信服务器由于信令所属业务的不同,逻辑处理的耗时会差异明显,所以无法类比;
等待耗时 - 受应用中请求并发数影响。
因此,我们提出了应用层的总读写超时如图8所示,最低网速根据不同的网络取不同的值。

方案二:分步的读写超时
在实际的使用过程中,我们发现这仅仅是一个可用的方案,并不是一个高性能的解决方案:超时时长的设置使用了差网络下、完整的完成单次信令交互的时间估值。这使得超时时间过长,在网络波动或拥塞时,无法敏感地发现问题并重试。进一步分析可以发现,我们无法预知服务器回包的大小,因此使用了最大的回包进行估算(微信中目前最大回包可到128KB)。然而,TCP 传输中当发送数据大于 MSS 时,数据将被分段传输,分段到达接收端后重新组合。如果服务器的回包较大,客户端可能会收到多个数据段。因此,我们可以对首个数据分段的到达时间进行预期,从而提出首包超时,如图9所示。

首包超时缩短了发现问题的周期,但是我们发现如果首个数据分段按时到达,而后续数据包丢失的情况下,仍然要等待整个读写超时才能发现问题。为此我们引入了包包超时,即两个数据分段之间的超时时间。因为包包超时在首包超时之后,这个阶段已经确认服务器收到了请求,且完成了请求的处理,因此不需要计算等待耗时、请求传输耗时、服务器处理耗时,只需要估算网络的 RTT。
在目前方案中,使用了不同网络下的固定RTT。由于有了“首包已收到”的上下文,使得包包超时的间隔大大缩短,从而提高了对网络突然波动、拥塞、突发故障的敏感性,使得应用获得较高的性能。
方案三:动态的读写超时
在上述的方案中,总读写超时、首包超时都使用了一些估值,使得这两个超时是较大的值。假如我们能获得实时的动态网速等,我们能获得更好的超时机制,如图10所示。

但是,理想是丰满的,现实是残酷的:
动态网速需要通过工具方法测定,实时性要求高,并且要考虑网络波动的影响;
服务器动态耗时需要服务器下发不同业务信令的处理耗时;
真实回包大小则只能靠服务器通知。
上述的三种途径对客户端和服务器都是巨大的流量、性能的消耗,所以动态化这些变量看起来并不可行。
因此,这里需要换个角度思考动态优化,手机的网络状况可以大概地归为优质、正常、差三种情况,针对三种网络状况进行不同程度的调整,也是动态优化的一种手段。这里选择优质网络状况进行分析:
如何判定网络状况好?网速快、稳定,网络模块中与之等价的是能够短时间完成信令收发,并且能够连续长时间地完成短时间内信令收发。
即使出现网络波动,也可以预期会很快恢复。

根据对网络状况好的分析,我们可以做出这样的优化(如图11所示):
将客户端网络环境区分为优良(Excellent)、评估(Evaluating)两种状态;
网速快、稳定就是条件1,信令失败或网络类型切换是条件2。
进入Exc状态后,就缩短信令收发的预期,即减小首包超时时间,这样做的原因是我们认为用户的网络状况好,可以设置较短的超时时间,当遇到网络波动时预期它能够快速恢复,所以可以尽快超时然后进行重试,从而改善用户体验。
总结
虽然 TCP/IP 协议栈中的链路层、传输层都已经提供了超时重传,保障了传输的可靠性。但应用层有着不同的可靠性需求,从而需要额外的应用层超时重传机制来保障应用的高性能、高可用。应用层超时重传的设计目标,笔者从自身经验出发,总结为:
在用户体验的接受范围内,尽可能地提高成功率;
保障弱网络下的可用性;
具有网络敏感性,快速地发现新的链路。
依从这些目标,mars STN 的超时重传机制在使用中不断的精细化演进,使用了包含总读写超时、首包超时、包包超时、动态超时等多种方案的综合。即使如此,STN的超时重传机制也有着不少的缺点与局限性,例如相对适用于小数据传输的信令通道、局限于一来一回的通信模式等。mars STN也会不断发现新的问题持续演进,并且所有的演进都将在微信的海量用户中进行验证。同时也期待随着 mars STN的开源,能收获更多、更广的经验交流、问题反馈、新想法的碰撞等。
原文出处:微信终端跨平台组件 Mars 系列(三)连接超时与IP&Port排序
号外号外!微信 Mars 已于2016年12月28号的微信公开课上,正式公开了源代码,加入了开源阵营。相信很多小伙伴已经看到了 Mars的代码,在这里热切的期望小伙伴们多给 Mars 提pr & Issues,共同促进移动网络技术的发展。开源只是一个开始,我们也将继续在WeMobileDev 的公众号上,分享 Mars 的技术细节与未来规划。
前言
Mars 是微信官方的终端基础组件,是一个使用 C++ 编写的业务无关、跨平台的基础组件。目前在微信 Android、iOS、Windows、Mac、WP等多个平台中使用。Mars 主要包括以下几个独立的部分:
COMM:基础库,包括socket、线程、消息队列、协程等基础工具;
XLOG:通用日志模块,充分考虑移动终端的特点,提供高性能、高可用、安全性、容错性的日志功能;(详情点击:高性能日志模块xlog)
SDT:网络诊断模块;
STN:信令传输网络模块,负责终端与服务器的小数据信令通道。包含了微信终端在移动网络上的大量优化经验与成果,经历了微信海量用户的考验。
Mars 系列开始,将为大家介绍 STN(信令传输网络模块)。由于 STN 的复杂性,该模块将被分解为多个篇章进行介绍。本文主要介绍微信中关于 socket 连接及 IP&Port 选择的思考与设计。
你需要知道的TCP连接
TCP 协议应该是目前使用的最广泛的传输层协议,它提供了可靠的端到端的传输,为应用的设计节省了大量的工作。TCP建立连接的”三次握手”与连接终止的“四次挥手”也广为人知。在这简单的 connect 调用中,还能做怎样的思考与设计呢?
1. int connect(int sockfd, const struct *addr, socklen_t addrlen)
连接的超时重传
超时与重传是 TCP 协议最核心的部分,在不稳定的移动网络中,超时重传的设计尤为重要。在连接建立的过程中,由于网络本身的不可靠特性,不可避免的需要重传的机制来保障可靠服务。在《TCP/IP详解 卷1》的描述中,在大多数 BSD 实现中,若主动 connect 方没有收到 SYN 的回应,会在第6秒发送第2个SYN 进行重试,第3个 SYN 则是与第2个间隔24秒。在第75秒还没有收到回应,则 connect 调用返回 ETIMEOUT。
这就意味着,在不能立刻确认失败(例如 unreachable等)的情况下,需要75秒的时间,才能获得结果。如果真相并不是用户的网络不可用,而是某台服务器故障、繁忙、网络不稳定等因素,那75秒的时间只能尝试1个IP&Port 资源,对于大多数移动应用而言,是不可接受的。我们需要更积极的超时重传机制!!!
然而,我们并不能修改 TCP 的协议栈,我们只能在应用层进行干预,设计应用层的超时机制。说干就干,这个时候你是否已经在构思新的、应用层的连接超时重传机制了呢?应用层的超时重传,典型做法就是提前结束 connect 的阻塞调用,使用新的 IP&Port 资源进行 connect重试。但是,我们应该选择怎样的连接超时值呢?4秒?10秒?20秒?30秒?不同的应用场景会有不同的选择。我们来看一下常见的几种场景:
连不同 or 网络不可用等
服务器繁忙 or 中间路由故障等
基站繁忙 or 连接信号弱 or 丢包率高等
在第一种场景中,连接超时设置不会带来什么区别。在第二种场景中,部分服务器资源或路由不可用,我们希望连接超时能稍微短一些,使得我们能尽快的发现故障,并且通过更换 IP&Port 的方式获得可用资源或路由路径。而第三种场景则是在移动网络中经常遇到的弱网络的场景。在这种场景中,我们更换 IP&Port资源也是无效的,因此希望连接超时能相对长一些,进行更多的TCP层的重传。(当然,也不是超时越长越好,后面的分析可以看到很多等待时长是效果低微的)
不同的场景对连接超时有不同的需求,然而,我们在程序中并没有很好的方法来区分这些场景。在进行连接超时这个阈值的选择前,我们先来看看,当前主流的android、iOS 操作系统的连接设计。android 的 TCP 层连接超时重传如下图所示(测试机型为 nexus5,android4.4)。超时间隔依次为(1,2,4,8,16),第5次重试后32秒返回 ETIMEOUT,总用时63秒。超时设置符合 Linux 的常规设置。

但在不同的机型中,偶尔会出现差异性。如下图 android 抓包(三星 android 4.4)。

iOS 的 connect 超时重传如下图所示。超时间隔依次为(1,1,1,1,1,2,4,8,16,32),总共是67s。

经过 tcpdump 的调研分析后,我们发现:
在 iOS 系统中对 connect 的超时重传进行了一定的修改,在 connect 初期使用更积极的策略,以适应移动网络的不稳定特征。而在 android 系统中,connect 超时重传则使用了较为“懒惰”、适用于有线网络的超时重传间隔;
不管什么平台,连接总超时时长都需要1分钟左右,这个时长在大多数移动应用中,都是不符合用户体验要求的;
连接的初始阶段,TCP 超时重传会更积极一些,越到后面,重传间隔越大。
因此,在实际的连接超时设置上,我们根据不同的系统特征,结合应用能接受的“用户体验”范围,可以设置不同的连接超时间隔。例如在 iOS系统中,由于采用了较为积极的超时间隔,我们可以将 connect 调用的超时设置为10s。在10s内,iOS 会自动进行6次的重发。在 android 系统中,系统会在第7秒发起第3次重发,之后需要在第15秒才会重发。在不同的用户体验要求下,应用可以将 connect的调用超时设置为不同的值。例如也可以设置为10s(意味着给第3次重发3s的等待时间),从而避免无效的等待时长。同时通过更换 IP&Port 后,重新调用 connect 操作的方式,来获得更积极的重发策略,更快的查找到可用的 IP&Port 组合。
连接的终止
“四次挥手”的连接终止协议已经口熟能详。过程如下图所示。需要关注的是,图中主动关闭的一方会进入 TIME_WAIT 状态,在此状态中通常将停留2倍的 MSL 时长。MSL 时长在不同的操作系统中有不同的设置,通常在30秒到60秒。TIME_WAIT 的数量太多会导致耗尽主动关闭方的 socket 端口和句柄,导致无法再发起新的连接,进而严重影响主动关闭方的并发性能。虽然在实际的使用中,可以通过tcp_tw_recycle,tcp_tw_reuse,tcp_max_tw_buckets等方式缓解该问题,但也会带来一些副作用。最好的解决方案是在协议的设计上,尽量的由终端来发起关闭的操作,避免服务器的大量 TIME_WAIT 状态。例如,使用长连接避免频繁的关闭;在短连接的协议设计上,务必加上终止标记(例如 http 头部加上 content-length)使得可以由终端来发起关闭的操作。

串行连接 VS 并发连接 VS 复合连接
在上述的连接超时策略中,我们选择10秒的连接超时。这就意味着我们需要10秒的时间来确认一个 IP&Port 组合的 connect 超时。当我们有多个 IP&Port 资源时,遍历的效率偏低。那我们是否能设置 connect 的超时为更短呢?例如4秒。我们知道移动互联网具有不稳定的特征,超时时间设置过短,会导致在弱网络的情况下,connect 总是失败,导致不可用。串行连接的策略在超时选择上,由于需要兼顾高性能与高可用的设计目标,使得该策略是一个相对“慢”的连接策略。
与此相应,我们会想到并发连接的策略。并发连接,同时发起对N个 IP&Port 的连接调用,可以让我们第一时间发现可用的连接,并且还顺带发现了 connect 最快的 IP&Port 配置。并发连接可以一举解决了“高性能”、“高可用”的设计目标,看起来很完美。然而,这个时候,服务端的同学“跳”起来了。在并发连接的策略下,服务器需要提供的连接能力是串行连接的N倍,对服务器连接资源是极大的浪费。同时,并发连接是否会引起连接资源的竞争,从而影响网络正常用户的常规体验,也是个未知的因素。
让我们来回顾串行连接与并行连接的优缺点。
串行连接
资源占用少
无服务器负载问题
超时选择困难
最慢可用
并行连接
网络资源竞争
服务器负载高
最快可用
那么,有没有一种策略,能同时满足高性能、高可用、低负载的目标呢?在微信的连接设计中,我们使用了”复合连接“的策略。如下图所示。

初始阶段,应用发起对 IP1 &Port1 的 connect 调用。在第4秒的时候,如果第一个 connect 还没有返回,则发起对 IP2 &Port2 的 connect 调用。以此类推,直至发起了5组 IP&Port 的 connect 调用。
对比串行连接与并行连接,复合连接有以下特点:
常规情况下,服务器负载与串行连接策略相同,实现了低负载的目标;
异常情况下,每4s发起新(IP,Port)组合的 connect 调用,使得应用可以快速的查找可用 IP&Port,实现高性能的目标;
在超时时间的选择上,复合方式的“并发”已经实现了高性能、低负载的目标,因此在超时时间的选择上可以相对宽松,以保障高可用为重。
综合对比,复合连接能够维持低资源消耗的情况下,能同时实现低负载、高性能、高可用的目标。
微信 IP&Port 排序算法的演进
在建立连接的调用中,除了超时时间的设置外,IP&Port是连接的最重要参数。IP&Port 的排序、选择对于 connect的性能也是有着重大的影响。本节主要讨论在已知 IP 列表、Port 列表的情况下,如何排序、组合的问题,而不讨论如何获得就近接入等问题。
IP&Port 的组成
在微信中,IP有多种来源类型。优先级从上而下分别为:
WXDNS IP
DNS IP
Auth IP
Hardcode IP
WXDNS IP 是通过微信自建的 DNS 服务获得的IP列表,自建 DNS 对防劫持、有效期控制等有重要作用。DNS IP 则是通过常规的 DNS 解析获得的 IP 列表。Auth IP 是微信动态下发的保底IP列表。而Hardcode IP 则是最终的保底IP列表。总体而言,分为常规IP列表、保底IP列表两个类别。WXDNS IP、DNS IP 为常规列表,Auth IP,Hardcode IP 为保底列表。同时,在组成实际使用的 IP&Port 列表时,由于 WXDNS 与 DNS 的功能近似,因此通常只出现其中一种类型的IP列表。Auth IP 与Hardcode IP 的功能近似,也是同时只能出现两者中的一种类型。
在 Port 的选择上,微信服务在常规情况下提供2个端口,预防端口被封锁的情况。特别情况下,可以通过配置下发进行端口更新。
IP&Port排序算法(一):随机组合排序算法
每个TCP连接都是以 IP&Port 的组合为唯一标识。在 IP&Port 的选择上,我们初步归纳为2个目标:
高可用:尽快的找到可用的 IP&Port 资源
高性能:优先使用质量好的 IP&Port
负载均衡:IP的排序算法不带任何偏向因子,避免造成人为的负载不均衡
在微信早期的排序选择上,我们使用了一种随机组合的排序算法。即将 WXDNS or DNS IP 列表与 Port 列表进行组合,组合后的结果进行随机排序。在随机排序的结果列表中,使用下述步骤进行排序:
选取IP1+Port1;
选取IP2+Port2,尽量使得IP1与IP2不相等,Port1与Port2不相等;
选取IP3+Port3,尽量使得IP3与IP1、IP2都不相等,Port3与Port1、Port2都不相等;
以此类推,形成常规列表。
同理,使用 Auth IP or Hardcode IP 列表与 Port 列表的组合,我们按照相同算法生成另外一份保底列表,并将保底列表排序在常规列表的后面,从而组成完整的 IP&Port 列表。随机组合排序的算法有着以下的特点:
高性能:每一次尝试都尽量使用完全不同的资源,使得能最快的发现可用资源;
初始随机,从而避免列表顺序的固化;
保底列表在最后,形成最后的保护屏障;
在不同的网络下,维护着不同的资源列表。
在使用中,如果发现 IP&Port 访问失败,则在列表中 ban 掉该资源。这里有个小优化,即当 IP1&Port1 的上一次访问成功时,需要连续失败2次才 ban 该资源。目的是为了减小偶然的网络抖动造成的影响。
随机组合排序算法的设计初衷,是为了以最快的速度尝试不同的资源组合,从而快速寻找到可用的资源。然而,在微信的实际使用中,却发现这种算法存在着诸多的问题。例如:
网络不可用或网络较大波动情况下,列表被ban的速度较快;
Auth IP or Hardcode IP 列表太容易被访问到:随着常规资源陆续被ban,保底资源总是会被访问到,造成对保底资源的访问量大。保底资源是为了微信服务这不符合保底资源的设计初衷。
当引入复合连接策略后,IP资源不足。这是因为 ban 的策略简单粗暴的丢弃失败的 IP,导致 IP 资源越来越少;
每次缓存超时或列表轮空后,对于新列表没有经验信息可用
在随机组合排序算法的基础上,为了解决遇到的新问题,微信使用了新的“以史为鉴”的算法。
IP&Port 排序算法(二):以史为鉴
由于复合连接的引入,在每次复合连接的尝试中,微信可以伪“并发”的对N个 IP&Port 进行 connect(微信中目前N=5)。简单的ban丢弃的策略会使得 IP 资源越来越少。 针对这个特点,我们对IP&Port算法进行了以下修改:
初始资源列表分为两类列表:常规列表,保底列表,分别使用方案(一)随机组合排序算法生成初始顺序;
对每次复合连接使用的列表,规定5个资源的组成是4个常规资源+1个保底资源,并且保底资源在最后(完全无法获取常规资源的情况除外)。这种资源组成方式一方面解决了“保底资源太容易被访问到”的问题,一方面也保障了保底资源的作用;
在不同网络中,分别记录每个 IP&Port 的使用情况,并根据使用记录进行评分、排序;
区分连续记录:对每个 IP&Port 的更新,10秒内的连续成功或失败,不进行使用情况的记录。这种处理方式一方面是为了避免网络不可用或网络出现较大波动时,IP资源被过快的错误标记;一方面也避免失败历史被快速的覆盖;
最近的8条使用记录中,如果有超过3条失败记录,且最新一次失败记录时间为10分钟内,则本次排序ban该记录。这种处理方式的目的是避免历史分数较高的 IP&Port 在突然出现故障时很难被排序算法排除的问题;
无历史的记录使用随机评分排序。
通过上述方法,我们保证了保底资源不会被轻易访问到,解决了列表被快速标记的问题,同时也保证了历史记录好的资源在出现故障时也能被快速替换。
IP&Port 排序算法(三):遗忘历史
“以史为鉴”的方案在微信中使用了一段时间,看起来运行良好。直至某一天,微信的部分服务集群出现了故障。虽然微信客户端快速的切换到可用的服务器资源,但当故障服务器恢复后,微信客户端却迟迟没有分流到已恢复服务的集群,导致部分微信服务器负载过高,而部分微信服务器却负载较低的情况。通过分析,发现“以史为鉴”的排序方案存在着一些问题:
初始阶段排在前面的资源容易获得较多的成功记录,从而分数始终维持在较高的水平;
出灾情况下,故障机器由于有失败记录,使得很难获得“被原谅”的机会,从而也很难更新使用历史;
采用了无历史记录随机评分,破坏了原有的“相邻记录尽量不相同”的随机性设计;
因此,好的 IP&Port 排序算法,不仅应该快速的发现可用的资源,使得在出灾情况下能快速的响应,同时,也应该具备一定的“遗忘性”、“容灾性”,使得灾情恢复后能较快的发现“灾情恢复”这一事实,并且进行重排序,使得服务器资源得到更合理的使用。在综合考虑“以史为鉴”和“遗忘历史”后,新的 方案具有以下特征:
内存历史、文件历史双层记录历史:反映资源使用的近期情况及历史情况;
初始化状态:每次进程重启或网络切换后,从文件历史中“压缩”出内存历史作为初始状态;
旁路检测:额外更新历史的渠道,更有助于挑选高性能的资源,并且帮助“灾情恢复”的资源获得使用的机会;
文件历史的遗忘性:文件历史每24小时强制刷新,避免高分数的记录长期“占有”队列;
无历史、有历史的混合排序。
具体实现查看 Mars 源代码中的 simple_ipport_sort。
总结
连接是信令传输的前提,一个简单的连接操作蕴含着不少的优化空间。在连接超时的选择上,我们要兼顾性能与可用性,过短的连接超时可能导致弱网络下的低可用性,但过长的连接超时又影响用户体验。在 STN 中,我们结合系统本身的 TCP 连接重传特性,进行了相应的设计考量。即使如此,串行的连接方案仍然不能满足高性能的需求。并发连接的方案获得高性能的同时,也带来了服务器负载剧增的损失。综合考虑下,STN 使用了“复合连接”的方案,获得高性能的同时,也保证通常情况下的服务器低负载。
IP&Port 是连接的最重要资源,IP&Port 的排序选择是连接过程的重要部分。在微信的实际使用中,我们依次使用了“随机组合”、“以史为鉴”、“遗忘历史”三种方案,综合的考虑了查找性能、移动互联网的不稳定性、容灾及容灾恢复等。
连接超时、连接策略及 IP&Port 排序是连接的是三个重要组成部分,相关的方案也随着微信实践在不断的发展中。相信在不同的应用场景中,我们可能会遇到更多的不同问题及需求。随着Mars的开源,也能有机会参考、吸收其他应用中的实战经验,使得网络优化持续的深入。