本文由小米技术团队分享,原题“小爱接入层单机百万长连接演进”,有修订。
1、引言
小爱接入层是小爱云端负责设备接入的第一个服务,也是最重要的服务之一,本篇文章介绍了小米技术团队2020至2021年在这个服务上所做的一些优化和尝试,最终将单机可承载长连接数从30w提升至120w+,节省了机器30+台。
提示:什么是“小爱”?
小爱(全名“小爱同学”)是小米旗下的人工智能语音交互引擎,搭载在小米手机、小米AI音箱、小米电视等设备中,在个人移动、智能家庭、智能穿戴、智能办公、儿童娱乐、智能出行、智慧酒店、智慧学习共八大类场景中使用。
(本文同步发布于:http://www.52im.net/thread-3860-1-1.html)
2、专题目录
本文是专题系列文章的第7篇,总目录如下:
- 《长连接网关技术专题(一):京东京麦的生产级TCP网关技术实践总结》
- 《长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践》
- 《长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路》
- 《长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践》
- 《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》
- 《长连接网关技术专题(六):石墨文档单机50万WebSocket长连接架构实践》
- 《长连接网关技术专题(七):小米小爱单机120万长连接接入层的架构演进》(* 本文)
3、什么是小爱接入层
整个小爱的架构分层如下:
接入层主要的工作在鉴权授权层和传输层,它是所有小爱设备和小爱大脑交互的第一个服务。
由上图我们知道小爱接入层的重要功能有如下几个:
- 1)安全传输和鉴权:维护设备和大脑的安全通道,保障身份认证有效和传输数据安全;
- 2)维护长连接:维持设备和大脑的长连接(Websocket等),做好连接状态存储,心跳维护等工作;
- 3)请求转发:针对每一次小爱设备的请求做好转发,保障每一次请求的稳定。
4、早期接入层的技术实现
小爱接入层最早的实现是基于Akka和Play,我们使用它们搭建了第一个版本,该版本特点如下:
- 1)基于Akka我们基本做到了初步的异步化,保障核心线程不被阻塞,性能尚可。
- 2)Play框架天然支持Websocket,因此我们在有限的人力下能够快速搭建和实现,且能够保障协议实现的标准性。
5、早期接入层的技术问题
随着小爱长连接的数量突破千万大关,针对早期的接入层方案,我们发现了一些问题。
主要的问题如下:
1)长连接数量上来后,需要维护的内存数据越来越多,JVM的GC成为不可忽略的性能瓶颈,且一旦代码写的不好有GC风险。经过之前事故分析,Akka+Play版的接入层其单实例长连接数量的上限在28w左右。
2)老版本的接入层实现比较随意,其Akka Actor之间存在非常多的状态依赖而不是基于不可变的消息传递这样使得Actor之间的通信变成了函数调用,导致代码可读性差且维护很困难,没有发挥出Akka Actor在构建并发程序的优势。
3)作为接入层服务,老版本对协议的解析是有很强的依赖的,这导致它要随着版本变动而频繁上线,其上线会引起长连接重连,随时有雪崩的风险。
4)由于依赖Play框架,我们发现其长连接打点有不准确的问题(因为拿不到底层TCP连接的数据),这个会影响我们每日巡检对服务容量的评估,且依赖其他框架在长连接数量上来后我们没有办法做更细致的优化。
6、新版接入层的设计目标
基于早期接入层技术方案的种种问题,我们打算重构接入层。
对于新版接入层我们制定的目标是:
- 1)足够稳定:上线尽可能不断连接且服务稳定;
- 2)极致性能:目标单机至少100w长连接,最好不要受GC影响;
- 3)最大限度可控:除了底层网络I/O的系统调用,其他所有代码都要是自己实现/或者内部实现的组件,这样我们有足够的自主权。
于是,我们开始了单机百万长连接的漫漫实践之路。。。
7、新版接入层的优化思路
7.1 接入层的依赖关系
接入层与外部服务的关系理清如下:
7.2 接入层的功能划分
接入层的主要功能划分如下:
- 1)WebSocket解析:收到的客户端字节流,要按照WebSocket协议要求解析出数据;
- 2)Socket状态保持:存储连接的基本状态信息;
- 3)加密解密:与客户端通讯的所有数据都是加密过的,而与后端模块之间传输是json明文的;
- 4)顺序化:同一个物理连接上,先后两个请求A、B到达服务器,后端服务中B可能先于A得到了应答,但是我们收到B不能立刻发送给客户端,必须等待A完成后,再按照A,B的顺序发给客户端;
- 5)后端消息分发:接入层后面不止对接单个服务,可能根据不同的消息转发给不同的服务;
- 6)鉴权:安全相关验证,身份验证等。
7.3 接入层的拆分思路
把之前的单一模块按照是否有状态,拆分为两个子模块。
具体如下:
- 1)前端:有状态,功能最小化,尽量少上线;
- 2)后端:无状态,功能最大化,上线可做到用户无感知。
所以,按照上面的原则,理论上我们会做出这样的功能划分,即前端很小、后端很大。示意图如下图所示。
8、新版接入层的技术实现
8.1 总览
模块拆分为前后端:
- 1)前端有状态,后端无状态;
- 2)前后端是独立进程,同机部署。
补充:前端负责建立与维护设备长连接的状态,为有状态服务;后端负责具体业务请求,为无状态服务。后端服务上线不会导致设备连接断开重连及鉴权调用,避免了长连接状态因版本升级或逻辑调整而引起的不必要抖动;
前端使用CPP实现:
- 1)Websocket协议完全自己解析:可以从Socket层面获取所有信息,任何Bug都可以处理;
- 2)更高的CPU利用率:没有任何额外JVM代价,无GC拖累性能;
- 3)更高的内存利用率:连接数量变大后与连接相关的内存开销变大,自己管理可以极端优化。
后端暂时使用Scala实现:
- 1)已实现的功能直接迁移,比重写代价要低得多;
- 2)依赖的部分外部服务(比如鉴权)有可直接利用的Scala(Java)SDK库,而没有C++版本,若用C++重写代价非常大;
- 3)全部功能无状态化改造,可以做到随时重启而用户无感知。
通讯使用ZeroMQ:
进程间通讯最高效的方式是共享内存,ZeroMQ基于共享内存实现,速度没问题。
8.2 前端实现
整体架构:
如上图所示,由四个子模块组成:
- 1)传输层:Websocket协议解析,XMD协议解析;
- 2)分发层:屏蔽传输层的差异,不管传输层使用的什么接口,在分发层转化成统一的事件投递到状态机;
- 3)状态机层:为了实现纯异步服务,使用自研的基于Actor模型的类Akka状态机框架XMFSM,这里面实现了单线程的Actor抽象;
- 4)ZeroMQ通讯层:由于ZeroMQ接口是阻塞实现,这一层通过两个线程分别负责发送和接收。
8.2.1)传输层:
WebSocket 部分使用 C++ 和 ASIO 实现 websocket-lib。小爱长连接基于WebSocket协议,因此我们自己实现了一个WebSocket长连接库。
这个长连接库的特点是:
- a. 无锁化设计,保障性能优异;
- b. 基于BOOST ASIO 开发,保障底层网络性能。
压测显示该库的性能十分优异的:
这一层同时也承担了除原始WebSocket外,其他两种通道的的收发任务。
目前传输层一共支持以下3种不同的客户端接口:
- a. websocket(tcp):简称ws;
- b. 基于ssl的加密websocket(tcp):简称wss;
- c. xmd(udp):简称xmd。
8.2.2)分发层:
把不同的传输层事件转化成统一事件投递到状态机,这一层起到适配器的作用,确保无论前面的传输层使用哪种类型,到达分发层变都变成一致的事件向状态机投递。
8.2.3)状态机处理层:
主要的处理逻辑都位于这一层中,这里非常重要的一个部分是对于发送通道的封装。
对于小爱应用层协议,不同的通道处理逻辑是完全一致的,但是在处理和安全相关逻辑上每个通道又有细节差异。
比如:
- a. wss 收发不需要加解密,加解密由更前端的Nginx做了,而ws需要使用AES加密发送;
- b. wss 在鉴权成功后不需要向客户端下发challenge文本,因为wss不需要做加解密;
- c. xmd 发送的内容与其他两个不同,是基于protobuf封装的私有协议,且xmd需要处理发送失败后的逻辑,而ws/wss不用考虑发送失败的问题,由底层Tcp协议保证。
针对这种情况:我们使用C++的多态特性来处理,专门抽象了一个Channel接口,这个接口中提供的方法包含了一个请求处理的一些关键差异步骤,比如如何发送消息到客户端,如何stop连接,如何处理发送失败等等。对于3种(ws/wss/xmd)不同的发送通道,每个通道有自己的Channel实现。
客户端连接对象一创建,对应类型的具体Channel对象就立刻被实例化。这样状态机主逻辑中只实现业务层的公共逻辑即可,当在有差异逻辑调用时,直接调用Channel接口完成,这样一个简单的多态特性帮助我们分割了差异,确保代码整洁。
8.2.4)ZeroMQ 通讯层:
通过两个线程将ZeroMQ的读写操作异步化,同时负责若干私有指令的封装和解析。
8.3 后端实现
8.3.1)无状态化改造:
后端做的最重要改造之一就是将所有与连接状态相关的信息进行剔除。
整个服务以 Request(一次连接上可以传输N个Request)为核心进行各种转发和处理,每次请求与上一次请求没有任何关联。一个连接上的多次请求在后端模块被当做独立请求处理。
8.3.2)架构:
Scala 服务采用 Akka-Actor 架构实现了业务逻辑。
服务从 ZeroMQ 收到消息后,直接投递到 Dispatcher 中进行数据解析与请求处理,在 Dispatcher 中不同的请求会发送给对应的 RequestActor进行 Event 协议解析并分发给该 event 对应的业务 Actor 进行处理。最后将处理后的请求数据通过XmqActor 发送给后端 AIMS&XMQ 服务。
一个请求在后端多个 Actor 中的处理流程:
8.3.3)Dispatcher 请求分发:
前端与后端之间通过 Protobuf 进行交互,避免了Json 解析的性能消耗,同时使得协议更加规范化。
后端服务从 ZeroMQ 收到消息后,会在 DispatcherActor 中进行PB协议解析并根据不同的分类(简称CMD)进行数据处理,分类包括如下几种。
* BIND 命令:
鉴权功能,由于鉴权功能逻辑复杂,使用C++语言实现起来较为困难,目前依然放在 scala 业务层进行鉴权。该部分对设备端请求的 HTTP Headers 进行解析,提取其中的 token 进行鉴权,并将结果返回前端。
* LOGIN 命令:
设备登入,设备鉴权通过后当前连接已成功建立,此时会进行 Login 命令的执行,用于将该长连接信息发送至AIMS并记录于Varys服务中,方便后续的主动下推等功能。在 Login 过程中,服务首先将请求 Account 服务获取长连接的 uuid(用于连接过程中的路由寻址),然后将设备信息+uuid 发送至AIMS进行设备登入操作。
* LOGOUT 命令:
设备登出,设备在与服务端断开连接时需要进行 Logout 操作,用于从 Varys 服务中删除该长连接记录。
* UPDATE 与 PING 命令:
a. Update 命令,设备状态信息更新,用于更新该设备在数据库中保存的相关信息;
b. Ping 命令,连接保活,用于确认该设备处于在线连接状态。
* TEXT_MESSAGE 与 BINARY_MESSAGE:
文本消息与二进制消息,在收到文本消息或二进制消息时将根据 requestid 发送给该请求对应的RequestActor进行处理。
8.3.4)Request 请求解析:
针对收到的文本和二进制消息,DispatcherActor 会根据 requestId 将其发送给对应的RequestActor进行处理。
其中:文本消息将会被解析为Event请求,并根据其中的 namespace 和 name 将其分发给指定的业务Actor。二进制消息则会根据当前请求的业务场景被分发给对应的业务Actor。
8.4 其他优化
在完成新架构 1.0 调整过程中,我们也在不断压测长连接容量,总结几点对容量影响较大的点。
8.4.1)协议优化:
a. JSON替换为Protobuf: 早期的前后端通信使用的是 json 文本协议,后来发现 json 序列化、反序列化这部分对CPU的占用较大,改为了 protobuf 协议后,CPU占用率明显下降。
b. JSON支持部分解析:业务层的协议是基于json的,没有办法直接替换,我们通过"部分解析json"的方式,只解析很小的 header 部分拿到 namespace 和 name,然后将大部分直接转发的消息转发出去,只将少量 json 消息进行完整反序列化成对象。此种优化后CPU占用下降10%。
8.4.2)延长心跳时间:
在第一次测试20w连接时,我们发现在前后端收发的消息中,一种用来保持用户在线状态的心跳PING消息占了总消息量的75%,收发这个消息耗费了大量CPU。因此我们延长心跳时间也起到了降低CPU消耗的目的。
8.4.3)自研内网通讯库:
为了提高与后端服务通信的性能,我们使用自研的TCP通讯库,该库是基于Boost ASIO开发的一个纯异步的多线程TCP网络库,其卓越的性能帮助我们将连接数提升到120w+。
9、未来规划
经过新版架构1.0版的优化,验证了我们的拆分方向是正确的,因为预设的目标已经达到:
- 1)单机承载的连接数 28w => 120w+(普通服务端机器 16G内存 40核 峰值请求QPS过万),接入层下线节省了50%+的机器成本;
- 2)后端可以做到无损上线。
再重新审视下我们的理想目标,以这个为方向,我们就有了2.0版的雏形:
具体就是:
- 1)后端模块使用C++重写,进一步提高性能和稳定性。同时将后端模块中无法使用C++重写的部分,作为独立服务模块运维,后端模块通过网络库调用;
- 2)前端模块中非必要功能尝试迁移到后端,让前端功能更少,更稳定;
- 3)如果改造后,前端与后端处理能力差异较大,考虑到ZeroMQ实际是性能过剩的,可以考虑使用网络库替换掉ZeroMQ,这样前后端可以从1:1单机部署变为1:N多机部署,更好的利用机器资源。
2.0版目标是:经过以上改造后,期望单前端模块可以达到200w+的连接处理能力。
10、参考资料
[1] 上一个10年,著名的C10K并发连接问题
[2] 下一个10年,是时候考虑C10M并发问题了
[3] 一文读懂高性能网络编程中的线程模型
[4] 深入操作系统,一文读懂进程、线程、协程
[5] Protobuf通信协议详解:代码演示、详细原理介绍等
[6] WebSocket从入门到精通,半小时就够!
[7] 如何让你的WebSocket断网重连更快速?
[8] 从100到1000万高并发的架构演进之路
学习交流:
(本文同步发布于:http://www.52im.net/thread-3860-1-1.html)