单体架构 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 系统之核心业务功能实现

相关推荐
棕生8 天前
单体架构 IM 系统之 Server 节点状态化分析
单体架构·im系统·无状态化·长轮询消息收发逻辑·网络编程模型
棕生13 天前
单体架构 IM 系统核心业务功能实现
单体架构·im系统·im业务功能·信箱模型
棕生18 天前
单体架构的 IM 系统设计
技术选型·单体架构·im系统
冰 河10 个月前
自己手写了一套高性能分布式IM即时通讯系统,出去面试嘎嘎聊,都把面试官整不会了!
分布式·微服务·面试·程序员·im系统
忆梦九洲10 个月前
微服务概述之单体架构
微服务·云原生·架构·单体架构·mvc模型