初探 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 集群下的长连接处理的一个大致流程了。感兴趣的可以再仔细阅读。

🌾本文到此结束。

相关推荐
爱勇宝11 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒15 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码16 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo17 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志17 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..17 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects18 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰18 小时前
一些基础和问题解决
后端
陈随易18 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN18 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp