目录

前言

一、通讯协议

二、编程语言

三、数据库选型

四、 用户状态维护

五、方案

方案一、 定时器方案

方案二、时间轮方案

总结文中关键


前言

先直接抛出业务背景

有一款游戏,日活跃量(DAU)在两千左右,虽然 DAU 不高,但这两千用户的忠诚度非常高,而且会持续为游戏充值;为了进一步提高用户体验,继续增强用户的忠诚度,老板想要在该款游戏中引入聊天功能,同时探索和验证游戏用户对 IM 的需求和依赖度。IM 需要在两周后上线,如果你是这个 IM 项目的架构师,带着两名经验尚欠的程序员,你如何设计并落地该 IM 系统?

业务背景并不复杂,简单总结一下:

  1. 用户规模小:DAU 在两千左右,同时在线人数高峰期不到200;

  2. 开发人员少:一名架构师加两名程序员;

  3. 开发时间短:一周开发加一周测试,只有两周时间。

这种情况下,研发策略通常是:怎么简单就怎么做,怎么快就怎么来!

所以对该 IM 系统采用【单体架构】的方式进行设计,见下图。

前端是运行安卓系统和 IOS 系统的移动设备,游戏 APP 内嵌 IM 的客户端,由前端同学负责开发。

后端是 Server 节点,通过多进程多机器部署的方式,避免 “单点”;这个地方需要注意:【单体架构】并非 “单点架构” ,单体架构仍然是分布式架构的一种,通过集群的方式提供高可用和高吞吐的服务;对多个 Server 节点的访问通过 Nginx 来做反向代理;Server节点由后端同学负责开发。

存储部分包括数据库和缓存,数据库中分别创建 “消息表”、“离线消息表”、“联系人表” 和 “用户表”;缓存用来记录用户的在线信息;数据库和缓存由 DBA 同学负责维护。

单体架构的系统,最大的优势就是在项目前期开发简单、部署简单、测试简单、运维简单,开发同学几乎可以将所有的注意力全部放在业务逻辑上,实现真正的【快速落地】。

下面分别讨论一下关键的技术选型:前端与后端的通讯协议、后端的编程语言、数据库选型。

一、通讯协议

前端与后端之间的通讯协议,有四种选择,见下表。

IM 系统通常会选择 “长连接” 类型的协议;但是 WebSocket 协议因为刚推出不久,成熟度不高;TCP 协议属于传输层协议,较为复杂,如果没有丰富的 TCP 网络编程经验的话,在研发时间非常紧张的情况下,建议不要选择 TCP。

所以,这里我们选择最简单和最容易落地的协议—Http;对于编程经验尚欠的应届生来讲,Http编程也不会有太大难度。

分析到这里,相信大家肯定有这样的疑惑:Http 是短连接的无状态协议,如何进行消息的即时通讯呢?难道是通过客户端周期性的轮询访问吗?是的,同时在线人数只有几百的情况下,客户端周期性轮询是完全没有问题的,况且Server是多节点部署,完全可以 Cover 客户端周期轮询的压力。

二、编程语言

Server 端编程语言,也有四种选择(公司内部正在使用的技术栈),见下表。

C++、Java、PHP、Go 四门编程语言都有非常成熟的并发编程模型,这里我们选择公司和团队最熟悉的语言—Go,使用最熟悉的语言才会带来更高的编程效率和更快的问题解决速度。

三、数据库选型

数据库选型有三种选择,分别是关系型 SQL 数据库—MySQL、非关系型 NoSQL 数据库—MongoDB、已经新兴的NewSQL 数据库—TiDB,见下表。

在该单体架构的 IM 系统中,数据库需要提供强事务能力,来保证业务的完整逻辑;同时,数据库需要做到更低成本的运维;这里我们选择满足需求和容易运维的数据库—MySQL。

四、 用户状态维护

用户状态是什么状态呢?就是用户的 “在线” 和 “离线” 状态。很多同学可能会有疑惑,不是基于 “长连接协议” 的客户端才会有 “在线” 和 “离线” 这一回事吗?http 是无状态化的短连接,难道也有 “在线” 一说?是的,客户端在线与否,其实与通讯协议并不是强相关的,协议仅仅是传递数据的方式而已,甚至我们用 http 比用 tcp 更能精准表达出用户的状态。

通过 http 协议表达用户的在线状态,这一块技术实现应该非常成熟了;大家不妨思考一下,当我们登录163邮箱,把浏览器关闭,10分钟后再次访问 163 邮箱,是不需要重新登录的;这就是 http 实现用户在线状态的关键。

单体架构 IM 系统实现用户状态维护的核心逻辑,见下图。

  • 登录

    • 客户端向 server 端发送 http 登录请求;

    • 因为客户端是嵌入在游戏 APP 中运行的,游戏会有登录逻辑,所以 server 只需要调用游戏侧的登录服务进行鉴权校验即可;

    • 登录成功后,server 向 redis 中写入 这样一个 kv 的 session 数据,并设置该 session 的有效期 10秒;type 描述客户端的类型,cmd 描述客户端请求server的接口,time描述写记录的时间。

后续,客户端访问 server 端其他所有接口时,server 读 redis ,如果对应的 session 不存在,说明用户未登录或登录已失效,需要重定向引导客户端重新登录。

  • 心跳

    • 客户端向 server 端周期性(2秒)发送 http 心跳请求;

    • server 延长 redis 中对应 session 的有效期,并修改 session 的 cmd 和 time 属性。

  • 登出

    • 客户端向 server 端发送 http 登出请求;

    • server 直接删除 redis 中对应的 session 数据。

在该单体架构的 IM 系统中,对用户状态的维护,就是对 redis 缓存中 session 数据的维护;在客户端访问 server 其他接口时,server 也会对 session 的有效期和 cmd、time 属性进行修改。

五、 点对点消息收发

消息收发是 IM 系统最核心的功能;基于 http 协议的单体架构 IM 系统的 “点对点消息收发“ 逻辑见下图。

  • 消息收发

    • client1 向 server 端发送 http 消息请求;

    • server 端向数据库中分别写入 “离线表” 和 “云消息表”;(离线表和云消息表在同一个数据库中,通过数据库保证其事务性)

    • client2 向 server 端周期性(2秒)发送 http 拉取消息请求;(拉取消息复用心跳请求)

    • server 端从数据库 “离线表” 中读取 client2 相关记录后返回;

    • client2 再次发送拉取消息请求时,携带上次已经成功拉取的消息msgid,server 端删除 “离线表” 中相关记录。

整个消息的收发逻辑并不复杂,【发消息】和【收消息】动作完全由客户端触发,server 端被动响应即可;我们把这样的消息收发模型叫做 【信箱模型】。很明显,信箱模型的实现非常简单,缺点是消息的及时性不高,取决于客户端的心跳动作。

在整个消息的收发逻辑中,有两个细节点需要注意:“离线表” 通常在消息接收方离线时存储其消息,而在该实现逻辑中, server 端不管 client2 是否在线都会直接将消息持久化在 “离线表” 中, 这样处理的原因在于防止 clien2 没有及时拉取消息而造成消息丢失,提高了消息的可靠性;再一个,当 client2 从 “离线表” 中拉取消息时,不能立刻将其删除,必须在下次拉取时进行删除,这也是消息可靠性的体现。

五、 云消息

所谓 “云消息”,指存储在云端的消息,这样的消息可以被反复拉取,用来查看历史记录和实现消息漫游;在上述的消息收发逻辑中,每次客户端发消息时,server 端都会在 “云消息表” 中保存一条记录,所以客户端随时随地都能实现对云消息的读取,见下图。

  • 云消息

    • client 向 server 端发送 http 拉取历史消息请求;

    • server 端从云消息表中读取相关记录返回。

五、方案

我们知道,http 是短连接协议,即一次客户端请求和服务端回复后,连接就断开了;在不更换协议(更换协议代价很大)的前提下,我们对其进行优化:服务端接收请求到回复客户端的时间,完全是可以控制的。描述到这里,很多同学应该已经非常清楚了,即:将短连接的 http 访问优化为 http 长轮询方式,通过 http 长轮询方式模拟出 “长连接” 的效果。http 长轮询见下图。

http-client 向 http-server 发出请求,http-server 拿到请求后,会立刻 hold 住该请求不返回;http-server 返回响应需要满足下面任何一个条件:

  1. 超过了一定时间,比如15秒,即超时了;(该条件减少了无效的 http 请求)

  2. 在超时之前产生了该 http-client 的数据。(该条件提高了消息的实时性)

http-client 收到响应后,会立刻再次向 http-server 发出请求,重复上述过程。

在单体架构 IM 系统的服务端,只需改造 http 部分,增加一个【http 长轮询】的插件即可大大提高消息的实时性,改造成本低,见效快!那么 【http 长轮询】插件应该如何实现呢?我们分别介绍 “定时器” 和 “时间轮” 两种实现方案。

方案一、 定时器方案

定时器方案非常容易理解,即针对每一个 http 客户端,当服务端接收到请求后,就开启 15秒(假设超时时间是 15秒)的定时器;15秒内若产生了消息,则立刻返回,否则就等15秒后超时返回。我们基于 Go 语言代码进行描述,如下。

chTimeout := make(chan struct{}, 1)//定义 “超时管道”go func() {time.Sleep(time.Second * POLLING_TIMEOUT)//定时器15秒超时fmt.Printf("INFO | [heartHandler] timeout")chTimeout <- struct{}{}//超时后,向“超时管道” 中写入元素}()select {case <-chTimeout://从 “超时管道” 中读数据fmt.Fprint(w, "nonthing")case msg := <-chPushMsg://从 “消息管道” 中读数据bs, _ := json.Marshal(msg)fmt.Fprint(w, string(bs))}

通过 Go 语言代码实现定时器方案非常简单!

在 Go 语言中有一个类似于 “IO 多路复用” 的用法,即通过 select-case 语句实现对多个管道的监听,哪个管道先有了数据,就执行哪个 case 语句。

基于此,我们定义了两个管道,一个是用于 15 秒定时器超时的 “超时管道”,一个是传输消息的 “消息管道”;“超时管道” 在一个独立的协程中,由 “定时器” 控制,超时后向 “超时管道” 写入一个元素数据 (即 struct{},内容不重要,有数据即可表示超时)。select-case 语句非常巧妙地帮助我们实现了 要么15秒后超时返回,要么15秒内有消息即可返回的选择情况。

我们分析一下这个定时器实现方案:服务端需要针对每一次的 http 请求,分别启动一个 “定时器”;”定时器“ 在本质上是一个计算脉冲的计数器,达到设定值之后,通过软中断方式向 CPU 发起中断请求;当 http 客户端并发请求增大之后,服务端同时运行的 “定时器” 也会增多,于是软中断也增多,CPU 会经常性的停下手头工作去处理中断请求,CPU的工作效率会大大降低。那么,在 http 客户端数量不断增多的时候,如何进行优化呢?

下面的时间轮方案可以非常优雅地解决这个问题。

方案二、时间轮方案

在时间轮实现方案只需要一个每秒走一格的定时器即可,其核心思想是将同一秒内超时的所有客户端进行批量处理;见下图。

在该时间轮实现方案中,需要准备三个数据结构:

  1. 一个作为 “时间轮” 的循环队列,该时间轮的指针,每秒钟走一格,走一圈是一个完整的超时周期(图中超时时间是 13秒);

  2. 一个用户维度的 map,该 map 的 key 是用户 uid,value 是时间轮指针所指向的时间刻度;

  3. 一个时间轮时间维度的 map,该 map 的 key 是时间轮的刻度,value 是这个时间刻度时所有发起 http 请求的 uid 列表。

当客户端发出 http 请求到服务端时,服务端将用户和当前时间刻度信息分别写入到上述的两个 map 中;在 13 秒超时之前,如果产生了用户的消息,则从上述两个 map 中删除用户和时间刻度信息;时间轮当前指针每走一格所指向的时间刻度,该时间刻度对应的用户列表就是 13 秒前发出 http 请求的用户列表,这些用户就是超时的客户端,需要超时返回,即返回空的 http 响应。

这样描述可能比较抽象,我们举一个例子:

  1. 假设当前时间轮指针指向了当前时间刻度 2,此时 有三个客户端分别是 101、102、103 发出 http 请求到服务端,服务端需要在 第一个 map 中分别写入 , ,,在第二个 map 中写入 ;

  2. 三秒后,时间轮指针指向了当前时间刻度 5,此时产生了用户 102 的消息,服务端需要先从第一个 map 中删除元素 (同时记录下时间刻度 2,方便后续操作),再从第二个 map 中删除 102 的记录,删除后的map为 ;

  3. 九秒后,在时间轮指针指向当前刻度 2 时,此时第二个 map 中,key 是 2 的所有的uid列表,即 [101, 102],就是所有超时的客户端列表,需要超时返回。

时间轮实现方案,通过一个定时器实现了对同一秒内超时的所有客户端的批量处理。

基于 http 长轮询方式实现的 IM 系统的单体架构中, server 节点还是无状态化的吗?所谓 “无状态化” 节点,是指进程在内存和硬盘中没有独立的数据;很明显,不同的 server 节点会 hold 住不同客户端的 http 请求,也就是不同的 server 节点中会存储不同客户端的数据, server 节点是有状态化的;此时,点对点的消息发送逻辑肯定需要进行调整。

大家可以先思考几个问题:

  1. 在 http 长轮询模式下, server 节点是有状态的,如何实现 server 节点的高可用呢?

  2. 客户端 x 发消息给 y,如果 x 和 y 访问的是不同的 server 节点,应该如何处理呢?

  3. 在 http 长轮询模式下,怎样判断消息接收方是否在线呢?

我们直接给出在 http 长轮询模式下,消息点对点的发送流程;以客户端 x 发消息给客户端 y 为例,如下:

  • 客户端 x 向 server 端发送 http 消息请求;

  • server 首先将消息直接落库,分别写 “云消息表” 和 “离线表”;

  • 然后 server 访问缓存,获取消息接收方 y 的在线状态,若 y 不在线,则整个流程结束;

  • 如果消息接收方 y 在线,通过访问缓存获取 y 连接的是哪一个 server 节点;

  • 如果 y 和 x 连接的同一个 server 节点,则 server 将该消息通过 http 长轮询返回给客户端 y;

  • 如果 y 连接的是另一个 server 节点,此时需要当前 server节点将消息推送到目标 server 节点;

  • 最后目标 server 节点将消息通过 http 长轮询返回给客户端 y。

在上述流程中,有两个地方需要特别注意:

  1. 客户端每一次发起 http 长轮询请求,相当于一次心跳,表示用户的在线状态,需要在缓存中记录客户端的在线数据;在 http 短轮询模式中,缓存中记录的 session 数据是 map ,在 http 长轮询模式中,需要记录客户端请求的是哪一个 server 节点,所以 session 类型为 map。

  2. 不管消息接收方在线与否,server 节点接收消息后,都需要写 “离线表”,这样设计的原因是为了提高消息的可靠性;因为即使用户 “在线”,在 http 长轮询返回时,客户端有可能接收不到消息,同时,在一次完整的 http 长轮询请求的间隙中,消息都是有丢失的可能的,所以持久化 “离线表” 是可靠性的保证;因此,在每一次 http 长轮询请求中,都需要访问 “离线表”,一是删除客户端已经收到的消息,二是从 “离线表” 中获取还未收到的消息。

在 http 长轮询模式下, server 节点是有状态的,那么其高可用如何保证呢?这个问题很容易解决:首先 server 节点肯定要集群化部署,然后由 反向代理 nginx 转发请求到 server ;nginx 通过配置实现客户端ip的会话保持,即将相同的客户端请求始终转发到固定的 server 节点;当 server 节点挂掉之后,nginx 将请求转发到其他 server 节点即可,服务仍将持续提供,只需变更缓存中客户端状态信息即可。

单体架构 IM 系统,从架构到设计,从协议到逻辑,其关键点都进行了 一 一 分析;最后,我们简单聊一下 server 的整体设计,server 通过 Go语言进行了实现,见下图。

  1. 主协程,不处理任何的业务逻辑,用于接收外部信号,如关闭程序等;

  2. 子协程,用于接收客户端连接,针对每一个客户端连接,子协程都会生成两个协程来维护该连接,即:每一条连接会有一个独立的协程组来维护(该协程组中有两个协程,一个用于读,一个用于写);

  3. 连接管理器,实现对所有连接的管理,从连接中读取请求交由业务逻辑模块处理;

  4. 业务逻辑模块,实现核心的业务逻辑,包括:登录、登出、心跳、发消息等;

  5. 在线用户管理器,维护连接当前 server 节点所有的客户端;如果消息接收方在当前 server 节点,在线用户管理器通过 管道(chan)将消息传输给连接管理器中消息接收方的连接;

  6. 通讯协议,是公共协议定义,由【连接管理器】【业务逻辑模块】【在线用户管理器】共同引用。

关于 “每一条连接会有一个独立的协程组来维护”,是 Go 语言通用的高效网络编程模型,见下图。

  • 客户端与服务端建立连接时,在服务端其实创建了一个 socket (即 fd 或句柄);

  • 然后为该 socket 生成一个协作组,该协程组包括两个协程:协程1-1,负责对 socket 进行读;协程1-2,负责对 socket 进行写;这两个协程,一个读一个写互不影响,高效协作;

  • 当需要向客户端写消息时,不管是当前socket 请求的数据,还是从其他 socket 中读取的数据,必须通过协程组的管道(channel) 作为入口,然后协程1-2会从 channel 中读取数据然后写入到 socket 中。

总结文中关键

1、业务背景:用户规模小、开发人员少、开发时间短;

2、研发策略:怎么简单怎么做,怎么快怎么来;

3、 IM单体架构:前端(APP)+ Server + 数据库

单体架构最大的优势就是在项目前期开发简单、部署简单、测试简单、运维简单。

4、技术选型:

选择最简单和最容易落地的协议—Http,

选择公司和团队最熟悉的语言—Go,

选择满足需求和容易运维的数据库—MySQL。

5、“信箱模型” 由客户端主动向服务端发送请求,服务端被动响应即可;该模型实现简单,但作为 IM 系统来说,消息的实时性不高;

6、 基于 “信箱模型” 的单体架构 IM 系统,用户的在线状态通过在 redis 中保存有有效期的 session 来实现,并通过客户端的周期心跳实现 session 的持续有效;

7、 基于 “信箱模型” 的单体架构 IM 系统,发消息的逻辑是发送方向 “离线表” 和 “云消息表” 写入消息记录,收消息的逻辑是接收方从 “离线表” 中读取消息记录;云消息表用来实现消息的历史记录读取和消息漫游。

8、基于 http 周期轮询方式的 “信箱模型”,消息的实时性不高,可优化为 http 长轮询方式,通过 http 长轮询模拟出 “长连接” 的效果;

9、http 长轮询有两种实现方案:定时器方案和时间轮方案;

10、Go 语言实现的定时器方案,通过 select-case 语句实现了对多个管道的多路复用监听,达到了随时产生消息随时返回或超时返回的目的;定时器方案适用于客户端数量较少的情况;

11、时间轮方案实现了对同一秒内所有超时客户端的批量处理,该方案需要三个数据结构:循环队列,map, map。

12、基于 http 长轮询方式实现的 IM 系统的单体架构中, server 节点是有状态的;

13、基于 http 长轮询发消息流程:消息到达 serer 后,先落库;若消息接收方在当前 server 节点,直接返回,否则需要将消息推送到目标 server 节点;

14、 基于 http 长轮询方式实现的 IM 系统,缓存中需要记录客户端连接的是哪一个 server 节点;

15、 在 http 长轮询模式中,不管消息接收方在线与否,server 节点接收消息后,都需要写 “离线表”;

16、 Go 语言通用的高效网络编程模型:每一条连接会有一个独立的协程组来维护;协程1-1,负责对 socket 进行读;协程1-2,负责对 socket 进行写。