初探 Netty 是如何解决 IM 场景集群的长连接

📖初探 Netty 是如何解决 IM 场景集群的长连接

故事还得从一个闲暇的下午说起,和组内的一个老员工聊到了 Netty 的应用场景,诸如 RPC、MQ等,然后提到了 IM。 RPC、MQ一般都是客户端和服务端的长连接, 而 IM,是用户和用户之间的聊天,难道长连接是客户端是客户端吗?

因为这个问题,便有了这篇文章。本文阅读需要有一些 Netty 知识背景。

📕一、方案探讨(必读)

为了更好的理解本文,针对下面几个词语在本文的意思,做一个说明。

术语 描述
Node 表示服务器,服务节点,服务端
channel 特指 Netty 中的 channel ,可以理解为长连接,或者 socket 连接,或者双向通道
客户端 可以理解为终端用户,比如用户 A 向用户 B 发送消息,等同于两个终端的通信

1.1 应用场景

像 PRC、MQ 等中间件都是客户端和服务端建立长连接。 如下图,每一个客户端都维护了一个和所有服务端的长连接,放到客户端的长连接池里面。

1.2 单点场景

在 IM 场景下,有大量的客户端,需要解决的是端到端的通信,本质是所有客户端和服务端建立长连接,服务端做消息的准发,从而实现整个通信过程,因此需要有两个长连接。

为了让场景更容易理解和解释,举例说明:

假如有两人需要通信。 A 向 B 发送消息,A 首先需要跟服务端建立长连接,B 也需要跟服务器建立长连接,而服务端可以通过 userId-> Channel 的映射,管理所有的 channel, 服务端的数据结构可以如下:

Java 复制代码
Map<userId, channel> {
    a=channel
    b=channel
    ....
}

包装一个简单的数据结构,targetUser: 表示给谁发送, sendMsg:消息内容。

A 向 B 发送消息的时候,携带了 B 的用户信息。 服务端通过 B 的 userId, 找到与 B 建立的 channel。

服务端职责:查找 channel; 推送数据,如下所示:

1.3 多点场景

上面这种情况是最简单的情况。服务端只考虑一个节点,没有考虑多节点。

比如 A 和 服务端 Node1 建立长连接,而 B 和服务端 Node2 建立长连接,Node1 和 Node2 是不同的服务端,属于不同进程。

那么是否可以参考 RPC 等中间件的做法,客户端跟每一个服务端都建立长连接, 如下图所示,每一个客户端和服务端建立长连接,每个服务端都缓存一份与客户端的 channel 数据。

这样就能避免 A 和 B 没有和同一个服务端建立长连接的问题。

1.4 集群场景

试想一下:在海量的用户面前,每个服务端都存一份所有用户的 channel, Map<userId,channel> 将非常庞大;实际上是不可取的,存在大量资源浪费。

那么是否可以将 Map<userId,channel> 放到中间件(比如:redis、zk、mysql等)里呢,集群共享一份数据,这样就不会存在浪费了;比如关系放入到 redis,如下图所示:

流程: A 和 Node1 建立长连接, B 和 Node2 建立长连接; 当 A 将消息发送到服务端 Node1, Node1 从中间件找到目标 channel(目标 channel 是 Node2 和 B 客户端的长连接);但是在 Node1 进程里面是没有办法直接使用这个 channel的。

因为它是长连接,需要经过三次握手才能建立的链接,并不是从中间件取出来,直接通过序列化就能使用的!!!

虽然不能直接存 channel,但是我们可以将用户与服务端建立的 channel 这一层关系存储到服务端。再在服务端之间再建立一个长连接。

例如: Node1 将信息转发到 Node2 ,Node2 再将信息推送给客户端 B。

如下所示,红色线条部分,分别表示三个长连接。

1.5 集群方案

服务器之间建立长连接,做消息转发;整个链路上经过 3 个长连接。而存入到中间件的是用户、服务端 channel 之间的关系。

举例:user1 给 user22 发送一条 hello 消息:

  • user1 与 Node1 建有长连接,user22 与 Node2 建有长连接
  • Node1 从存储中间件查找 user22 与 Node2 有长连接
  • Node1 从内存中找到了与 Node2 长连接, 进行消息转发
  • Node2 通过与 user22 的长连接,进行消息推送

1.6 简单小结

本文只针对集群下的长连接做了一个探讨,到这里基本上就结束了,上面也只是一个简单的方案,仅做参考。很多问题并没有涉及,比如下面问题:

  • 同一个用户有多个终端问题
  • 如果 A 向 B 发送消息的时候,B 并没有建立长连接问题
  • 消息的持久化问题 ......

针对这个简单的方案,网上找到了一个开源代码库,也做了一下研究,大致实现也是和上面一致的,感兴趣可以继续阅读下面部分:

📒二、代码研究(选读)

2.1 设计实现

gitee 地址, 疯狂创客圈 IM: 从0开始100w级应用实战 (gitee.com)

这个工程不是我写的,只做学习;有兴趣可以 clone 阅读一下,还不错。

代码中的技术选型:

技术 功能描述
zk 服务节点之间的上线下线,通过消息订阅及时管理服务端节点之间的 channel 信息。可理解为注册和发现
redis 用户与服务端建立的所有 channel 信息

限于精力,本文也只针对服务端的两个核心流程做讲解。 一个登录,一个发送消息。

为了更好理解流程图:先提前对类做一个描述

作用和描述
ChatServer Netty 服务端,启动类
LoginRequestHandler/ChatRedirectHandler 登录处理器类,做一些校验,登录成功后,此 handler 将被移除,同时会加入心跳检查的 handler
SessionManger 1. 管理客户端与本服务端的长连接(本地Map) 2. 将长连接关系存入到 redis. 3. 更新用户的长连接信息(redis)
PeerSender 服务端之间消息的转发类,把自己当成客户端,与其他服务端建立长连接
LocalSession/RemoteSession 会话信息,包装了用户、channel等信息,重写 writeAndFlush,能够实现数据处理
  1. 下面是登录简化的时序图:

  2. 下面是消息发送的一个时序图:

  3. ServerSession: LocalSession 和 RemoteSession实现类。

classDiagram interface ServerSession ServerSession <|-- LocalSession ServerSession <|-- RemoteSession ServerSession : +void writeAndFlush(Object pkg) ServerSession : +String getSessionId() class RemoteSession { + PeerSender peerSender } class LocalSession { + Channel channel }
  • LocalSession: 客户端与该服务节点的会话信息(channel)
  • RemoteSession: 该服务节点与其他服务节点的会话信息(PeerSender 为 Netty 客户端,向其他服务端节点转发信息)
  1. 集群中节点上线和下线通知,通过 zk 实现:

上面的几个点,基本上就包含了服务端的处理过程。到这里基本算是结束了。

如果还有精力和耐心,我摘取了部分关键代码。

2.2 代码分析(有耐心可阅读)

ChatServer:服务端

Netty 模板式的代码,重点关注下面两个 Handler:

  • loginRequestHandler 登录处理器
  • chatRedirectHandler 消息处理器
Java 复制代码
@Service("ChatServer")
public class ChatServer {

    .....
    public void run() {
        ......
        b.childHandler(new ChannelInitializer<SocketChannel>() {
            protected void initChannel(SocketChannel ch) throws Exception {
                // 管理pipeline中的Handler
                .......
                // 在流水线中添加handler来处理登录,登录后删除
                ch.pipeline().addLast("login", loginRequestHandler);
                ......
                ch.pipeline().addLast("chatRedirect", chatRedirectHandler);
                ......
            }
        });
        .....
}

loginRequestHandler 登录处理器

  • LoginProcesser 处理登录逻辑
  • LocalSession 由 channel + user 等信息组成
  • sessionManger 管理所有 channel (客户端与服务端的链接信息)
Java 复制代码
@ChannelHandler.Sharable
public class LoginRequestHandler extends ChannelInboundHandlerAdapter
{
    @Autowired
    LoginProcesser loginProcesser;

    /**
     * 收到消息
     */
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception
    {
        ......
        LocalSession session = new LocalSession(ctx.channel());

        //异步任务,处理登录的逻辑
        CallbackTaskScheduler.add(new CallbackTask<Boolean>()
        {
            @Override
            public Boolean execute() throws Exception
            {
                return loginProcesser.action(session, pkg);
            }
            .....
        }
    }
}
Java 复制代码
public class LoginProcesser extends AbstractServerProcesser
{
    ......
    @Override
    public Boolean action(LocalSession session,
                          ProtoMsg.Message proto)
    {
        // 取出token验证
        ProtoMsg.LoginRequest info = proto.getLoginRequest();
        UserDTO user = UserDTO.fromMsg(info);
        ......
        session.setUser(user);
        /**
         * 绑定session
         */
        session.bind();
        sessionManger.addLocalSession(session);
        ......
        return true;
    }
}

SessionManger 会话服务端

  • 管理客户端与服务端的 channel 信息
  • 将 channel 与客户端(user)信息保存到 redis
  • 通知集群中其他服务节点有新节点加入
Java 复制代码
public class SessionManger
{

