IM长连接网关 :请求处理模型

avatar
研发 @比心APP

简单介绍

Mercury是公司的一个长连基础建设,支持IM双向通信和信道传输模块。旨在替换云信,为公司在泛娱乐领域探索提供高可用的实时通信技术支持,让业务快跑的同时保障用户良好的体验。

服务端基于Netty框架进行二次开发,支持 TCP 或 WebSocket 建连,使用私有协议进行消息传输,安全高效且节约流量。

请求处理模型

作为长连接网关,Mercury 要支持处理海量的客户端请求,要做到这一点,需要设计一套稳定高效的请求处理模型。

Netty 处理模型

Netty 使用 主从Reactor模型

bossGroup 负责处理 accept 事件 , workerGroup 负责处理 read 、write 事件

其中 ChannelPipeline 是一个双向链表,netty 里面定义了十几种事件,触发之后会顺序调用所有 ChannelHandler 的指定方法,ChannelHandler的调用都是由 workerGroup中的同一个 eventloop 线程执行,不存在线程之间的切换。

这种无锁化的设计,避免了上下文切换,在海量请求的情况下能提供很高的性能。但是也存在风险,如果执行某个 ChannelHandler 出现了阻塞,会拖累这个 eventloop 线程所负责的其他请求。

在实际场景中,ChannelHandler里面一般都会执行一些 IO 操作,比如RPC调用,MQ等,无法保证不会出现阻塞的情况。所以很多高性能的分布式框架中都会使用三层处理模型,额外增加一个业务线程池,将耗时的IO操作放在这个业务线程池里面执行,这样就不会阻塞 workerGroup 中的线程了。

Kafka 三层处理模型


默认配置下的线程数, Acceptor :1 , Processor : 3 , IO : 8

Acceptor线程采用轮询方式将请求公平分发到所有网络线程,防止请求处理的倾斜。

通过这样的设计,kafka 能够支撑海量的网络请求

Mercury 处理模型


整体设计大致上参考了 Dubbo

pipeline 中除了 encode 和 decode ,我只实现了一个 NettyServerHandler

NettyServerHandler 里面使用装饰器模式实现了不同分工的 handler

AccessChannelHandler : 基于 IP 的权限校验,不符合的连接直接拒绝

**CatReportChannelHandler : **cat 数据上报

IdleChannelHandler : 心跳和探测消息、握手等任务处理

DispatchChannelHandler : 线程池分发请求

MercuryServerHandler : 上层的业务数据处理 handler

在上面也提到过,netty 里面的 ChannelHandler 有十几种事件,但对于我们来说,其实只需要关注其中的几种事件。因此,在NettyServerHandler中做了一层映射

channelActiveconnect建立连接
channelInactivedisconnect断开连接
channelReadreceive读取到数据
writesend写入数据
exceptionCaughtcaught异常捕获

NettyChannel 是我对 netty 底层 channel 的封装,业务层直接操作 NettyChannel,无需关心底层channel的处理,屏蔽了netty的使用细节。里面实现了对连接的维护管理以及消息发送的优化

MercuryServerHandler 中会根据请求中的 Command 命令,将当前请求路由到对应的业务 handler 中处理

LoginAuthHandler : 登录认证相关

IMMsgHandler :单聊、群聊等

ChatRoomMsgHandler : 聊天室消息

WhiteboardMsgHandler : 白板消息

ClientPushMsgHandler : 客户端push 类消息

还有一个 HandlerWrapper ,是上面这些业务处理handler 的包装类,主要用于执行 handler 拦截器链,类似于 spring mvc 里面的拦截器,可以在业务处理的前后增加一些额外的处理逻辑

DispatchChannelHandler


这里着重介绍一下 DispatchChannelHandler ,前面说了这个 handler 是用于线程池分发请求的。

一般情况下,其实用 Jdk里面提供的线程池就可以了,比如说 Dubbo ,通过 spi 的方式提供了4种线程池,服务端默认使用 fixed *,*客户端默认使用 cached。但底层还是直接使用的ThreadPoolExecutor

对于 IM 场景下,需要严格保证单个 channel 的处理顺序,但不同channel 之间可以不用保证顺序。

这里我是自己实现了一个 线程池 OrderedChannelExecutor

  1. 每个channel 都会生成一个 channelId (这里并没有使用 Netty里面默认的 id,而是通过一定规则生成的,主要是因为某些业务处理上的需要)

  2. 通过对 channelId 做一致性哈希,将这个channel 中的所有请求都路由到同一个 MpscQueue 里面。(MpscQueue 是 JCTools里面提供的 多生产者单消费者模式的 lock free 队列,能保证并发安全并且性能极高,Netty 底层使用的队列就是这个)

  3. MpscQueue 会从线程池里挑选一个线程去执行任务,只有当前任务处理完成,才会再从队列里 poll 下一个任务执行

  4. 只要MpscQueue 里面有数据,就会一直霸占这个线程,等队列的任务都执行完了,才会将它放回线程池中

目前 MpscQueue 和线程数量是 1:1 ,每个队列都能拿到一个线程,不需要任何等待

设计的时候,没有参考 netty eventloop 那样将 Selector 和线程 强绑定,因为我觉得线程是比较珍贵的资源,生产机器的配置是4核8g,线程多了性能也不一定能提升,而队列相对来说还好,只是耗点内存,项目中在节省内存方面也做了很多优化,大量使用池化技术,8g内存完全足够了,所以队列数量是可以大于线程数量的。

当时想着后面实现一个分级队列,支持队列按照优先级划分,优先级越高则有更高的概率优先执行,优先级低的在系统负载过大时,则允许延迟处理、丢弃或者快速失败 。不过之前压测,单机可以支持1.5w qps ,目前线上还远远没有达到这种量级,就暂时搁置了。

一致性哈希算法


哈希算法使用的是 FNV ,FNV 能快速 hash 大量数据并保持较小的冲突率,它的高度分散使它适用于 hash 一些非常相近的字符串,比如 URL,hostname,文件名,text,IP 地址等。

因为我是对 channelId 进行hash , channelId 中包含了客户端和服务端的IP 信息,所以采用了FNV。而且增加了 虚拟节点,每个真实节点会对应8个虚拟节点,这样最终的hash 分布还算比较均匀。

其他业务场景的话也可以选择其他哈希算法,比如说高运算性能,低碰撞率的 MurmurHash2 ,redis里面就是用的这种。

至于一致性哈希的实现,用 java 是很容易实现的,直接利用 TreeMap 的特性就可以了。站在Doug Lea 大佬的肩膀上,实现起来 so easy ,这里贴下我的实现

Dubbo 里面的负载均衡 LoadBalance 也提供了一致性哈希算法 ConsistentHashLoadBalance,不过它里面使用的是 Ketama 算法 ,实现稍微复杂点。因为其他的哈希算法有通用的一致性哈希算法实现,只不过是替换了哈希方式而已,但 Ketama 是一整套的流程。感兴趣的话也可以去拉下源码,了解它的实现。