文章目录

前言

无意间搜到一篇知乎技术团队一个月前(2019年5月24日)发表的《知乎千万级高性能长连接网关揭秘》,浏览了内容和评论后兴奋了起来,这感觉就像考试之后对答案发现另一个同学的解题思路跟你一模一样而且别人已经考了150分。分布式服务器最头疼的就是长连接了,下面结合知乎的做法,具体说一说长连接网关的设计难点和解决思路。

一、长连接网关的需求分析

对于安卓APP的应用,长连接能提供推送消息、即时通讯、游戏、共享定位等等功能,也就是适用于需要服务器主动往客户端“推”的业务场景。随着业务规模的扩大,不同的业务可能都会需要长连接,所以现在几乎每个互联网公司都会将长连接系统做成一个基础服务供后面的业务使用。知乎团队称自己的这套长连接网关设计方案“经过一年多开发和演进,面向数个APP,数百万设备同时在线,经历了突发大规模消息推送”,很有借鉴价值。

二、知乎网关架构设计概述

2.1 整体结构

知乎采用了经典的“发布订阅模型”来解耦客户端(APP)、长连接网关、云业务后端三者的关联关系,完全参考了MQTT协议设计。

APP通过指定Topic和后端业务通信,消息是二进制数据(MQTT协议的Payload也是二进制数据),因此网关无需关心业务方具体协议规范和序列化方式。

2.2 认证和授权

网关需要对客户端能够访问的Topic进行权限控制,避免数据污染或越权访问。这里要区分一下认证和授权的概念:认证指的是身份校验,也就是我知道你是我们系统中合法的客户端;授权指的是权限控制,管理员、VIP用户、普通用户、游客,虽然都是合法的客户端,但他们可以访问的Topic应该是不同的。所以对于一般的场景,比如物联网,假设一个品类的权限是固定的,那么可以在客户端内置证书,服务器识别指定的证书来认证,然后根据证书中的信息进行固定授权。

知乎遇到的困难在于客户端的权限并不是固定的,知乎Live频道是需要付费的,任何用户都可能付费,付费后才能观看指定的频道内容,对于云网关来说,用户付费后就可以订阅主题,用户没付费就不能订阅,而用户付费状态只有后端服务知道,网关无法独立作出判断。因此知乎在ACL规则设计中提供了基于回调的鉴权机制,也就是网关只提供一个认证、授权接口,后端服务提供认证、授权实现,网关将每个订阅、发布的动作通过HTTP回调给后端服务进行身份权限判断。

很容易想到,如果每个客户端的每个订阅、发布请求都要通过HTTP调用一次后端服务接口,这么巨大的访问量会造成性能低下。知乎对内部业务观察后发现,大部分场景下客户端都只订阅自己的主题(物联网的设计上也几乎都是订阅设备自己的主题),所以可以使用Topic模板变量来降低业务方的接入成本,网关可以直接判断是否有模板的权限。举个例子,原本的订阅主题可能是“notification/faceair”、“notification/bwb”等等,这些订阅和业务耦合;现在定义一个Topic模板“notification/{username}”,网关在接收到客户端faceair订阅请求后,将faceair的username填进去变成"notification/faceair",然后对比订阅的是否是这个主题即可,网关完全不知道业务是什么,只做了简单的字符串替换和比对就完成了授权,唯一带来的限制就是,客户端只能订阅公共主题或者包含自己信息的主题,比如客户端faceair无法订阅notification/bwb。

对于物联网的场景,APP除了用户账号的Topic,还可能需要订阅其账号下全部设备的Topic,这种动态性简单替换模板是达不到要求的,所以这种场景还是需要和帐号业务耦合,通过帐号拉取其下设备ID,然后替换设备ID的模板。

2.3 消息可靠性保证

虽然长连接是TCP传输,但一旦遇到TCP状态异常、客户端接收逻辑异常(比如设备更新固件后接收模块有Bug,或者APP升级版本后引入了接收模块的Bug),或者服务器宕机,传输中的消息就会丢失。

为了保证传输消息可靠,知乎设计了类似MQTT的QoS1的回执和重传功能,网关下发消息前先缓存一份数据,收到客户端回执则删除消息,超时未收到回执则判断客户端状态并尝试重发,直到客户端收到消息且回执。

当服务器业务流量很大时,每条消息都要回执的方式效率较低,所以知乎提供了基于消息队列的接受和发送方式(批量收发),这种方式我没有考虑过,值得参考。知乎明确提到设计通讯协议时参考了MQTT规范,保持了和MQTT协议一定程度的兼容,便于直接使用MQTT的各种语言平台版本开源客户端二次开发,降低业务方接入成本。

