消息推送系统的设计与实现
所有真理的成长都需要经过 3 个阶段:首先是遭到无情地嘲笑,然后是激烈地反对,最终被当成理所当然所接受。
弹幕系统的技术挑战
技术复杂度
系统调用的瓶颈
以一个直播间为例,假设在线人数为 100W,每秒发送的弹幕数量为 1000 条,据此可以推算出单个直播间的吞吐量要达到 100W * 1000条 = 10亿条/秒,把问题延伸到 N 个直播间,则系统的吞吐量为 N*10亿/秒。
根据经验值,Linux 系统在处理 TCP 网络调用的时候大概每秒只能处理 100W 左右的包,这么看的话发送一条弹幕就达到了单机的处理能力上限,这是第一个难点。
解决:假设在线人数为 100W,推送 1 条消息就达到了系统极限,假设推送 100 条消息仍旧使用单机处理,如何优化呢?很简单:将 100 条消息合并成 1 条消息进行发送,这样对于 100W 人在线的系统推送吞吐量固定为每秒 100W 次。当然合并消息不可能无限大,当超过一定阈值之后,TCP/IP 层会进行大包拆分,此时底层实际包频就会超过每秒 100W 次,再次到达系统调用的极限。
锁瓶颈
在推送的时候,需要遍历所有的在线连接,这些连接通常放在一个集合里。遍历 100W 个连接去发送消息,肯定需要花费一个可观的时间,而在推送期间客户端仍旧在不停的上线与下线,所以这个集合是需要上锁做并发保护的。可见,遍历期间上锁的时间会非常长,而且只能有一个线程顺序遍历集合,这个耗时是无法接受的。
解决:在做海量服务架构设计的时候,一个很有用的思路就是:分而治之。既然 100W 连接放在一个集合里导致锁粒度太大,那么我们就可以把连接通过哈希的方式散列到多个集合中,每个集合有自己的锁。当我们推送的时候,可以通过多个线程,分别负责若干个集合的推送任务。因为推送的集合不同,所以线程之间没有锁竞争关系。而对于同一个集合并发推送多条不同的消息,我们可以把互斥锁换成读写锁,从而支持多线程并发遍历同一个集合发送不同的消息。其实操作系统管理 CPU 也是分时的,就像我们的推送任务被拆分成若干小集合一样,每个集合只需要占用一点点的时间片快速完成,而多个集合则尽可能的利用多核的优势实现真并行。
CPU 瓶颈
一般客户端和服务器是基于 JSON 协议进行通信,给每个客户端推消息的时候需要对消息进行 JSON Encode。当在线连接比较少(比如 1W)而推送消息比较频繁(10W/s)的情况下,我们可以计算得到每秒要 json encode 编码的次数是:10000 * 100000 = 10^9 次。即便我们提前对 10W 条消息做 json encode 后,再向 1 万个连接做分发,那么每秒也需要 10W 次的编码。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 协议
Upgrade 是 HTTP 协议中的一个请求头字段,用于客户端向服务器提出升级当前连接协议的请求。通常情况下,它被用来协商从 HTTP/1.1 升级到其他协议,如 WebSocket、HTTP/2 等。这个机制允许客户端和服务端在不中断现有 TCP 连接的情况下,转换通信协议,从而可以更灵活地支持不同的应用层协议。
通讯流程
客户端和服务器首先要完成一次握手,握手的本身是基于 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,见下图: