一、HTML5 - Websocket协议
二、聊天室(IM)系统的设计
- 2.1.使用者眼中的聊天系统
- 2.2.开发者眼中的聊天系统
- 2.3.IM系统的特性
- 2.4.心跳机制:解决网络的不确定性
- 2.5.消息的多终端漫游
三、Go语言实现-聊天(IM)系统
四、参考资料
一、HTML5 - Websocket协议
浏览器支持的socket编程,轻松维持服务端的
长连接
。基于
TCP
可靠传输协议之上的协议,无需开发者关心通讯细节。提供了高度抽象的编程接口,业务开发成本较低。
没有同源限制,客户端可以与任意服务器通信。
协议标识符是
ws
(如果加密,则为wss
),服务器网址就是 URL。
客户端的简单示例
var ws = new WebSocket("wss://echo.websocket.org");
ws.onopen = function(evt) {
console.log("Connection open ...");
ws.send("Hello WebSockets!");
};
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};
ws.onclose = function(evt) {
console.log("Connection closed.");
};
二、聊天室(IM)系统的设计
在整个 IM 系统的实现上深度用到了网络、数据库、缓存、加密、消息队列等后端必备知识。
架构设计中也在大规模分布式、高并发、一致性架构设计等方面有众多成熟的解决方案。
要打造一套“实时、安全、稳定”的 IM 系统,需要深入思考很多个地方,尤其是作为整个实时互动业务的基础设施,扩展性、可用性、安全性等方面都需要有较高的保障。
如果站在一个使用者的角度从直观体验上来看,一个简单的聊天系统大概由以下元素组成:用户账号、账号关系、联系人列表、消息、聊天会话。
- 聊天的参与需要用户,所以需要有一个用户账号,用来给用户提供唯一标识,以及头像、昵称等可供设置的选项。
- 账号和账号之间通过某些方式(比如加好友、互粉等)构成账号间的关系链。
- 你的好友列表或者聊天对象的列表,我们称为联系人的列表,其中你可以选择一个联系人进行聊天互动等 *** 作。
- 在聊天互动这个环节产生了消息。
- 同时你和对方之间的聊天消息记录就组成了一个聊天会话,在会话里能看到你们之间所有的互动消息。
客户端:一般是用户用于收发消息的终端设备,内置的客户端程序和服务端进行网络通信,用来承载用户的互动请求和消息接收功能。
接入服务:可以认为是服务端的门户,为客户端提供消息收发的出入口。
发送的消息先由客户端通过网络给到接入服务,然后再由接入服务递交到业务层进行处理。
主要有四块功能:连接保持、协议解析、Session 维护和消息推送。
业务处理服务:是消息业务逻辑处理层,比如消息的存储、未读数变更、更新最近联系人等。
存储服务:用于进行账号信息、关系链,以及消息的持久化存储。
外部接口服务:通过手机 *** 作系统自身的公共连接服务来进行 *** 作系统级的“消息推送”,通过这种方式下发的消息一般会在手机的“通知栏”对用户进行提醒和展示。
实时性:
短轮询的频率一般较高,但大部分轮询请求实际上是无用的,客户端既费电也费流量;高频请求对服务端资源的压力也较大,一是大量服务器用于扛高频轮询的 QPS(每秒查询率),二是对后端存储资源也有较大压力。
长轮询并没有完全解决服务端资源高负载的问题,服务端悬挂(hang)住请求,只是降低了入口请求的 QPS,并没有减少对后端资源轮询的压力。
假如有 1000 个请求在等待消息,可能意味着有 1000 个线程在不断轮询消息存储资源。
长轮询在超时时间内没有获取到消息时,会结束返回,因此仍然没有完全解决客户端“无效”请求的问题。
随着 HTML5 的出现,全双工的 WebSocket 彻底解决了服务端推送的问题。
TCP 长连接衍生的 IM 协议
除了 WebSocket 协议,在 IM 领域,还有其他一些常用的基于 TCP 长连接衍生的通信协议,如 XMPP 协议、MQTT 协议以及各种私有协议。
可靠性:
ACK 机制中的消息重传:其实也是参考了 TCP 协议的重传机制。
类似的,IM 服务器的“等待 ACK 队列”一般都会维护一个超时计时器,一定时间内如果没有收到用户 B 回的 ACK 包,会从“等待 ACK 队列”中重新取出那条消息进行重推。
消息重复推送的问题:对于推送的消息,如果在一定时间内没有收到 ACK 包,就会触发服务端的重传。
收不到 ACK 的情况有两种,除了推送的消息真正丢失导致用户 B 不回 ACK 外,还可能是用户 B 回的 ACK 包本身丢了。
对于第二种情况,ACK 包丢失导致的服务端重传,可能会让接收方收到重复推送的消息。
针对这种情况,一般的解决方案是:服务端推送消息时携带一个 Sequence ID,Sequence ID 在本次连接会话中需要唯一,针对同一条重推的消息 Sequence ID 不变,接收方根据这个唯一的 Sequence ID 来进行业务层的去重,这样经过去重后,对于用户 B 来说,看到的还是接收到一条消息,不影响使用体验。
设想一下,假设一台 IM 服务器在推送出消息后,由于硬件原因宕机了,这种情况下,如果这条消息真的丢了,由于负责的 IM 服务器宕机了无法触发重传,导致接收方 B 收不到这条消息。
补救措施:消息完整性检查
一致性:是指同一条消息,在多人、多终端需要保证展现顺序的一致性。
时序基准:全局序号生成器;“时序基准”之外的其他误差
消息服务端包内整流:比如发送方的某一个行为同时触发了多条消息,而且这多条消息在业务层面需要严格按照触发的时序来投递。
一个例子:用户 A 给用户 B 发送最后一条分手消息同时勾上了“取关对方”的选项,这个时候可能会同时产生“发消息”和“取关”两条消息,如果服务端处理时,把“取关”这条信令消息先做了处理,就可能导致那条“发出的消息”由于“取关”了,发送失败的情况。
对于这种情况,我们一般可以调整实现方式,在发送方对多个请求进行业务层合并,多条消息合并成一条;也可以让发送方通过单发送线程和单 TCP 连接能保证两条消息有序到达。
但即使 IM 服务端接收时有序,由于多线程处理的原因,真正处理或者下推时还是可能出现时序错乱的问题,解决这种“需要保证多条消息绝对有序性”可以通过 IM 服务端包内整流来实现。
比如:我们在实现离线推送时,在网关机启动后会自动订阅一个本 IP 的 Topic,当用户上线时,网关机会告知业务层用户有上线 *** 作,这时业务层会把这个用户的多条离线消息 pub 给这个用户连接的那个网关机订阅的 Topic,当网关机收到这些消息后,再通过长连接推送给用户,整个过程大概是下图这样的。
但是很多时候会出现 Redis 队列组件的 Sharding 和网关机多线程消费处理导致乱序的情况,这样,如果一些信令(比如删除所有会话)的 *** 作被乱序推送给客户端,可能就会造成端上的逻辑错误。
然后再说一下离线推送服务端整流的过程:
- 首先,生产者为每个消息包生成一个 packageID,为包内的每条消息加个有序自增的 seqId。
- 其次,消费者根据每条消息的 packageID 和 seqID 进行整流,最终执行模块只有在一定超时时间内完整有序地收到所有消息才执行最终 *** 作,否则将根据业务需要触发重试或者直接放弃 *** 作。
通过服务端整流,服务端包内整流大概就是图中这个样子,我们要做的是在最终服务器取到 TCP 连接后下推的时候,根据包的 ID,对一定时间内的消息做一个整流和排序,这样即使服务端处理多条消息时出现乱序,仍然可以在最终推送给客户端时整流为有序的。
- 首先,生产者为每个消息包生成一个 packageID,为包内的每条消息加个有序自增的 seqId。
消息接收端整流:携带不同序号的消息到达接收端后,可能会出现“先产生的消息后到”“后产生的消息先到”等问题,消息接收端的整流就是解决这样的一个问题的。
目前业界比较常见的实现方式比较简单,步骤如下:
1)下推消息时,连同消息和序号一起推送给接收方;
2)接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号就将当前消息追加在会话里;
3)否则继续往前查找倒数第二条、第三条等,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。
安全性:
- 消息传输安全性:存在网络交互的即时消息服务,大多需要通过开放网络来进行消息和信令的传输。
关于消息传输过程被截获、篡改、伪造,我们则利用私有协议和 TLS 的技术,来进行防控。
- 消息存储安全性:由于消息漫游和离线消息等业务需要,大部分即时消息服务会将消息暂存在 IM 服务器端的数据库,并保留一定的时间,对于一些私密的消息内容和用户隐私数据,如果出现内部人员非法查询或者数据库被“拖库”,可能会导致隐私信息的泄露。
- 账号密码存储安全:“单向散列”算法:针对账号密码的存储安全一般比较多的采用“高强度单向散列算法”(比如:SHA、MD5 算法)和每个账号独享的“盐”(这里的“盐”是一个很长的随机字符串)结合来对密码原文进行加密存储。
- 消息内容存储安全:端到端加密:消息内容采用“端到端加密”(E2EE),中间任何链路环节都不对消息进行解密。
- 账号密码存储安全:“单向散列”算法:针对账号密码的存储安全一般比较多的采用“高强度单向散列算法”(比如:SHA、MD5 算法)和每个账号独享的“盐”(这里的“盐”是一个很长的随机字符串)结合来对密码原文进行加密存储。
- 消息内容安全性:针对消息内容的安全性一般都依托于第三方的内容识别服务来进行”风险内容“的防范。
- 消息传输安全性:存在网络交互的即时消息服务,大多需要通过开放网络来进行消息和信令的传输。
支持客户端断线重连:通过“心跳”快速识别连接的可用性,除了可以降低服务端的资源开销,也被用于来支撑客户端的断开重连机制。
对于客户端发出心跳包,如果在一定的超时时间内(考虑到网络传输具有一定的延迟性,这个超时时间至少要大于一个心跳的间隔),比如连续两次发送心跳包,都没有收到 IM 服务端的响应,那么客户端可以认为和服务端的长连接不可用,这时客户端可以断线重连。
导致服务端没有响应的原因可能是和服务端的网络在中间环节被断开,也可能是服务器负载过高无法响应心跳包,不管什么情况,这种场景下断线重连是很有必要的,它能够让客户端快速自动维护连接的可用性。
连接保活:维护一条“高可用”的长连接,还有一个重要的任务就是尽量让建立的长连接存活时间更长。
心跳检测的几种实现方式:
TCP Keepalive
TCP 的 Keepalive 作为 *** 作系统的 TCP/IP 协议栈实现的一部分,对于本机的 TCP 连接,会在连接空闲期按一定的频次,自动发送不携带数据的探测报文,来探测对方是否存活。
*** 作系统默认是关闭这个特性的,需要由应用层来开启。
默认的三个配置项:心跳周期是 2 小时,失败后再重试 9 次,超时时间 75s。
三个配置项均可以调整。
这样来看,TCP 的 Keepalive 作为系统层 TCP/IP 协议栈的已有实现,不需要其他开发工作量,用来作为连接存活与否的探测机制是非常方便的;上层应用只需要处理探测后的连接异常情况即可,而且心跳包不携带数据,带宽资源的浪费也是最少的。
由于易用性好、网络消耗小等优势,TCP Keepalive 在很多 IM 系统中被开启使用,之前抓包就发现,WhatsApps 使用空闲期 10 秒间隔的 TCP Keepalive 来进行存活探测。
虽然拥有众多优势,但 TCP Keepalive 本身还是存在一些缺陷的,比如心跳间隔灵活性较差,一台服务器某一时间只能调整为固定间隔的心跳;另外 TCP Keepalive 虽然能够用于连接层存活的探测,但并不代表真正的应用层处于可用状态。
我举一个例子,比如 IM 系统出现代码死锁、阻塞的情况下,实际上已经无法处理业务请求了,但此时连接层 TCP Keepalive 的探针不需要应用层参与,仍然能够在内核层正常响应。
这种情况就会导致探测的误判,让已失去业务处理能力的机器不能被及时发现。
应用层心跳
为了解决 TCP Keepalive 存在的一些不足的问题,很多 IM 服务使用应用层心跳来提升探测的灵活性和准确性。
应用层心跳实际上就是客户端每隔一定时间间隔,向 IM 服务端发送一个业务层的数据包告知自身存活。
如果 IM 服务端在一定时间内没有收到心跳包,就认定客户端由于某种原因连接不可达了,此时就会从 IM 服务端把这个连接断开,同时清除相应分配的其他资源。
应用层心跳相比 TCP Keepalive,由于需要在应用层进行发送和接收的处理,因此更能反映应用的可用性,而不是仅仅代表网络可用。
而且应用层心跳可以根据实际网络的情况,来灵活设置心跳间隔,对于国内运营商 NAT 超时混乱的实际情况下,灵活可设置的心跳间隔在节省网络流量和保活层面优势更明显。
目前大部分 IM 都采用了应用层心跳方案来解决连接保活和可用性探测的问题。
比如之前抓包中发现 WhatApps 的应用层心跳间隔有 30 秒和 1 分钟,微信的应用层心跳间隔大部分情况是 4 分半钟,目前微博长连接采用的是 2 分钟的心跳间隔。
下面是一个典型的应用层心跳的客户端和服务端的处理流程图,从图中可以看出客户端和服务端,各自通过心跳机制来实现“断线重连”和“资源清理”。
智能心跳
所谓智能心跳,就是让心跳间隔能够根据网络环境来自动调整,通过不断自动调整心跳间隔的方式,逐步逼近 NAT 超时临界点,在保证 NAT 不超时的情况下尽量节约设备资源。
据说微信就采用了智能心跳方案来优化心跳间隔。
所谓的“多终端漫游”是指用户在任意一个设备登录后,都能获取到历史的聊天记录。
设备维度的在线状态:对于在多个终端同时登录并在线的用户,可以让 IM 服务端在收到消息后推给接收方的多台设备,也推给发送方的其他登录设备。
离线消息存储:如果消息发送时,接收方或者发送方只有一台设备在线,可能一段时间后,才通过其他设备登录来查看历史聊天记录,这种离线消息的多终端漫游就需要消息在服务端进行存储了。
当用户的离线设备上线时,就能够从服务端的存储中获取到离线期间收发的消息。
三、Go语言实现-聊天(IM)系统
ZuoFuhong/go-IM 使用纯go开发,基于websocket的聊天(IM)系统,支持多设备支持,离线消息同步。
本项目是由gim项目fork而来,在此基础上进行了二次开发,目标是为了梳理gim的核心流程,并仿写实现单用户多设备支持,离线消息同步的逻辑层。
在开发的过程中, 砍掉了gRPC、TCP服务端、Redis缓存等模块。
同时,在go.mod中仅依赖了几个必须的第三方包,其余均由纯go实现。
- 离线消息同步
心跳
消息单发
四、参考资料
阮一峰的网络日志-WebSocket 教程
即时消息技术剖析与实战
基于Go的马蜂窝旅游网分布式IM系统技术实践
Stomp-面向文本的消息传递协议
关于socket.io的使用
Golang写的IM服务器gim
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)