📖初探 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,能够实现数据处理 |
-
下面是登录简化的时序图:
-
下面是消息发送的一个时序图:
-
ServerSession: LocalSession 和 RemoteSession实现类。
- LocalSession: 客户端与该服务节点的会话信息(channel)
- RemoteSession: 该服务节点与其他服务节点的会话信息(PeerSender 为 Netty 客户端,向其他服务端节点转发信息)
- 集群中节点上线和下线通知,通过 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 集群下的长连接处理的一个大致流程了。感兴趣的可以再仔细阅读。
🌾本文到此结束。