背景

jike-io 是即刻基于 socket.io 构建的一个实时通信基础设施。目前客户端上的所有实时通信服务都是建立在其基础上,涵盖了私信、消息通知、用户反馈、活动页小游戏等诸多组件。

在目前即刻的实时通信设计里,我们的实时通信只是为了让服务端主动推送消息给客户端,客户端不会主动通过 websocket 发送消息。由于几乎我们所有需要发送消息的请求都会有一定业务逻辑在,而这个业务逻辑我们并不希望 websocket 连接层(jike-io)去处理,所以我们仍旧采用传统 HTTP 请求的方式去发送请求。至于之后是否需要推送消息给用户,由服务端调用 jike-io 的接口进行实现。

在客户端层面,每一个在线用户都会向服务端建立一个 websocekt 连接,后端会为每一个用户建立一个单独的 room 。所有需要通知到该用户的消息都会使用该 websocket 连接进行推送。这种设计相较于针对不同场景建立不同的room的方案,带来的好处是无论需求如何变化,我们的 room 数目永远是和在线用户数一致了,避免个别复杂需求导致 room 数目暴涨。而具体消息类型我们通过自己定义数据格式来进行鉴别。

我们的整套方案是完全依赖于 socket.io 的,期间也遇到了不少大大小小的坑。有些其实是我们自己的场景与其设计初衷不是非常吻合导致的,还有一些算是它的设计缺陷。

在讨论上述问题之前,我们需要去弄明白的一个事情是,socket.io 到底背后做了哪些事情。

socket.io 的设计与实现细节

socket.io 是什么

socket.io 是一个非常流行的实时通信框架, 在 Github 上已经积累了 43574 个 star 。它在开源软件里可以算是一个非常产品化了的软件。拥有相对良好的生态,对于许多功能的封装也很体贴,从移动端到 Web 再到服务端都有比较完整的实现。

socket.io 并不等同于 websocket 框架,它在运行平台不支持 websocket 的时候能够自动回退到 long poll 的方式建立实时通信。同时也实现了断线重连机制。还封装了一套 namespace && room 的代码层概念。在分布式方面,socket.io 支持多种 adapter 作为 backend 。话虽这么说,但目前看上去可以用的且被人广泛使用的也只有 redis 作为 backend 的 adapter 。所以以下讨论建立在 socket.io-redis 基础之上。

socket.io 如何实现分布式

当我们有多个 socket.io 节点时,我们需要一个 sticky load balancing 将过来的请求分配到不同的 socket.io 节点上建立持久连接并订阅他们需要的 channel 。当其中一个连接向某个 channel 发送消息时,所有节点需要知道这件事情并检查自己有没有订阅该 channel 的连接,有的话需要将消息发送出去。

这种设计的代价是,虽然我们在部署层次上实现了分布式,但每个节点还是需要知道我这个系统里流通的所有消息。因为彼此都无法明确知道哪个节点上有订阅了我这个消息的连接。当我们消息量达到一定数量的时候,这个网络传输的开销就会非常巨大。后面会细讲这个事情。

socket.io 内部细节

socket.io 有一个叫 namespace 的概念, 它只是一个用来区分不同发送事件的程序概念, 多个 namespcae 都是共用同一个连接。每个 namespace 下还可以建立多个 room , 一般用于指明实际发送消息的目的地。假设我们现在有一个 namespace 叫 /xxx 。每个 socket.io 节点一启动都会订阅以下几个 channel :

  • psubscribe : /socket.io#/xxx#* : 模式订阅发送到该 namespace 下的所有 room 里的消息, 事件是 pmessageBuffer
  • subscribe : /socket.io-request#/xxx# , 订阅多节点间发送同步信息请求的 channel,事件是 messageBuffer
  • subscribe : /socket.io-response#/xxx#, 订阅多节点接受同步信息请求响应的 channel, 事件是 messageBuffer

之前谈到每一个连接的发送消息请求都需要各个节点知道并且检查自己的连接是否需要接受该消息。这里的多节点通信就是使用了 /socket.io-request#/xxx#/socket.io-response#/xxx# 这两个 channel 。

在 redis adapter 里有以下函数:

  • .onmessage : 将消息打包后调用 broadcast 函数
  • .broadcast : 广播消息。
    • 如果这个消息是通过别的节点发来的,则不向其它节点再传递
    • 如果是本地连接发起的消息, 默认会在 /socket.io#/xxx/#{room}# 中 pub 当前消息(即向其它节点广播了一个消息)

之后调用 socket.io-adapter 里的 .broadcast 向本地连接发送消息。

  • .onrequest : 将需要所有节点配合查询的操作发送给每个节点,等待每个节点查完以后,将事件 pub 到 /socket.io-response#/xxx# channel 中。
  • .onresponse : 各个节点查询完毕后被 pub 他们各自的响应体到该 channel 。每次拿到都会将 request.msgCount++ , 并且检查改request的msgCount 是否等于 request.numsub , 是的话说明处理完了
  • .clients : 首先会调用 pubsub numsub 命令查看当前 requestChannel 下有多少订阅者(一般即节点数目),然后将整个 request 请求体塞进 /socket.io-request#/xxx# channel去询问其它节点。

