单体架构 IM 系统之长轮询方案设计

在上一篇技术短文(单体架构 IM 系统之核心业务功能实现)中,我们讨论了 "信箱模型" 在单体架构 IM 系统中的应用,"信箱模型" 见下图。

客户端 A 将 "信件" 投入到客户端 B 的 "信箱" 中,然后客户端 B 去自己的 "信箱" 中取回自己的 "信件", "信箱模型" 故此得名。

"信箱模型" 中,所有动作由客户端触发,作为 "信箱" 的服务端被动响应即可; 所以 "信箱模型" 实现逻辑非常简单,但是对于 "信件" 的实时性不高; 客户端 B 想要尽快获取到自己的 "信件",只能周期性地高频请求。我们知道,在 IM 系统中,所有客户端都在高频请求时,无效请求(即没有获取到自己的消息)是非常高的,这对服务器资源和网络资源来说是一种浪费。 在当前单体架构 IM 系统背景下(单体架构 IM 系统之架构设计),如何进行优化呢?

我们知道,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 语言代码进行描述,如下。

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<uid, 时间轮时间>,该 map 的 key 是用户 uid,value 是时间轮指针所指向的时间刻度;

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

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

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

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

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

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

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

最后,对文中关键进行总结:

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

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

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

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

大家思考一下:

在该单体架构的 IM 系统中,http 短轮询方式优化成 http 长轮询方式后,点对点消息发送逻辑,需要调整吗?

(http 短轮询方式的消息发送逻辑参见技术短文:单体架构 IM 系统之核心业务功能实现

相关推荐
ktkiko111 个月前
前后端本地启动
java·项目开发·im系统
棕生3 个月前
分层架构 IM 系统之架构演进
im系统·分层架构·业务分离·mq解耦
棕生3 个月前
单体架构 IM 系统之 Server 节点状态化分析
单体架构·im系统·无状态化·长轮询消息收发逻辑·网络编程模型
棕生3 个月前
单体架构 IM 系统核心业务功能实现
单体架构·im系统·im业务功能·信箱模型
棕生4 个月前
单体架构的 IM 系统设计
技术选型·单体架构·im系统
冰 河1 年前
自己手写了一套高性能分布式IM即时通讯系统,出去面试嘎嘎聊,都把面试官整不会了!
分布式·微服务·面试·程序员·im系统
忆梦九洲1 年前
微服务概述之单体架构
微服务·云原生·架构·单体架构·mvc模型