设计一个百万级的消息推送系统

前言

首先迟到的祝你们中秋快乐。java

最近一周多没有更新了。其实我一直想憋一个大招,分享一些你们感兴趣的干货。git

鉴于最近我我的的工做内容,因而利用这三天小长假憋了一个出来(实际上是玩了两天🤣)。github


先简单说下本次的主题,因为我最近作的是物联网相关的开发工做,其中就难免会遇到和设备的交互。算法

最主要的工做就是要有一个系统来支持设备的接入、向设备推送消息;同时还得知足大量设备接入的需求。数据库

因此本次分享的内容不但能够知足物联网领域同时还支持如下场景:编程

  • 基于 WEB 的聊天系统(点对点、群聊)。
  • WEB 应用中需求服务端推送的场景。
  • 基于 SDK 的消息推送平台。

技术选型

要知足大量的链接数、同时支持双全工通讯,而且性能也得有保障。浏览器

在 Java 技术栈中进行选型首先天然是排除掉了传统 IO缓存

那就只有选 NIO 了,在这个层面其实选择也很少,考虑到社区、资料维护等方面最终选择了 Netty。安全

最终的架构图以下:服务器

如今看着蒙不要紧,下文一一介绍。

协议解析

既然是一个消息系统,那天然得和客户端定义好双方的协议格式。

常见和简单的是 HTTP 协议,但咱们的需求中有一项须要是双全工的交互方式,同时 HTTP 更多的是服务于浏览器。咱们须要的是一个更加精简的协议,减小许多没必要要的数据传输。

所以我以为最好是在知足业务需求的状况下定制本身的私有协议,在我这个场景下其实有标准的物联网协议。

若是是其余场景能够借鉴如今流行的 RPC 框架定制私有协议,使得双方通讯更加高效。

不过根据这段时间的经验来看,无论是哪一种方式都得在协议中预留安全相关的位置。

协议相关的内容就不过讨论了,更多介绍具体的应用。

简单实现

首先考虑如何实现功能,再来思考百万链接的状况。

注册鉴权

在作真正的消息上、下行以前首先要考虑的就是鉴权问题。

就像你使用微信同样,第一步怎么也得是登陆吧,不能不管是谁均可以直接链接到平台。

因此第一步得是注册才行。

如上面架构图中的 注册/鉴权 模块。一般来讲都须要客户端经过 HTTP 请求传递一个惟一标识,后台鉴权经过以后会响应一个 token,并将这个 token 和客户端的关系维护到 Redis 或者是 DB 中。

客户端将这个 token 也保存到本地,从此的每一次请求都得带上这个 token。一旦这个 token 过时,客户端须要再次请求获取 token。

鉴权经过以后客户端会直接经过TCP 长链接到图中的 push-server 模块。

这个模块就是真正处理消息的上、下行。

保存通道关系

在链接接入以后,真正处理业务以前须要将当前的客户端和 Channel 的关系维护起来。

假设客户端的惟一标识是手机号码,那就须要把手机号码和当前的 Channel 维护到一个 Map 中。

这点和以前 SpringBoot 整合长链接心跳机制 相似。

同时为了能够经过 Channel 获取到客户端惟一标识(手机号码),还须要在 Channel 中设置对应的属性:

public static void putClientId(Channel channel, String clientId) {
    channel.attr(CLIENT_ID).set(clientId);
}
复制代码

获取时手机号码时:

public static String getClientId(Channel channel) {
    return (String)getAttribute(channel, CLIENT_ID);
}
复制代码

这样当咱们客户端下线的时即可以记录相关日志:

String telNo = NettyAttrUtil.getClientId(ctx.channel());
NettySocketHolder.remove(telNo);
log.info("客户端下线,TelNo=" +  telNo);
复制代码

这里有一点须要注意:存放客户端与 Channel 关系的 Map 最好是预设好大小(避免常常扩容),由于它将是使用最为频繁同时也是占用内存最大的一个对象。

消息上行

接下来则是真正的业务数据上传,一般来讲第一步是须要判断上传消息输入什么业务类型。

在聊天场景中,有可能上传的是文本、图片、视频等内容。

因此咱们得进行区分,来作不一样的处理;这就和客户端协商的协议有关了。

  • 能够利用消息头中的某个字段进行区分。
  • 更简单的就是一个 JSON 消息,拿出一个字段用于区分不一样消息。

无论是哪一种只有能够区分出来便可。

消息解析与业务解耦

消息能够解析以后即是处理业务,好比能够是写入数据库、调用其余接口等。

咱们都知道在 Netty 中处理消息通常是在 channelRead() 方法中。

在这里能够解析消息,区分类型。

但若是咱们的业务逻辑也写在里面,那这里的内容将是巨多无比。

甚至咱们分为好几个开发来处理不一样的业务,这样将会出现许多冲突、难以维护等问题。

因此很是有必要将消息解析与业务处理彻底分离开来。

这时面向接口编程就发挥做用了。

这里的核心代码和 「造个轮子」——cicada(轻量级 WEB 框架) 是一致的。

都是先定义一个接口用于处理业务逻辑,而后在解析消息以后经过反射建立具体的对象执行其中的处理函数便可。

这样不一样的业务、不一样的开发人员只须要实现这个接口同时实现本身的业务逻辑便可。

伪代码以下:

想要了解 cicada 的具体实现请点击这里:

github.com/TogetherOS/…

上行还有一点须要注意;因为是基于长链接,因此客户端须要按期发送心跳包用于维护本次链接。同时服务端也会有相应的检查,N 个时间间隔没有收到消息以后将会主动断开链接节省资源。

这点使用一个 IdleStateHandler 就可实现,更多内容能够查看 Netty(一) SpringBoot 整合长链接心跳机制

消息下行

有了上行天然也有下行。好比在聊天的场景中,有两个客户端连上了 push-server,他们直接须要点对点通讯。

这时的流程是:

  • A 将消息发送给服务器。
  • 服务器收到消息以后,得知消息是要发送给 B,须要在内存中找到 B 的 Channel。
  • 经过 B 的 Channel 将 A 的消息转发下去。

这就是一个下行的流程。

甚至管理员须要给全部在线用户发送系统通知也是相似:

遍历保存通道关系的 Map,挨个发送消息便可。这也是以前须要存放到 Map 中的主要缘由。

伪代码以下:

具体能够参考:

github.com/crossoverJi…

分布式方案

单机版的实现了,如今着重讲讲如何实现百万链接。

百万链接其实只是一个形容词,更多的是想表达如何来实现一个分布式的方案,能够灵活的水平拓展从而能支持更多的链接。

再作这个事前首先得搞清楚咱们单机版的能支持多少链接。影响这个的因素就比较多了。

  • 服务器自身配置。内存、CPU、网卡、Linux 支持的最大文件打开数等。
  • 应用自身配置,由于 Netty 自己须要依赖于堆外内存,可是 JVM 自己也是须要占用一部份内存的,好比存放通道关系的大 Map。这点须要结合自身状况进行调整。

结合以上的状况能够测试出单个节点能支持的最大链接数。

单机不管怎么优化都是有上限的,这也是分布式主要解决的问题。

架构介绍

在将具体实现以前首先得讲讲上文贴出的总体架构图。

先从左边开始。

上文提到的 注册鉴权 模块也是集群部署的,经过前置的 Nginx 进行负载。以前也提过了它主要的目的是来作鉴权并返回一个 token 给客户端。

可是 push-server 集群以后它又多了一个做用。那就是得返回一台可供当前客户端使用的 push-server

右侧的 平台 通常指管理平台,它能够查看当前的实时在线数、给指定客户端推送消息等。

推送消息则须要通过一个推送路由(push-server)找到真正的推送节点。

其他的中间件如:Redis、Zookeeper、Kafka、MySQL 都是为了这些功能所准备的,具体看下面的实现。

注册发现

首先第一个问题则是 注册发现push-server 变为多台以后如何给客户端选择一台可用的节点是第一个须要解决的。

这块的内容其实已经在 分布式(一) 搞定服务注册与发现 中详细讲过了。

全部的 push-server 在启动时候须要将自身的信息注册到 Zookeeper 中。

注册鉴权 模块会订阅 Zookeeper 中的节点,从而能够获取最新的服务列表。结构以下:

如下是一些伪代码:

应用启动注册 Zookeeper。

对于注册鉴权模块来讲只须要订阅这个 Zookeeper 节点:

路由策略

既然能获取到全部的服务列表,那如何选择一台恰好合适的 push-server 给客户端使用呢?

这个过程重点要考虑如下几点:

  • 尽可能保证各个节点的链接均匀。
  • 增删节点是否要作 Rebalance。

