消息推送系统的设计与实现
所有真理的成长都需要经过3个阶段:首先是遭到无情地嘲笑,然后是激烈地反对,最终被当成理所当然所接受。
弹幕系统的技术挑战
技术复杂度
系统调用的瓶颈
以一个直播间为例,假设在线人数为100W,每秒发送的弹幕数量为1000条,据此可以推算出单个直播间的吞吐量要达到100W1000条 = 10亿条/秒,把问题延伸到N个直播间,则系统的吞吐量为 N10亿/秒。
根据经验值,Linux系统在处理TCP网络调用的时候大概每秒只能处理100W左右的包,这么看的话发送一条弹幕就达到了单机的处理能力上限,这是第一个难点。
解决:假设在线人数为100W,推送1条消息就达到了系统极限,假设推送100条消息仍旧使用单机处理,如何优化呢?很简单:将100条消息合并成1条消息进行发送,这样对于100W人在线的系统推送吞吐量固定为每秒100W次。当然合并消息不可能无限大,当超过一定阈值之后,TCP/IP层会进行大包拆分,此时底层实际包频就会超过每秒100万次,再次到达系统调用的极限。
锁瓶颈
在推送的时候,需要遍历所有的在线连接,这些连接通常放在一个集合里。遍历100万个连接去发送消息,肯定需要花费一个可观的时间,而在推送期间客户端仍旧在不停的上线与下线,所以这个集合是需要上锁做并发保护的。可见,遍历期间上锁的时间会非常长,而且只能有一个线程顺序遍历集合,这个耗时是无法接受的。
解决:在做海量服务架构设计的时候,一个很有用的思路就是:分而治之。既然100万连接放在一个集合里导致锁粒度太大,那么我们就可以把连接通过哈希的方式散列到多个集合中,每个集合有自己的锁。当我们推送的时候,可以通过多个线程,分别负责若干个集合的推送任务。因为推送的集合不同,所以线程之间没有锁竞争关系。而对于同一个集合并发推送多条不同的消息,我们可以把互斥锁换成读写锁,从而支持多线程并发遍历同一个集合发送不同的消息。其实操作系统管理CPU也是分时的,就像我们的推送任务被拆分成若干小集合一样,每个集合只需要占用一点点的时间片快速完成,而多个集合则尽可能的利用多核的优势实现真并行。
CPU瓶颈
一般客户端和服务器是基于JSON协议进行通信,给每个客户端推消息的时候需要对消息进行JSON Encode。当在线连接比较少(比如1万)而推送消息比较频繁(每秒10万条)的情况下,我们可以计算得到每秒要json encode编码的次数是:10000 * 100000 = 10^9次。即便我们提前对10万条消息做json encode后,再向1万个连接做分发,那么每秒也需要10万次的编码。JSON编码是一个纯CPU计算行为,非常耗费CPU,我们仍旧面临不小的优化压力。
解决:其实当我们通过消息合并的方式减少网络系统调用的时候,我们已经完成了对sys cpu的优化,操作系统用来处理网络系统调用的CPU时间大幅减少。但是user CPU需要我们继续做优化,我们如果在每个连接级别做json encode,那么1条消息就会带来100万次encode,是完全无法接受的性能。因为业务上消息推送分2类,一种是按客户端关注的主题做推送,一种是推送给所有客户端。基于上述特点,我们可以把消息合并动作提前到消息入口层,即把近一段时间所有要推往某个主题、推给所有在线的消息做消息合并成batch,每个batch可能包含100条消息。当1个batch塞满后或者超时后,经过对其进行一次json encode编码后,即可直接向目标客户端做遍历分发。经过消息合并前置,编码的CPU消耗不再与在线的连接数有关,也不再直接与要推送的消息条数有关,而是与打包后的batch个数有关,具有量级上的锐减效果。
单机架构的瓶颈
- 维护海量长连接会花费不少内存
- 消息推送瞬时会消耗大量CPU资源
- 消息推送瞬时带宽高达400
600MB(46Gbits,一般的服务器网卡是千兆网卡只能跑到100MB),带宽是主要瓶颈!
HTTP2支持连接复用,用作RPC性能更佳,例如google的gRPC。
上面的这个架构定向向某个用户推送的时候需要将逻辑层的消息广播到网关层,一般可以在逻辑服务器后面加一层session层,记录用户在哪个gateway上,从而减少不必要的广播。
负载均衡设备为什么不存在性能瓶颈?负载均衡可以配置多个,前端通过DNS控制。
pull or push
拉模型比较简单,客户端定时轮询获取最新的数据,但是拉模式存在以下弊病:
- 如果数据更新频率低,则大多数请求是无效的
- 在线数量多,则服务端的查询负载很高(每秒百万次请求,相当于DDOS攻击,几乎不可能实现)
- 无法满足时效性
综上所述,推模型比较靠谱。
WebSocket协议
通讯流程
客户端和服务器首先要完成一次握手,握手的本身是基于HTTP调用完成的:客户端首先发起一个HTTP请求到服务端,这个请求的特殊之处在于其在header中带了upgrade字段告诉服务器想升级成websocket协议,服务器收到之后就会给客户端发送一个握手的确认switching(允许向websocket协议转换),协议升级后会继续复用HTTP底层的socket完成后续通信,服务器和客户端可以进行全双工通信。
message底层被分成多个Frame帧传输,我们编程的时候只需关注message即可,至于发送的消息会不会被切分成小的帧是由协议和库本身去做。
抓包观察
服务器代码如下:
1 | const WebSocket = require('ws'); |
客户端
1 |
|
注意GET请求头中有个Upgrade字段,值为websocket,响应头中也有个Upgrade字段,值为websocket,表示服务器允许客户端进行服务升级,经过这一轮握手协议之后后续就是基于websocket通信了。返回码为101。websocket的底层最小传输单元是Frame,见下图: