crossoverJie 关注
手机版

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

  1. 云栖社区>
  2. crossoverJie>
  3. 博客>
  4. 正文

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

技术小能手 2018-09-25 15:18:33 浏览50942 评论2

摘要: 前言 首先迟到的祝大家中秋快乐。 最近一周多没有更新了。其实我一直想憋一个大招,分享一些大家感兴趣的干货。 鉴于最近我个人的工作内容,于是利用这三天小长假憋了一个出来(其实是玩了两天)。 先简单说下本次的主题,由于我最近做的是物联网相关的开发工作,其中就不免会遇到和设备的交互。

前言

首先迟到的祝大家中秋快乐。

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

鉴于最近我个人的工作内容,于是利用这三天小长假憋了一个出来(其实是玩了两天)。


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

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

所以本次分享的内容不但可以满足物联网领域同时还支持以下场景:

  • 基于 WEB 的聊天系统(点对点、群聊)。

  • WEB 应用中需求服务端推送的场景。

  • 基于 SDK 的消息推送平台。

技术选型

要满足大量的连接数、同时支持双全工通信,并且性能也得有保障。

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

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

最终的架构图如下:

0eb02f86610cc6f94c5d28c85485b5327a1e3fbc

现在看着蒙没关系,下文一一介绍。

协议解析

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

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

因此我觉得最好是在满足业务需求的情况下定制自己的私有协议,在我这个场景下其实有标准的物联网协议。

如果是其他场景可以借鉴现在流行的 RPC 框架定制私有协议,使得双方通信更加高效。

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

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

简单实现

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

注册鉴权

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

就像你使用微信一样,第一步怎么也得是登录吧,不能无论是谁都可以直接连接到平台。

所以第一步得是注册才行。

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

客户端将这个 token 也保存到本地,今后的每一次请求都得带上这个 token。一旦这个 token 过期,客户端需要再次请求获取 token。

鉴权通过之后客户端会直接通过 TCP长连接到图中的 push-server 模块。

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

保存通道关系

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

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

这点和之前 SpringBoot 整合长连接心跳机制 类似。

51979adc375c035fbe411f3f3ff360c9792b344d

同时为了可以通过 Channel 获取到客户端唯一标识(手机号码),还需要在 Channel 中设置对应的属性:


  1. public static void putClientId(Channel channel, String clientId) {

  2.    channel.attr(CLIENT_ID).set(clientId);

  3. }

获取时手机号码时:


  1. public static String getClientId(Channel channel) {

  2.    return (String)getAttribute(channel, CLIENT_ID);

  3. }

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


  1. String telNo = NettyAttrUtil.getClientId(ctx.channel());

  2. NettySocketHolder.remove(telNo);

  3. log.info("客户端下线,TelNo=" +  telNo);

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

消息上行

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

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

所以我们得进行区分,来做不同的处理;这就和客户端协商的协议有关了。

  • 可以利用消息头中的某个字段进行区分。

  • 更简单的就是一个 JSON 消息,拿出一个字段用于区分不同消息。

不管是哪种只有可以区分出来即可。

消息解析与业务解耦

消息可以解析之后便是处理业务,比如可以是写入数据库、调用其他接口等。

我们都知道在 Netty 中处理消息一般是在 channelRead() 方法中。

d07743f4117ebaa58c65e14678e07578cb59dbbe

在这里可以解析消息,区分类型。

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

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

所以非常有必要将消息解析与业务处理完全分离开来。

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

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

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

这样不同的业务、不同的开发人员只需要实现这个接口同时实现自己的业务逻辑即可。

伪代码如下:

144c6098fd753e75aaa42fa54bb652e720d3ad7c

f1403106cf16a690c64a8694bcddb853f26ebdca

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

https://github.com/TogetherOS/cicada

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

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

消息下行

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

这时的流程是:

  • A 将消息发送给服务器。

  • 服务器收到消息之后,得知消息是要发送给 B,需要在内存中找到 B 的 Channel。

  • 通过 B 的 Channel 将 A 的消息转发下去。

这就是一个下行