GO千万级消息推送服务

公司此前有一个简单的文章订阅业务,但是采用的是定时拉取的模式,周期比较长,时效性不佳。

于是考虑做一个长连接服务,主动把新产生的文章推送下去。

因为是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的反查能力。

源代码

项目代码我开源到了github,代码量非常少,所以感兴趣的话不如读一下源码喽。

go-push

我的慕课网课程

我近期在慕课网录制了《GO实现千万级WebSocket消息推送服务》 ,是一门免费课,大家可以花1小时快速了解一下相关技术。

GO千万级消息推送服务》上有11条评论

  1. shenke

    是阈值。
    关于登陆后的连接是在哪个实例上的问题,可以考虑实现一个用户表(或K-V)放在redis中,在连接建立(登陆)后在redis中找到此用户,并将所在实例的标识(或IP)写入.

    回复
    1. yuer 文章作者

      你的这个想法有业务耦合,我建议PUSH和业务尽量解耦。

      PUSH是一个消息通道,业务是围绕在PUSH周边的卫星系统。

      包括认证登录应该独立成一个passport服务,认证后携带JWT等登录token连接IM系统,并在REDIS建立会话关系。

      你提到的用户表我认为不属于PUSH的必要部分,是有办法解耦开的。

      最终的PUSH应该通过开放协议的方式(包括长连接开放协议),向其他业务线开放消息的推/拉通道,提供更加复杂的业务能力。

      回复
  2. jzoom

    我觉得所有关于高性能的系统都可以考虑如下的模型:1、单线程进行任务请求处理和任务分发 2、多工作线程进行实际工作。针对你的并发瓶颈,可以考虑使用单线程拷贝数据到任务线程,这样就不会有锁的问题,即使上下线是针对单个信息上锁,不会有那么大的竞争。但是拷贝数据又会带来内存的使用,可能需要实际评测。

    回复
    1. 匿名

      通过副本减少共享内存,需要考虑内存拷贝的cpu成本。另外,大量内存拷分配,最好引入内存池来缓解碎片问题。

      回复
  3. Pingback引用通告: GO千萬級訊息推送服務 | 程式前沿

  4. dean

    有一个想法, logic和gateway是不是可以解耦,将推送消息向各gateway进程做分发的时候, 往logic push不直接http2推到gateway而是写道消息队列里面, 所有的gateway读取消息队列然后推到client

    回复

发表评论

电子邮件地址不会被公开。