公司此前有一个简单的文章订阅业务,但是采用的是定时拉取的模式,周期比较长,时效性不佳。
于是考虑做一个长连接服务,主动把新产生的文章推送下去。
因为是web场景,所以优先考虑成熟的websocket协议,很多编程语言都有成熟的服务端开发框架。
技术核心难点
系统调用的瓶颈
假设有100万人在线,那么1篇文章会导致100万次推送,10篇文章就是1000万次推送。
根据经验值,linux系统在处理TCP网络系统调用的时候,大概每秒只能处理100万左右个包。
这么看的话,推送1篇文章就已经到达了单机的处理能力极限,这是第一个难点。
锁瓶颈
我们在推送时,需要遍历所有的在线连接,通常这些连接被放在一个集合里。
遍历100万个连接去发送消息,肯定需要花费一个可观的时间,而在推送期间客户端仍旧在不停的上线与下线,所以这个集合是需要上锁做并发保护的。
可见,遍历期间上锁的时间会非常长,而且只能有一个线程顺序遍历集合,这个耗时是无法接受的。
CPU瓶颈
一般客户端与服务端之间基于JSON协议通讯,给每个客户端推送消息前需要对消息做json encode编码。
当在线连接比较少(比如1万)而推送消息比较频繁(每秒10万条)的情况下,我们可以计算得到每秒要json encode编码的次数是:10000 * 100000 = 10^9次。
即便我们提前对10万条消息做json encode后,再向1万个连接做分发,那么每秒也需要10万次的编码。
JSON编码是一个纯CPU计算行为,非常耗费CPU,我们仍旧面临不小的优化压力。
解决技术难点
系统调用瓶颈
仍旧假设100万人在线,那么单机极限就是每秒推送1篇文章,这会带来每秒100万次的网络系统调用。
如果我们想推送100篇文章,仍旧使用单机处理,优化的思路是什么呢?
很简单,我们把100篇文章作为一条消息推送,那么仍旧是每秒100万次系统调用。
无论是10篇,50篇,80篇,我们都合并成1条消息推送,那么100万人在线的推送频次就是恒定的每秒100万次,不随着文章数量的变化而变化。
当然,合并消息不可能无限大,当超过一定的阈值之后,TCP/IP层会进行大包拆分,此时底层实际包频就会超过每秒100万次,再次到达系统调用的极限。
锁瓶颈
在做海量服务架构设计的时候,一个很有用的思路就是:大拆小。
既然100万连接放在一个集合里导致锁粒度太大,那么我们就可以把连接通过哈希的方式散列到多个集合中,每个集合有自己的锁。
当我们推送的时候,可以通过多个线程,分别负责若干个集合的推送任务。
因为推送的集合不同,所以线程之间没有锁竞争关系。而对于同一个集合并发推送多条不同的消息,我们可以把互斥锁换成读写锁,从而支持多线程并发遍历同一个集合发送不同的消息。
其实操作系统管理CPU也是分时的,就像我们的推送任务被拆分成若干小集合一样,每个集合只需要占用一点点的时间片快速完成,而多个集合则尽可能的利用多核的优势实现真并行。
CPU瓶颈
其实当我们通过消息合并的方式减少网络系统调用的时候,我们已经完成了对sys cpu的优化,操作系统用来处理网络系统调用的CPU时间大幅减少。
但是user CPU需要我们继续做优化,我们如果在每个连接级别做json encode,那么1篇文章就会带来100万次encode,是完全无法接受的性能。
因为业务上消息推送分2类,一种是按客户端关注的主题做推送,一种是推送给所有客户端。
基于上述特点,我们可以把消息合并动作提前到消息入口层,即把近一段时间所有要推往某个主题、推给所有在线的消息做消息合并成batch,每个batch可能包含100条消息。当1个batch塞满后或者超时后,经过对其进行一次json encode编码后,即可直接向目标客户端做遍历分发。
经过消息合并前置,编码的CPU消耗不再与在线的连接数有关,也不再直接与要推送的消息条数有关,而是与打包后的batch个数有关,具有量级上的锐减效果。
架构考量
集群化gateway
经过上述的设计后,我们可以用GO来实现一个高并发的websocket长连接网关(gateway)。
gateway可以横向部署构成集群,前端采用LVS/HA/DNS负载均衡。
当我们采用gateway集群化部署之后,当我们想要推送一条消息的时候,需要将消息分发给所有的gateway进程。
逻辑服务logic
因此,我实现了一个Logic服务,它本身是无状态的,负责2个核心功能:
1,为业务提供了HTTP接口提交推送消息,因为作为推送系统的推送频次不会太高。而且业务方在推送前会有很多业务逻辑判定,最终通过HTTP完成推送,相信是一个比较易于接入的方式。
2,负责将推送消息向各gateway进程做分发,在这里采用了HTTP/2作为RPC协议(GRPC就是HTTP/2)保障了单连接的高并发能力,同时保障了不同gateway之间的故障隔离,互不影响。
认证服务
目前尚未引入websocket连接的登录认证,今后存在向特定用户推送的需求时,需要实现认证服务。
认证服务独立于gateway与logic,可以称作Passport。
客户端首先基于公司帐号体系向passport完成登录,得到一个自验证的Login token(例如JWT),然后再发起gateway连接。
gateway验证token后完成uid的识别,整个过程不需要与其他业务系统额外交互,当然也可以增加额外的调用服务验证。
那么当logic希望向特定uid推送消息的时候,当前架构下仍旧必须将消息分发给所有gateway,由gateway找到uid对应的连接。但是这无疑造成了浪费,因为uid可能只连在某一个gateway上,对其他gateway毫无意义。
session会话层
未来可以考虑增加会话层,记录uid与gateway之间的连接关系,这样logic经过session层反查找到uid所在的gateway,完成定向推送即可。
会话层可以做一层单独的服务,采用纯内存的方式保存uid与gateway的关系。
因为gateway宕机等原因,可能导致我们无法及时剔除掉线的会话,所以gateway与session之间应该定时传输健康客户端的心跳信息。
当然,也可以简单粗暴的将会话层用redis集群取代,仅仅提供单一的uid->gateway的反查能力。
我的慕课网课程
我在慕课网录制了《GO实现千万级WebSocket消息推送服务》 ,是一门免费课,大家可以花1小时快速了解一下相关技术。
源代码
项目代码我开源到了github,代码量非常少,所以感兴趣的话不如读一下源码喽。