2.4 具体架构和集群设计

知乎的架构由四个主要组件组成:

(1)接入层:使用OpenResty实现,负责连接负载均衡和会话保持。OpenResty是目前很火的一个服务端组件,我还不太了解,极客时间上有教程,而且也被人推荐过,很值得学习。如果不用OpenResty,可以采用云负载均衡器(腾讯、百度、阿里、AWS等)+ Netty,或者自己用HaProxy+Keepalive+Netty做,都是可行的方案。

(2)长连接Broker:部署在容器中,负责协议解析、认证鉴权、会话管理、发布订阅逻辑

(3)Redis存储:持久化会话数据

(4)Kafka消息队列:分发消息给Broker或后端业务

知乎的设计很清晰,主要考虑的点是:

(1)可靠性

知乎采用了经典的存储和计算分离的思路,计算由Broker负责,会话数据存储由Redis负责,逻辑简单,职责清晰

(2)水平扩展能力

知乎通过Kafka广播Publish消息,每个Broker都会收到所有客户端的Publish消息,这样Broker就无状态了(因为Publish不需要指定Broker),这种思路被很多MQTT Broker使用过,比如MqttWk用Redis做广播、Moquette用Hazelcast分布式缓存做广播,保持Broker的简单性,Broker就可以用容器无限水平扩展了。这样做是有一个问题的,不止我们考虑到过,评论里也有人评论。那就是这样的水平扩展只能扩展客户端数量承受能力,不能扩展消息吞吐能力。具体地说,我们确实可以无限扩展机器安装Broker,这样就能够连接更多的客户端;但由于是采用广播的形式,每台机器对Publish消费的能力是有上限的,而消费的又是全量消息(不管该Broker上是否有客户端订阅,都要消费一次Publish消息,然后查询一次订阅树,然后丢弃掉),所以随着客户端数量上升,消息量会不断上升,单台机器的消费能力并不能通过水平扩展增加(只有垂直扩展才可以,比如增加内存、CPU等),所以消息吞吐成为整个系统的瓶颈。

如何解决呢,作者也进行了回复:

也就是说,Broker保持和设备-Topic的关系,一个后端组件保持Broker-Topic的关系,并去消费全量Publish消息,根据订阅路由到指定的Broker:

如果你看过EMQ源码,就知道EMQ就是这样实现的,分发的订阅信息称为路由表,连接的订阅信息称为订阅树。在我看来,如果没有特殊需求,初期直接广播就好,不必这么复杂,因为做定向路由有很多难点,比如路由表应该如何根据订阅树来更新。

(3)依赖组件成熟度

尽量不要自己写组件,用成熟的库和框架。

三、知乎网关架构实现

3.1 接入层

接入层主要做两件事:

(1)负载均衡:每个Broker上的连接数尽可能均衡

(2)会话保持:去掉SSL,保持长连接

知乎之前用的是四层负载均衡,根据IP进行Hash,这样做后发现有两个坑:

(1)分布不够均匀。大部分源IP是大型局域网NAT出口,上面的连接数多,导致Hash集中到少量几个Broker上去

(2)不能准确标识客户端,当移动客户端掉线切换网络,就可能无法连接回原来的Broker了

最后使用了Nginx的preread机制实现七层负载均衡,用客户端的唯一标识来进行一致性Hash,这样随机性更好,保证网络波动也能正确路由。具体来说,Nginx的preread可以在接受连接时指定预读取连接的数据,通过解析preread buffer中客户端发送的第一个报文中的客户端标识,然后用这个标识进行一致性Hash拿到固定Broker实例。

3.2 Kafka的选型理由

所有Publish的数据通过Kafka广播给其他Broker或者后端服务,这样做的好处在于:

  • 减少长连接Broker内部状态,让Broker能够无压力扩容
  • 用消息队列削峰,避免突发性的上行下行消息压垮Broker
  • 业务交互大量使用Kafka传输数据,降低业务方对接成本

前面已经解释了这样广播可以让Broker无状态;削峰是Kafka这类消息队列的一个重要作用,比如当一个片区断电/断网,然后重新来电/来网,很容易引起一波海量Publish冲垮服务器,用Kafka能让这些Publish消息囤积起来,后端服务慢慢去消费掉;业务上大多用Kafka传递数据,很多MQTT Broker也都在对接MQ方便后端业务处理,几乎所有MQTT Broker都支持Kafka,所以用Kafka没毛病。

3.3 发布消息流程

