【自研项目之分布式IM】04. 深入服务路由组件

1,786 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情

上一节讲解了核心服务各自的职责,本节继续对push-service的路由组件做深入的探讨。本章篇幅虽然较长,但容易理解。

1. Socket层用户数据缓存Userinfo

讲之前先简单了解下客户端和Socket服务建立新连接时,需要上报到Redis缓存中的数据。如图所示

userCache.png

思考下图中第2步都需要缓存什么信息?

图中第2步写入用户信息到Redis缓存,先从目的分析,目的是使路由组件能拿到该用户所在的Socket服务的ip+port地址。 答案不就有了:

  1. 用户ID
  2. Socket的ip、port
  3. 其他关键信息,后续讲解Socket服务时会补充。
// 用户信息缓存
public class Userinfo {
    private String uid;
    private ZoneDTO zone; // Socket服务地址
    
    private static final class ZoneDTO {
        private String ip;
        private Integer port;
    }
}

2. 路由组件运作流程

路由组件其实就是从Redis缓存中获取用户的Socket服务的ip+port,然后根据ip+port发起远程调用,将消息发送给Socket服务即可。如图:

router_socket.png

// 这里定义一个push-service与Socket服务通讯的消息结构体,方便后续讲解
public class Message {
    private String msgId; // 消息唯一ID
    private int msgType; // 消息类型
    private String content; // 内容
    // 推送消息默认指定admin,否则为发送消息的用户id
    private String from = "admin";
    // 接收消息的用户id
    private String to = "userB"; 
}

这样一看还不得大吃一惊:真TM简单。主流程确实简单,但作为面试嘛,总得需要点什么来吹吹。

3. 路由组件的优化

当一个简单的功能被放大到100、1000倍流量时就变成一个复杂问题了。下面通过例子来说说高并发下遇到的问题和解决方案。

3.1 取个推送的栗子

假设运营需要给所有符合条件用户发送1条推送,用户数量为100万。按照上图推送的流程工作的话会面临着什么样的问题?

假设对应的Socket服务器有20台,一台4H8G的服务器建立5万用户连接。100万用户刚好。

首先会面临2个100万的调用:

  1. 第1步路由组件读取100万次Redis获取用户的Userinfo缓存(获取ip+port)。
  2. 第2步push-service和Socket服务需要推送数据100万次(gRpc调用)。

发送1条推送总共需要200万次的调用,乍一看200万好像也没多少,那如果用户基数再放大10倍或者推送的数据不是1条而是100条,又或者几十上百万在线用户的直播间呢,那就有点夸张了。这时候就需要优化系统了。怎么优化?还记得上面说过的“从目的出发寻找答案”,现在的目的就是怎么减少这200万次的调用。

这也是某些APP限制群人数的一个点,群人数变多技术压力也会对应增大。

优化手段1:单播转广播
我们已经知道推送到Socket服务需要从缓存获取用户连接的ip+port然后进行通信,这是P2P方式。那如果我们不关心用户具体连接在哪台Socket服务(总会在这20台Socket服务里),直接把消息广播到所有Socket的服务,那是不是就不需要第1步了?如图所示:

router_广播.png

下面根据上图分析具体的实现:

  1. 第1步:100万的用户通过分页从数据库中每次获取1000条,总共需要1000次。
  2. 第2步:Message消息每1000个用户合并为一条消息,同时发送到20台Socket服务,总共需要1000*20=2万次调用。(关注下面代码的to字段)
// Message的代码就变成:
public class Message {
    private String msgId;
    private int msgType;
    private String content;
    // 推送消息默认指定admin,否则为发送消息的用户id
    private String from = "admin";
    // 接收消息的用户id
    private String to = "userA,userB...user1000";
}

这一下子从200万次调用优化成2万次,需要注意的是Message的报文变大,要根据中间件合理设置。

而合并的Message消息则在Socket层去处理,Socket服务拿到Message数据,根据to字段解析出1000个用户,直接查询本地的SocketChannelMap发送,如果命中不了的用户说明不在这台Socket服务上,就直接忽略。

见多识广的朋友估计想到,这不就是和Redis的订阅发布一样吗?确实原理是一样,但优化还未结束,让我们继续深入探讨。

之前说过Redis的方式所有的订阅者全量消费,不支持P2P推送。如果这里不是20台,而是200台Socket服务器呢?思考下有什么优化手段。

优化手段2:组播

上个优化是从单播转广播,适合Socket服务集群不多的情况。那大集群下怎么解决呢?没错就是标题上的组播。组播就是分组广播,既然机器多,那就给他们分个组,闻到内味没?Redis Cluster、一致性Hash集群方案、分库分表熟不熟悉?联想到了吗?

分组广播原理就是给集群下的Socket服务添加一个类似GroupId的东西,比如上面例子中的20台服务,可以划分成2个组,每组10台,分别对应GroupId-1, GroupId-2。既然分完组,那就需要能标识用户连接的是哪个组,那么上面的Userinfo缓存就需要改造一下,用户建立新连接还需要把Socket服务的GroupId缓存起来,代码如下:

// 用户信息缓存
public class Userinfo {
    private String uid;
    private ZoneDTO zone; // Socket服务地址
    
    private static final class ZoneDTO {
        private String ip;
        private Integer port;
        private String groupId; // 新增一个Socket服务所在的GroupId
    }
}

再回到3.1中的推送例子做进一步的优化。

说实话组播用推送例子不太合适,应该使用大群或直播间的例子来讲的。重要的是思想

  1. 第1步:100万的用户通过分页每次获取1000条,总共需要1000次。这里需要优化将GroupId合并进来,减少独立查询GroupId。
  2. 第2步:新增一个对用户GroupId合并打包的逻辑,每查询出1000个用户时根据GroupId分组,等到分组满1000条再发送,总结来讲就是增加缓冲区与超时发送。看个例子:
    1. 第一次查询1000个用户,根据GroupId分组后,GroupId-1有300个用户,GroupId-2有700个用户。
    2. 由于1中一次查询2个组都不够1000个用户,那就进行下一次查询,GroupId-1有400个用户,GroupId-2有600个用户。
    3. 2中分组用户与1中合并时发现GroupId-1的用户数满足1000个,就可以针对GroupId-1的集群组发送消息。
    4. 这里需要增加一个超时发送,比如1s后发送,否则等待的时间太久,实时性会很差。

根据分组广播的总调用次数平均估算值为:1000 / 2 * 10 = 5000次。最坏情况下所有用户都在同一个GroupId:1000 * 10 = 1万次。比广播调用保守减少2倍。

router_组播.png

总结

  1. 路由组件推送数据支持多种模式
    1. P2P单聊模式
    2. 集群全广播模式:直播应用全频道通知、APP全量升级通知等
    3. 组播:对全广播的优化:大用户量直播间、超大群聊等。
  2. 组播模式下,需要优化Userinfo缓存,合并GroupId到缓存中,减少增加查询。
  3. 客户端新建连接获取GroupId时注意负载均衡,可以使用一致性Hash或轮询等,合理的设计能避免热点分组数据。
  4. 组播模式下对每个分组应做好监控,控制每台服务的连接数,但出现突增流量,只需要增加对应分组的Socket服务器即可。
  5. 路由组件其他优化:高优先级消息推送、消息过期丢弃等有兴趣的留言探讨。

本章对路由组件介绍完了,并针对路由组件做了优化,其中涉及到的用户缓存和Socket层消息发送会在后续讲解,下一章介绍另一个核心服务Socket服务。文章若有错误或建议,欢迎留言~