遇到的问题与挑战

瞬时高峰压力

当遇到一些突发热点信息的时候,由于有操作系统推送存在,有时候会突然进来数倍的用户,而每当用户激活 app 都会触发 websocket 连接请求,这个时候后端就会有比较大的连接压力。

nodejs 瓶颈

由于 socket.io 是用 nodejs 写的,而 nodejs 本身就是单线程的,虽然 socket.io 有 nodejs cluster 的支持,但就我们本身集群的部署方式来看,我们并不是特别想用这个功能,而且这个功能并不能完全解决我们的问题。所以我们非常需要开多个节点来提供服务。但是扩容节点也会带来其它问题。

节点扩容压力

前面谈到 socket.io 的实现每个节点都会订阅全量的消息, 所以当我们扩容一倍的节点数时,redis 的负载也会增加一倍甚至更多,同时网络传输也会翻倍。

判断用户是否在线

我们私信侧有一个需求是,当判断该用户 websocket 连接不在线时,通过操作系统推送发送推送。问题出在只有 socket.io 集群内的某个节点自己是知道用户是否在线这件事情的,早期时候是通过每次都调用 .clients 方法取得所有用户列表来做的,但是显然这种做法没法扩展。后来我们通过监听 connect 事件在 redis 里 set 一个 key ,监听 disconnect 移除 key 。这种做法遇到的一个问题是,当一个应用 crash 了或者机器自己挂掉了的时候,disconnect 事件的代码可能并没有来得及被调用,导致一直认为这个用户在线而丢失了系统通知。

目前我们是通过对 key 设置了过期时间来尽可能减轻这个问题导致的失效时长,但总体来说这不是一个非常优雅的方案。

redis 扩展性问题

redis 虽然本身单机性能非常强劲,但是它归根结底但单线程的,规模大了肯定会遇到瓶颈。虽然 redis 自己目前有了 cluster 的实现,但总体来讲,redis cluster 的设计只能算是单机 redis 的一个补丁,并不是一个分布式 k-v 数据库的优雅实现。不过如果真的需要使用 redis cluster ,在不改动代码的情况下,也没法使用 .cleints 这种需要多节点配合的远程调用请求,因为 redis cluster 不支持 pubsub numsub 命令。这个命令其实无非就是获得当前订阅者,正常时候就是节点数,所以你可以非常简单地通过别的方式去获取,这个问题倒也是能够解决。

redis cluster 的 pub/sub 实现和 socket.io 其实很像,每个 redis 节点同样需要知道所有 publish 的消息。无非是每个 redis 节点的连接数少了。但是在 socket.io 这个事情上我们会发现,本身我们向 redis 节点建立的连接并不多,所以我很怀疑这种方案是否真的能够对性能有提升。或许有那么一点,但是整个方案会给人一种”凑合着用”的感觉。

架构改进

观察上面这套方案,我们发现真正的矛盾在于: 我们无法定位到消息的接受者在哪个节点上。只要能够解决了这个问题,我们就不再需要每个节点都订阅所有消息,也不需要不同节点之间的消息同步通信。而一旦不需要这两点,那么我们可以直接移除 redis 。这样也不会面临 redis 上面的问题。

许多人产生的一个误解是认为 socket.io 的 “pub/sub” 功能是 redis 提供的,但事实上 redis 只会为他们提供了多节点信息同步通信的功能,真正的 “pub/sub” 是每个 socket.io 节点自己在维护和处理。

由于我们永远都是向一个用户发送消息,如果任何一个请求进来先通过 load balancer 将其 userId hash 到一个 socket.io 节点上建立连接,之后我们发送消息的时候,都只需要经过同样的 hash 定位到它的节点然后直接发送消息过去就行了。判断用户是否在线也变得非常轻量级。

这个方案还有一个问题在于,我们的这个 hash 算法首先必须是精确定位到节点的,再者,当我们需要加一个节点的时候,即便使用了一致性 hash 还是会导致有些 userId 被 hash 到了它并不存在的节点上。因此每当用户连接时都需要将用户ID和访问节点注册到一个注册表中,每次发送消息都从里面去取。当节点变化时,并不会影响已有的用户,新连接也能无感知被移动到新的节点上。

总结

socket.io 是一个非常不错的开源产品,它各个组件间非常解耦,方便使用者进行各种层次的定制化。但一件比较吊诡的事情是,即便它坐拥四万多 star 却依然缺少一个活跃的生态社区,也没有一个完善的文档讲它的实现原理。虽然它的组件化设计可以让社区轻松实现定制化需求,但事实上社区去真正实现这些需求的人却微乎其微。个人的一个可能性猜测是因为当一个公司体量到了需要考虑扩展性的时候,可能都会偏向于实现自己的通信协议。而 socket.io 目前的架构加上 redis 优秀的单机性能表现本身足以支撑一个中小型产品的体量使得在其上做针对性技术改造的人并不多。