    /**
     * 登录成功之后, 增加session对象
     */
    public void addLocalSession(LocalSession session)
    {
        //step1: 保存本地的session 到会话清单
        String sessionId = session.getSessionId();
        // map 保存关系到本地。 session 里面包含了用户与 channel 信息
        sessionMap.put(sessionId, session);
        String uid = session.getUser().getUserId();

        //step2: 缓存session到redis
        ImNode node = ImWorker.getInst().getLocalNodeInfo();
        SessionCache sessionCache = new SessionCache(sessionId, uid, node);
        sessionCacheDAO.save(sessionCache);

        //step3: 通知集群中其他节点有服务端节点加入
        notifyOtherImNodeOnLine(session);
    }

}

ChatRedirectHandler 消息处理

  • 推送消息
Java 复制代码
public class ChatRedirectHandler extends ChannelInboundHandlerAdapter
{
    ......
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception
    {
        FutureTaskScheduler.add(() ->
        {
            //判断是否登录,如果登录了,则为用户消息
            LocalSession session = LocalSession.getSession(ctx);
            if (null != session && session.isLogin())
            {
                redirectProcesser.action(session, pkg);
                return;
            }
           ......
        });
    }
    ......
}

ChatRedirectProcesser 消息处理器

  • 取出接收方的 chatId(目标用户信息)
  • 根据 chatId(userId) 从 redis 找到长连接的关联关系
  • 从关系找到所有服务端之间的长连接,进行消息的转发
Java 复制代码
public class ChatRedirectProcesser extends AbstractServerProcesser {
    
    ......
    @Override
    public Boolean action(LocalSession fromSession, ProtoMsg.Message proto) {
        // 聊天处理
        ......

        // 获取接收方的chatID
        String to = messageRequest.getTo();
        // int platform = messageRequest.getPlatform();
        List<ServerSession> toSessions = SessionManger.inst().getSessionsBy(to);
        if (toSessions == null) {
            //接收方离线
            Logger.tcfo("[" + to + "] 不在线,需要保存为离线消息,请保存到nosql如mongo中!");
        } else {

            toSessions.forEach((session) ->
            {
                // 将IM消息发送到接收客户端;
                // 如果是remoteSession,则转发到对应的服务节点
                session.writeAndFlush(proto);

            });
        }
        return null;
    }
}

SessionManger.inst().getSessionsBy(to) 获取所有转发的服务端长连接

Java 复制代码
public class ChatRedirectProcesser extends AbstractServerProcesser {
    
    ......
    @Override
    public Boolean action(LocalSession fromSession, ProtoMsg.Message proto) {
         .......
        // 获取接收方的chatID
        String to = messageRequest.getTo();
        // int platform = messageRequest.getPlatform();
        List<ServerSession> toSessions = SessionManger.inst().getSessionsBy(to);
        ......
        // 遍历发送
    }
}

RemoteSession

服务器节点之间的消息转发

Java 复制代码
public class RemoteSession implements ServerSession, Serializable
{
   ......

    /**
     * 通过远程节点,转发
     */
    @Override
    public void writeAndFlush(Object pkg)
    {
        ImNode imNode = cache.getImNode();
        long nodeId = imNode.getId();

        //获取转发的  sender
        PeerSender sender =
                WorkerRouter.getInst().route(nodeId);

        if(null!=sender)
        {
            sender.writeAndFlush(pkg);
        }
    }

}

把每个服务端又当成一个客户端,与其他服务器进行通信。

2.3 简单小结

上面这个工程代码做了一个基本实现。基本可理解 IM 集群下的长连接处理的一个大致流程了。感兴趣的可以再仔细阅读。

🌾本文到此结束。

相关推荐
九圣残炎30 分钟前
【springboot】简易模块化开发项目整合Redis
spring boot·redis·后端
.生产的驴1 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
爱学的小涛1 小时前
【NIO基础】基于 NIO 中的组件实现对文件的操作(文件编程),FileChannel 详解
java·开发语言·笔记·后端·nio
爱学的小涛1 小时前
【NIO基础】NIO(非阻塞 I/O)和 IO(传统 I/O)的区别,以及 NIO 的三大组件详解
java·开发语言·笔记·后端·nio
北极无雪1 小时前
Spring源码学习:SpringMVC(4)DispatcherServlet请求入口分析
java·开发语言·后端·学习·spring
爱码少年1 小时前
springboot工程中使用tcp协议
spring boot·后端·tcp/ip
2401_857622669 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589369 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没10 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch11 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j