本篇文章介绍了小米技术团队2020~2021年在小爱网关服务上所做的一些优化和尝试,最终将单机(40C16G)可承载长连接数从 30w 提升到 120w+,同时节省30+台服务器。
“小爱同学”是小米集团统一的智能语音服务基础设施, “小爱同学”客户端被集成在小米手机、小米 AI 音箱、小米电视等设备中,广泛应用于个人移动、智能家居、智能穿戴、智能办公、儿童娱乐、智能出行、智慧酒店和智慧学习等八大场景。
小爱接入层网关是小爱云端设备长连接接入的关键服务,也是核心服务之一。
01
什么是小爱接入层网关
整个小爱的架构分层如下:
接入层主要的工作在鉴权授权层和传输层,它是所有小爱设备和小爱大脑交互的第一个服务,由上图我们知道小爱接入层的重要功能有如下几个:
安全传输和鉴权:维护设备和大脑的安全通道,保障身份认证有效和传输数据安全;
维护长链接:维持设备和大脑的长链接(Websocket等),做好连接状态存储,心跳维护等工作;
请求转发:针对每一次小爱设备的请求做好转发,保障每一次请求的稳定;
02
早期接入层技术实现和问题
小爱接入层最早的实现是基于 Akka (https://akka.io/) 和 Playframework (https://www.playframework.com/),我们使用它们搭建了第一个版本,其特点如下:
基于Akka,我们实现了初步的异步化,确保核心线程不会被阻塞,性能表现良好。
playframework框架天然支持Websocket,因此我们在有限的人力下能够快速搭建和实现,且能够保证协议实现的标准性。
但是随着小爱长链接的数量突破千万大关,我们发现了以下问题:
a. 长链接数量上来后,需要维护的内存数据越来越多,JVM的GC成为不可忽略的性能瓶颈,且一旦代码写的不好有GC风险。经过之前事故分析,AKKA+Play版的接入层其单实例长链接数量的上限在28w左右;
b. 老版本的接入层实现比较随意,其AKKA Actor之间存在非常多的状态依赖而不是基于不可变的消息传递这样使得Actor之间的通信变成了函数调用,导致代码可读性差且维护很困难,没有发挥出AKKA Actor在构建并发程序的优势;
c. 作为接入层服务,老版本对协议的解析是有很强的依赖的,这导致它要随着版本变动而频繁上线,其上线会引起长链接重连,随时有雪崩的风险;
d. 由于依赖Play框架,我们发现其长链接打点有不准确的问题(因为拿不到底层TCP连接的数据),这个会影响我们每日巡检对服务容量的评估,且依赖其他框架在长链接数量上来后我们没有办法做更细致的优化;
基于以上问题,我们打算重构接入层。对于接入层我们的目标是:
足够稳定,上线尽可能不断连接且服务稳定;
极致性能,目标单机至少100w长链接,最好不要受GC影响;
最大限度可控,除了底层网络I/O的系统调用,其他所有代码都要是自己实现/或者内部实现的组件,这样我们有足够的自主权;
之后,我们开始了单机百万长链接的漫漫实践之路。
03
百万长链接的演进
3.1 演进方向:合理分工,各司其职
3.1.1 接入层的依赖关系
接入层与外部服务的关系理清如下:
3.1.2 接入层的主要功能
websocket解析:收到的客户端字节流,要按照websocket协议要求解析出数据。
Socket状态保持:存储连接的基本状态信息。
加密解密:与客户端通讯的所有数据都是加密过的,而与后端模块之间传输是json明文的。
顺序化:同一个物理连接上,先后两个请求A,B到达服务器,后端服务中B可能先于A得到了应答,但是我们收到B不能立刻发送给客户端,必须等待A完成后,再按照A,B的顺序发给客户端
后端消息分发:接入层后面不止对接单个服务,可能根据不同的消息转发给不同的服务
鉴权:安全相关验证,身份验证等
3.1.3 接入层的拆分思路:有无状态
把之前的单一模块按照是否有状态,拆分为两个子模块:
前端:有状态,功能最小化,尽量少上线。
后端:无状态,功能最大化,上线可做到用户无感知。
所以按照上面的原则,理论上我们会做出这样的功能划分,前端很小,后端很大:
3.2 新版接入层 1.0 的技术实现
3.2.1 架构总览
a. 模块拆分为前后端
前端有状态,后端无状态;
前后端是独立进程,同机部署;
前端负责建立与维护设备长连接的状态,为有状态服务;后端负责具体业务请求,为无状态服务。后端服务上线不会导致设备连接断开重连及鉴权调用,避免了长连接状态因版本升级或逻辑调整而引起的不必要抖动;
b. 前端使用CPP实现
Websocket协议完全自己解析,可以从Socket层面获取所有信息,任何Bug都可以处理。
更高的CPU利用率,没有任何额外JVM代价,无GC拖累性能。
更高的内存利用率,连接数量变大后与连接相关的内存开销变大,自己管理可以极端优化。
c. 后端暂时使用Scala实现
已实现的功能直接迁移,比重写代价要低得多;
依赖的部分外部服务(比如鉴权)有可直接利用的Scala(Java)SDK库,而没有C++版本,若用C++重写代价非常大;
全部功能无状态化改造,可以做到随时重启而用户无感知;
d. 通讯使用ZeroMQ
进程间通讯最高效的方式是共享内存,ZeroMQ基于共享内存实现,速度没问题。
3.2.2 前端实现
整体架构
a. 四个子模块:
传输层,Websocket协议解析,XMD协议解析。
分发层,屏蔽传输层的差异,不管传输层使用的什么接口,在分发层转化成统一的事件投递到状态机
状态机层,为了实现纯异步服务,使用自研的基于Actor模型的类AKKA状态机框架XMFSM,这里面实现了单线程的Actor抽象;
ZeroMQ通讯层,由于ZeroMQ接口是阻塞实现,这一层通过两个线程分别负责发送和接收。
b. 传输层
Websocket 部分使用 C++ 和 ASIO 实现 websocket-lib。小爱长链接基于Websocket协议,因此我们自己实现了一个Websocket长链接库:
无锁化设计,保障性能优异;
基于BOOST ASIO 开发,保障底层网络性能;
压测显示该库的性能十分优异的:
长链接数 | qps | P99延时 |
---|---|---|
100w | 5w | 5ms |
这一层同时也承担了除原始websocket外,其他两种通道的的收发任务,目前传输层一共支持以下3种不同的客户端接口:
websocket(tcp)
基于ssl的加密websocket(tcp)
xmd(udp)
c. 分发层
把不同的传输层事件转化成统一事件投递到状态机,这一层起到适配器的作用,确保无论前面的传输层使用哪种类型,到达分发层变都变成一致的事件向状态机投递。
d. 状态机处理层
主要的处理逻辑都位于这一层中,这里非常重要的一个部分是对于发送通道的封装。
对于小爱应用层协议,不同的通道处理逻辑是完全一致的,但是在处理和安全相关逻辑上每个通道又有细节差异。比如
wss 收发不需要加解密,加解密由更前端的Nginx做了,而ws需要使用AES加密发送;
wss 在鉴权成功后不需要向客户端下发challenge文本,因为wss不需要做加解密;
xmd 发送的内容与其他两个不同,是基于protobuf封装的私有协议,且xmd需要处理发送失败后的逻辑,而ws/wss不用考虑发送失败的问题,由底层Tcp协议保证。
针对这种情况,我们使用C++的多态特性来处理,专门抽象了一个Channel接口,这个接口中提供的方法包含了一个请求处理的一些关键差异步骤,比如如何发送消息到客户端,如何stop连接,如何处理发送失败等等。对于3种(ws/wss/xmd)不同的发送通道,每个通道有自己的Channel实现。
客户端连接对象一创建,对应类型的具体Channel对象就立刻被实例化。这样状态机主逻辑中只实现业务层的公共逻辑即可,当在有差异逻辑调用时,直接调用Channel接口完成,这样一个简单的多态特性帮助我们分割了差异,确保代码整洁。
e. ZeroMQ 通讯层
通过两个线程将ZeroMQ的读写操作异步化,同时负责若干私有指令的封装和解析。
3.2.3 后端实现
a. 无状态化改造
后端做的最重要改造之一就是将所有与连接状态相关的信息进行剔除,整个服务以 Request(一次连接上可以传输N个Request)为核心进行各种转发和处理,每次请求与上一次请求没有任何关联。一个连接上的多次请求在后端模块被当做独立请求处理。
b. 架构
Scala 服务采用 Akka-Actor 架构实现了业务逻辑。服务从 ZeroMQ 收到消息后,直接投递到 Dispatcher 中进行数据解析与请求处理,在 Dispatcher 中不同的请求会发送给对应的 RequestActor进行 Event 协议解析并分发给该 event 对应的业务 Actor 进行处理。最后将处理后的请求数据通过XmqActor 发送给后端 AIMS&XMQ 服务。
一个请求在后端多个 Actor 中的处理流程:
c. 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 命令
Update 命令,设备状态信息更新,用于更新该设备在数据库中保存的相关信息;
Ping 命令,连接保活,用于确认该设备处于在线连接状态。
TEXT_MESSAGE与BINARY_MESSAGE
文本消息与二进制消息,在收到文本消息或二进制消息时将根据 requestid 发送给该请求对应的RequestActor进行处理。
d. Request 请求解析
针对收到的文本和二进制消息,DispatcherActor 会根据 requestId 将其发送给对应的RequestActor进行处理。其中,文本消息将会被解析为Event请求,并根据其中的 namespace 和 name 将其分发给指定的业务Actor。二进制消息则会根据当前请求的业务场景被分发给对应的业务Actor。
3.2.4 其他优化
在完成新架构 1.0 调整过程中,我们也在不断压测长链接容量,总结几点对容量影响较大的点:
协议优化
JSON替换为Protobuf: 早期的前后端通信使用的是 json 文本协议,后来发现 json 序列化、反序列化这部分对CPU的占用较大,改为了 protobuf 协议后,CPU占用率明显下降;
JSON支持部分解析:业务层的协议是基于json的,没有办法直接替换,我们通过"部分解析json"的方式,只解析很小的 header 部分拿到 namespace 和 name,然后将大部分直接转发的消息转发出去,只将少量 json 消息进行完整反序列化成对象。此种优化后CPU占用下降10%。
延长心跳时间
在第一次测试20w连接时,我们发现在前后端收发的消息中,一种用来保持用户在线状态的心跳PING消息占了总消息量的75%,收发这个消息耗费了大量CPU。因此我们延长心跳时间也起到了降低CPU消耗的目的。
自研内网通讯库
为了提高与后端服务通信的性能,我们使用自研的TCP通讯库,该库是基于Boost ASIO开发的一个纯异步的多线程TCP网络库,其卓越的性能帮助我们将连接数提升到120w+。
04
未来规划
经过架构1.0的优化,验证了我们的拆分方向是正确的,因为预设的目标已经达到:
单机承载的连接数 28w提升到 120w+(40C16G),节省了50%+的机器成本;
后端可以做到无损上线
再重新审视下我们的理想目标,以这个为方向,我们就有了2.0的雏形:
后端模块使用C++重写,进一步提高性能和稳定性。同时将后端模块中无法使用C++重写的部分,作为独立服务模块运维,后端模块通过网络库调用。
前端模块中非必要功能尝试迁移到后端,让前端功能更少,更稳定。
如果改造后,前端与后端处理能力差异较大,考虑到ZeroMQ实际是性能过剩的,可以考虑使用网络库替换掉ZeroMQ,这样前后端可以从1:1单机部署变为1:N多机部署,更好的利用机器资源。
最终目标是经过改造后,单前端模块可以达到200w+的连接处理能力。
往期推荐