Broker根据路由配置(后端服务产生或运维人员配置,声明自己需要哪些Topic,通常是通配符主题)将消息发布到Kafka Topic,也会根据订阅配置(客户端产生或运维人员配置,通常是精确主题)消费Kafka数据,将消息下发给客户端。一共有四种场景:

(1)消息路由到Kafka Topic,但不消费,单纯的数据上报场景(肯定是有后端服务消费后处理的,通常是日志信息的上报)

(2)消息路由到Kafka Topic,也被消费,普通的即时通信场景(端到端消息,不经过处理)

(3)直接从Kafka Topic消费并下发,用于推送场景

(4)消息路由到一个Topic,然后从另一个Topic消费,用于消息需要过滤的场景(其实这个场景更像MQTT,所谓的Message Processing Worker类似于Rule Engine的作用)

这里很容易有一个疑问,知乎这篇文章的底部也有人提问,那就是Kafka根本不能承受客户端数量级的Topic啊。实际上这是两个概念:Kafka Topic是和业务绑定的,有多少业务就有多少Kafka Topic,例如ruleengine.message;客户端Topic(比如MQTT Topic)则是客户端主动订阅的,通常是和规定的通信流程有关,并且包含客户端标识,例如device/{clientId}/writelog,客户端的Topic和客户端的数据是合起来作为Kafka的数据的。

Kafka通常在上百Topic后性能就会大幅度下降,通常业务是不会达到上百Kafka Topic的。

3.4 订阅

订阅数据都会存储两份,一份是【clientID→Topic】的映射,这属于Session的一部分;另一份是【Topic→clientId(Subscription)】的映射,这是属于订阅树的部分。知乎的客户端都是精确订阅,所以不需要订阅树(订阅树的概念参考Mosquitto),直接用了HashMap来做。知乎实践中发现,订阅和取消订阅都会操作这个HashMap,而HashMap的全局锁冲突很严重,所以他们利用clientID作为Key,Hash到数百个HashMap上去,大大降低了冲突,提升了整体性能。这个方法是我之前从未考虑过的,非常有参考价值。

3.5 会话

如果是QoS1消息,则先缓存消息到Session(Redis队列),等客户端回送确认ACK后,再将其从Redis队列中删除。有关如何存储Session这块,文中也提到业界的方案:

(1)内存维护队列,如EMQ。当扩容和缩容时这块数据没法跟着迁移。EMQ的做法是在连接阶段当发现别的Broker上有未过期Session,则将所有请求代理过去,不用迁移数据,但这块数据会在Broker宕机后丢失。

(2)集群中分布式内存存储,如Moquette/HiveMQ,用集群分布式内存存储,当一个Broker宕机后,或者客户端掉线切换Broker后,仍然能够访问到Session数据。缺点是实现复杂度高。

(3)统一的存储层存储。采用统一的存储层存储,缺点是需要维护一个存储集群,这对于千万级连接级别的架构来说不是问题。

知乎正是采用了第三种方案,他们有专门的团队维护Redis集群。

3.6 滑动窗口

在MQTT协议中也有滑动窗口(Inflight Window)的概念。当QoS消息量较大,或者客户端处理缓慢,回送ACK可能会很慢,如果发完一条等确认后再发另一条,相当于串行执行,效率低下,因此可以同时下发一批消息,然后分别确认这些消息,并行处理。滑动窗口中的消息就是下发后未确认的消息,通过一个阈值限制下发未确认消息量,像一个窗口一样。

并行会有顺序问题,但由于滑动窗口是队列,且通信是TCP连接,只要连接正常、客户端正常,就不会发生业务消息乱序的问题。

在这里还有一个经典选择:MQTT协议规定,当客户端超时未ACK,需要服务端重发未确认的QoS1消息,何时重发有两种方案,一种是定时重发,一种是等下次客户端连接再重发。知乎选择的是后者,因为TCP基本保证了消息的可靠性,所以很少会出现由于链路问题导致未ACK的情况;消息的去重由客户端来保证,这也是常见的做法。

四、总结

知乎提供了一整套长连接方案,基本和MQTT Broker的设计思路一致,在做MQTT Broker时可以参考。长连接网关几乎是一个所有云平台的硬需求,终于有大厂肯开源这些方案了,其中提到的比如HashMap做成多个避免并发锁的冲突等经验非常值得学习。长连接的TCP配置方面还有很多坑,希望以后能通过实践积累更多经验。


转载请注明出处http://www.bewindoweb.com/263.html | 三颗豆子
分享许可方式知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
重大发现:转载注明原文网址的同学刚买了彩票就中奖,刚写完代码就跑通,刚转身就遇到了真爱。
你可能还会喜欢
具体问题具体杠