首先保证均衡有如下几种算法:

  • 轮询。挨个将各个节点分配给客户端。但会出现新增节点分配不均匀的状况。
  • Hash 取模的方式。相似于 HashMap,但也会出现轮询的问题。固然也能够像 HashMap 那样作一次 Rebalance,让全部的客户端从新链接。不过这样会致使全部的链接出现中断重连,代价有点大。
  • 因为 Hash 取模方式的问题带来了一致性 Hash算法,但依然会有一部分的客户端须要 Rebalance。
  • 权重。能够手动调整各个节点的负载状况,甚至能够作成自动的,基于监控当某些节点负载较高就自动调低权重,负载较低的能够提升权重。

还有一个问题是:

当咱们在重启部分应用进行升级时,在该节点上的客户端怎么处理?

因为咱们有心跳机制,小心跳不通以后就能够认为该节点出现问题了。那就得从新请求注册鉴权模块获取一个可用的节点。在弱网状况下一样适用。

若是这时客户端正在发送消息,则须要将消息保存到本地等待获取到新的节点以后再次发送。

有状态链接

在这样的场景中不像是 HTTP 那样是无状态的,咱们得明确的知道各个客户端和链接的关系。

在上文的单机版中咱们将这个关系保存到本地的缓存中,但在分布式环境中显然行不通了。

好比在平台向客户端推送消息的时候,它得首先知道这个客户端的通道保存在哪台节点上。

借助咱们之前的经验,这样的问题天然得引入一个第三方中间件用来存放这个关系。

也就是架构图中的存放路由关系的 Redis,在客户端接入 push-server 时须要将当前客户端惟一标识和服务节点的 ip+port 存进 Redis

同时在客户端下线时候得在 Redis 中删掉这个链接关系。

这样在理想状况下各个节点内存中的 map 关系加起来应该正好等于 Redis 中的数据。

伪代码以下:

这里存放路由关系的时候会有并发问题,最好是换为一个 lua 脚本。

推送路由

设想这样一个场景:管理员须要给最近注册的客户端推送一个系统消息会怎么作?

结合架构图

假设这批客户端有 10W 个,首先咱们须要将这批号码经过平台下的 Nginx 下发到一个推送路由中。

为了提升效率甚至能够将这批号码再次分散到每一个 push-route 中。

拿到具体号码以后再根据号码的数量启动多线程的方式去以前的路由 Redis 中获取客户端所对应的 push-server

再经过 HTTP 的方式调用 push-server 进行真正的消息下发(Netty 也很好的支持 HTTP 协议)。

推送成功以后须要将结果更新到数据库中,不在线的客户端能够根据业务再次推送等。

消息流转

也许有些场景对于客户端上行的消息很是看重,须要作持久化,而且消息量很是大。

push-sever 作业务显然不合适,这时彻底能够选择 Kafka 来解耦。

将全部上行的数据直接往 Kafka 里丢后就无论了。

再由消费程序将数据取出写入数据库中便可。

其实这块内容也很值得讨论,能够先看这篇了解下:强如 Disruptor 也发生内存溢出?

后续谈到 Kafka 再作详细介绍。

分布式问题

分布式解决了性能问题但却带来了其余麻烦。

应用监控

好比如何知道线上几十个 push-server 节点的健康情况?

这时就得监控系统发挥做用了,咱们须要知道各个节点当前的内存使用状况、GC。

以及操做系统自己的内存使用,毕竟 Netty 大量使用了堆外内存。

同时须要监控各个节点当前的在线数,以及 Redis 中的在线数。理论上这两个数应该是相等的。

这样也能够知道系统的使用状况,能够灵活的维护这些节点数量。

日志处理

日志记录也变得异常重要了,好比哪天反馈有个客户端一直连不上,你得知道问题出在哪里。

最好是给每次请求都加上一个 traceID 记录日志,这样就能够经过这个日志在各个节点中查看究竟是卡在了哪里。

以及 ELK 这些工具都得用起来才行。

总结

本次是结合我平常经验得出的,有些坑可能在工做中并无踩到,全部还会有一些遗漏的地方。

就目前来看想作一个稳定的推送系统实际上是比较麻烦的,其中涉及到的点很是多,只有真正作过以后才会知道。

看完以后以为有帮助的还请不吝转发分享。

欢迎关注公众号一块儿交流:

相关文章
相关标签/搜索