聊天系统大概由这几大部分组成:客户端、接入服务、业务处理服务、存储服务和外部接口服务。
接入服务主要有四块功能:连接保持、协议解析、Session 维护和消息推送。
IM 系统特性
- 实时性
- 可靠性
- 不丢消息
- 消息不重复
- 一致性
- 安全性
IM 服务端的网关服务和消息接收方设备之间维护一条 TCP 长连接(或者 Websocket 长连接),借助 TCP 的全双工能力,也就是能够同时接收与发送数据的能力。当有消息需要投递时,通过这条长连接实时把消息从 IM 服务端推送给接收方。
对于接收方不在线(比如网络不通、App 没打开等)的情况,还可以通过第三方手机操作系统级别的辅助通道,把这条消息通过手机通知栏的方式投递下去。
ACK 机制中的消息丢失 怎么办
- 维护一个超时计时器,一定时间内如果没有收到用户 B 回的 ACK 包,会从“等待 ACK 队列”中重新取出那条消息进行重推。
ACK 消息重复推送 怎么办
- 对于推送的消息,如果在一定时间内没有收到 ACK 包,就会触发服务端的重传。收不到 ACK 的情况有两种,除了推送的消息真正丢失导致用户 B 不回 ACK 外,还可能是用户 B 回的 ACK 包本身丢了。
对于第二种情况,ACK 包丢失导致的服务端重传,可能会让接收方收到重复推送的消息。
服务端推送消息时携带一个 Sequence ID,Sequence ID 在本次连接会话中需要唯一,针对同一条重推的消息 Sequence ID 不变,接收方根据这个唯一的 Sequence ID 来进行业务层的去重,这样经过去重后,对于用户 B 来说,看到的还是接收到一条消息,不影响使用体验。
时间戳机制”是如何对消息进行完整性检查的
- IM 服务器给接收方 B 推送 msg1,顺便带上一个最新的时间戳 timestamp1,接收方 B 收到 msg1 后,更新本地最新消息的时间戳为 timestamp1。
- IM 服务器推送第二条消息 msg2,带上一个当前最新的时间戳 timestamp2,msg2 在推送过程中由于某种原因接收方 B 和 IM 服务器连接断开,导致 msg2 没有成功送达到接收方 B。
- 用户 B 重新连上线,携带本地最新的时间戳 timestamp1,IM 服务器将用户 B 暂存的消息中时间戳大于 timestamp1 的所有消息返回给用户 B,其中就包括之前没有成功的 msg2。
- 用户 B 收到 msg2 后,更新本地最新消息的时间戳为 timestamp2。
接入域名的 DNS 劫持问题
- 第一类是路由器的 DNS 设置被非法侵入篡改了。这种问题常见于一些家用宽带路由器,由于安全性设置不够(比如使用默认密码),导致路由器被黑客或者木马修改了,DNS 设置为恶意的 DNS 地址,这些有问题的 DNS 服务器会在你访问某些网站时返回仿冒内容,或者植入弹窗广告等。
- 运营商 LocalDNS 可能会导致接入域名的解析被劫持。
- LocalDNS 是部分运营商为了降低跨网流量,缓存部分域名的指向内容,把域名强行指向自己的内容缓存服务器的 IP 地址。
- 运营商可能会修改 DNS 的 TTL(Time-To-Live,DNS 缓存时间),导致 DNS 的变更生效延迟,影响服务可用性。我们之前一个线上业务域名的 TTL 在某些省市能达到 24 小时。
- 一些小运营商为了减轻自身的资源压力,把 DNS 请求转发给其他运营商去解析,这样分配的 IP 地址可能存在跨运营商访问的问题,导致请求变慢甚至不可用。
防止DNS 劫持
对于宽带路由器的 DNS 设置被篡改的问题,一般,我们会重置一下路由器的配置,然后修改默认的路由管理登录密码
解决运营商 LocalDNS 的域名劫持和调度错误,业界比较常用的方案有 HttpDNS。HttpDNS 绕开了运营商的 LocalDNS,通过 HTTP 协议(而不是基于 UDP 的 DNS 标准协议)来直接和 DNS 服务器交互,能有效防止域名被运营商劫持的问题。
而且由于 HttpDNS 服务器能获取到真实的用户出口 IP,所以能选择离用户更近的节点进行接入,或者一次返回多个接入 IP,让客户端通过测速等方式选择速度更快的接入 IP,因此整体上接入调度也更精准。
当然,调度精准的另一个前提是 HttpDNS 服务自身需要有比较全的 IP 库来支持。目前很多大厂也基本都支持 HttpDNS 为主,运营商 LocalDNS 为辅的模式了,像很多第三方云厂商也提供对外的 HttpDNS 解析服务。HttpDNS 的实现架构如下图:
保证传输链路安全:TLS 传输层加密协议保证传输链路安全:TLS 传输层加密协议
对于消息在传输链路中的安全隐患,基本可以总结为以下几种。
- 中断,攻击者破坏或者切断网络,破坏服务可用性。 (采取多通道方式进行解决)
- 截获,攻击者非法窃取传输的消息内容,属于被动攻击。(利用私有协议和 TLS 的技术,来进行防控。)
- 篡改,攻击者非法篡改传输的消息内容,破坏消息完整性和真实语义。
- 伪造,攻击者伪造正常的通讯消息来模拟正常用户或者模拟 IM 服务端。
心跳检测的机制
- TCP Keepalive
- 应用层心跳
离线消息同步的几个关键点
对于多终端同时在线的情况,实现上相对比较简单,只需要维护一套设备维度的在线状态就能同时推送多台设备。
计算机端口范围是 0 ~ 65535,主要分成三大类:公认端口(0 ~ 1023)、注册端口(1024 ~ 49151)、动态或私有端口(49152 ~ 65535)。
微博消息箱图片上传、网络访问架构
CDN 加速技术,就是将客户端上传的图片、音视频发布到多个分布在各地的 CDN 节点的服务器上,当有用户需要访问这些图片和音视频时,能够通过 DNS 负载均衡技术,根据用户来源就近访问 CDN 节点中缓存的图片和音视频消息,如果 CDN 节点中没有需要的资源,会先从源站同步到当前节点上,再返回给用户。
CDN下载时的访问链路
CDN 作为一种非常成熟而且效果明显的资源访问加速技术,在用户访问量较大的多媒体业务中被广泛使用,直播、短视频、图片等业务都是 CDN 的重度使用对象。
CDN 预热
大部分 CDN 加速策略采用的是“拉模式”,也就是当用户就近访问的 CDN 节点没有缓存请求的数据时,会直接从文件上传存储的源站下载数据,并更新到这个 CDN 节点的缓存中。但在即时消息的一些特殊场景中,比如对超高热度的大型聊天室来说,如果采用“拉模式”,可能会导致 CDN 缓存命中率低,高并发的请求都被回源到源站,源站的带宽和存储压力都会比较大。这种情况下,我们可以采用“预热”的方式,来提前强制 CDN 节点回源并获取最新文件。大部分 CDN 都支持这个功能,通过 CDN 服务提供的 API 接口,把需要预热的资源地址和需要预热的区域等信息提交上去,CDN 收到后,就会触发这些区域的边缘节点进行回源来实现预热。此外,利用 CDN 预热功能,还可以在业务高峰期预热热门的视频和图片等资源,提高资源的访问效率。
在视频消息中,如果针对视频文件使用 HLS 协议来进行分片,那么就可以采用 HLS 协议自带的加解密功能,来实现视频的流加密。
HLS(流媒体网络传输协议)是苹果公司主导的为提高视频流播放效率而开发的技术。它的工作原理就是把整个媒体流分成一个个小的、基于 HTTP 的文件来下载,每次只下载一部分文件,从而达到实现消息加速分发的目的。
HLS 实现上由一个包含元数据的 M3U8 文件和众多被切割的视频片段的 TS 文件组成。其中,M3U8 文件作为 TS 的索引文件,用于寻找可用的媒体流,可以针对这些视频片段的 TS 文件进行 AES(Advanced Encryption Standard)等对称加密,从而保证第三方用户即使获取到 TS 的媒体文件,也播放不了。
M3U8 的索引文件中,支持“针对每一个 TS 文件可设置相应的获取密钥的地址”,这个地址可以作为业务层的鉴权接口,获取密钥时通过自动携带的 Cookie 等信息进行权限判定。只有鉴权通过,才会返回正确的密钥,而且整个解密过程都是播放器默认自动支持的,也不需要人为地对播放器进行改造。
通过 HLS 实现视频加解密的大概流程如下图:
用户通过上传服务,把视频上传到服务端;服务端进行视频的 HLS 切片并针对切完的 TS 文件流进行加密,同时把密钥存储到密钥服务中。当有用户请求该视频时,CDN 节点从源站回源加密的视频文件,播放器先通过下载的 M3U8 索引文件获取到“密钥地址”,然后将客户端缓存的认证 Token 拼接到该“密钥地址”后面,再通过该地址请求鉴权服务。
鉴权服务先检查携带的认证 Token 是否有权限访问该视频文件,如果权限没问题,会从密钥存储服务中将该视频的密钥文件返回给播放器,这时播放器就能自动解密播放了。
不过,虽然 HLS 原生支持加解密操作,但是对于图片等其他多媒体消息来说,没有办法使用这种方式。而且在有的即时消息系统中,只支持 MP4 格式的视频文件。所以,针对非 HLS 视频,我们还可以通过播放器的改造,来支持自定义的加密方式。
边下边播和拖动播放
一种常见的优化方案是采用边下边播策略。在播放器下载完视频的格式信息、关键帧等信息后,播放器其实就可以开始进入播放,同时结合 HTTP 协议自带支持的 Range 头按需分片获取后续的视频流,从而来实现边下边播和拖动快进。
支持边下边播需要有两个前提条件。
格式信息和关键帧信息在文件流的头部。如果这些信息在文件尾部,就没法做到边下边播了。对于格式信息和关键帧信息不在头部的视频,可以在转码完成时改成写入到头部位置。
服务端支持 Range 分片获取。有两种支持方式。
a. 一种是文件的存储服务本身支持按 Range 获取,比如阿里的对象存储服务 OSS 和腾讯的对象存储服务 COS,都支持分片获取,能够利用存储本身的分片获取机制真正做到“按需下载”。
b. 对于不支持分片获取的存储服务来说,还可以利用负载均衡层对 Range 的支持来进行优化。比如,Nginx 的 HTTP Slice 模块就支持在接收到 Range 请求后从后端获取整个文件,然后暂存到 Nginx 本地的 Cache 中,这样取下一片时能够直接从 Nginx 的 Cache 中获取,不需要再次向后端请求。这种方式虽然仍存在首次获取速度慢和 Cache 命中率的问题,但也可以作为分片下载的一种优化策略。
图片压缩和视频转码
另一种优化下载性能的策略是对图片、视频进行压缩和转码,在保证清晰度的情况下尽量降低文件的大小,从而提升下载的性能和降低网络开销。图片压缩一般又分为客户端压缩和服务端压缩。客户端压缩的目的主要是减小上传文件的大小,提升上传成功率,降低上传时间
分辨率自适应
在消息会话里的图片,我们可以使用低分辨率的缩略图来显示,等用户点击缩略图后再去加载大图,因为低分辨率的缩略图一般都只有几十 KB,这样加载起来也比较快。一般服务端会提前压缩几种常见的低分辨率的缩略图,然后按照终端机器的分辨率来按需下载。
H.265 转码
视频的码率是数据传输时单位时间传送的数据 BPS。同一种编码格式下,码率越高,视频越清晰;反之码率太低,视频清晰度不够,用户体验会下降。但码率太高,带宽成本和下载流量也相应会增加。
目前,主流的视频格式采用 H.264 编码,H.265(又名 HEVC)是 2013 年新制定的视频编码标准。同样的画质和同样的码率,H.265 比 H.264 占用的存储空间要少 50%,因此在实现时,我们可以通过 H.265 来进行编码,从而能在保证同样画质的前提下降低码率,最终达到降低带宽成本和省流量的目的。
但 H.265 的编码复杂度远高于 H.264(10 倍左右),因此服务端转码的耗时和机器成本也相应会高很多。很多公司也并不会全部都采用 H.265 编码,而是只选取部分热点视频来进行 H.265 编码,通过这种方式,在降低转码开销的同时来尽量提升 H.265 视频的覆盖度。
针对多媒体消息的下行都有哪些技能树:
- 通过 CDN 加速,让“用户离资源更近”;
- 通过“流加密”来解决 CDN 上多媒体消息的私密性问题;
- 为图片提供多种中低分辨率的缩略图来提升图片预览性能;
- 使用 WebP 和渐进式 JPEG 来对图片进行压缩,以降低体积,提升加载性能;
- 针对热门的小视频采用 H.265 转码,在保证画质的同时,降低带宽成本并加快视频加载;
- 通过视频的自动“预加载”功能,达到视频播放“秒开”的效果;
- 借助长连接通道,对体积较小的音频和缩略图进行实时推送,提升用户浏览和播放体验。
针对 CDN 上的文件访问鉴权
- 给资源设置带有过期时间的访问 token,服务端经过鉴权后向 CDN 服务申请对应资源的访问 token 然后给客户端下发带有访问 token 的资源链接,客户端用这个带有 token 的链接才能在有限时间向 CDN 获取资源。如果资源链接过期可以通过上面的方式重新获取。
多级缓存架构在消息系统中的应用
计算机相关的硬件指标
L1 cache reference 0.5 ns
Branch mispredict 5 ns
L2 cache reference 7 ns
Mutex lock/unlock 100 ns
Main memory reference 100 ns
Compress 1K bytes with Zippy 10,000 ns
Send 2K bytes over 1 Gbps network 20,000 ns
Read 1 MB sequentially from memory 250,000 ns
Round trip within same datacenter 500,000 ns
Disk seek 10,000,000 ns
Read 1 MB sequentially from network 10,000,000 ns
Read 1 MB sequentially from disk 30,000,000 ns
Send packet CA->Netherlands->CA 150,000,000 ns
同样是 1MB 的数据读取,从磁盘读取的耗时,比从内存读取的耗时相差近 100 倍,这也是为什么业界常说“处理高并发的三板斧是缓存、降级和限流”了。
缓存的分布式算法
取模求余
用于存储消息内容的缓存,如果采用取模求余,就可以简单地使用消息 ID 对缓存实例的数量进行取模求余。如下图所示:如果消息 ID 哈希后对缓存节点取模求余,余数是多少,就缓存到哪个节点上。
取模求余的分布式算法在实现上非常简单。但存在的问题是:如果某一个节点宕机或者加入新的节点,节点数量发生变化后,Hash 后取模求余的结果就可能和以前不一样了。由此导致的后果是:加减节点后,缓存命中率下降严重。
为了解决这个问题,业界常用的另一种缓存分布式算法是一致性哈希
一致性哈希
一致性哈希的算法是:把全量的缓存空间分成 2 的 32 次方个区域,这些区域组合成一个环形的存储结构;每一个缓存的消息 ID,都可以通过哈希算法,转化为一个 32 位的二进制数,也就是对应这 2 的 32 次方个缓存区域中的某一个;缓存的节点也遵循同样的哈希算法(比如利用节点的 IP 来哈希),这些缓存节点也都能被映射到 2 的 32 次方个区域中的某一个。
消息 ID 和具体的缓存节点对应起来呢?
每一个映射完的消息 ID,我们按顺时针旋转,找到离它最近的同样映射完的缓存节点,该节点就是消息 ID 对应的缓存节点。
一致性哈希能够解决取模求余算法下,加减节点带来的命中率突降的问题
假设已经存在了 4 个缓存节点,现在新增加一个节点 5,那么本来相应会落到节点 1 的 mid1 和 mid9,可能会由于节点 5 的加入,有的落入到节点 5,有的还是落入到节点 1;落入到新增的节点 5 的消息会被 miss 掉,但是仍然落到节点 1 的消息还是能命中之前的缓存的。
另外,其他的节点 2、3、4 对应的这些消息还是能保持不变的,所以整体缓存的命中率,相比取模取余算法波动会小很多。
同样,如果某一个节点宕机的话,一致性哈希也能保证,只会有小部分消息的缓存归属节点发生变化,大部分仍然能保持不变。
如何去解决这种缓存热点问题
多级缓存架构 - 主从模式
多级缓存架构 -L1+ 主从模式
为了解决主从模式下,单点峰值过高导致单机带宽和热点数据在从库宕机后,造成后端资源瞬时压力的问题,我们可以参考 CPU 和主存的结构,在主从缓存结构前面再增加一层 L1 缓存层。
L1 缓存作为最前端的缓存层,在用户请求的时候,会先从 L1 缓存进行查询。如果 L1 缓存中没有,再从主从缓存里查询,查询到的结果也会回种一份到 L1 缓存中。
与主从缓存模式不一样的地方是:L1 缓存有分组的概念,一组 L1 可以有多个节点,每一组 L1 缓存都是一份全量的热数据,一个系统可以提供多组 L1 缓存,同一个数据的请求会轮流落到每一组 L1 里面。
比如同一个文章 ID,第一次请求会落到第一组 L1 缓存,第二次请求可能就落到第二组 L1 缓存。通过穿透后的回种,最后每一组 L1 缓存,都会缓存到同一篇文章。通过这种方式,同一篇文章就有多个 L1 缓存节点来抗读取的请求量了。
而且,L1 缓存一般采用 LRU(Least Recently Used)方式进行淘汰,这样既能减少 L1 缓存的内存使用量,也能保证热点数据不会被淘汰掉。并且,采用 L1+ 主从的双层模式,即使有某一层节点出现宕机的情况,也不会导致请求都穿透到后端存储上,导致资源出现问题。
多级缓存架构 - 本地缓存 +L1+ 主从的多层模式
通过 L1 缓存 + 主从缓存的双层架构,我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。
但有的时候,面对一些极热的热点峰值,我们可能需要增加多组 L1 才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本,所以有没有更好的平衡极热峰值和缓存成本的方法呢?
对于大部分请求量较大的应用来说,应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作,就能充分利用应用层机器的带宽和极少的内存,来低成本地解决带宽问题了。那么,这种方式是否可以实现呢?
答案是可以的,这种本地缓存 +L1 缓存 + 主从缓存的多级缓存模式,也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图:
本地缓存一般位于应用服务器的部署机器上,使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存,应用层获取数据时先访问本地缓存,如果未命中,再通过远程从 L1 缓存层获取,最终获取到的数据再回种到本地缓存中。通过增加本地缓存,依托应用服务器的多部署节点,基本就能完全解决热点数据带宽的问题。而且,相比较从远程 L1 缓存获取数据,本地缓存离应用和用户设备更近,性能上也会更好一些。
Q&A
- 有了 TCP 协议本身的 ACK 机制,为什么还需要业务层的 ACK 机制?
有了 TCP 协议本身的 ACK 机制为什么还需要业务层的ACK 机制?
答:这个问题从操作系统(linux/windows/android/ios)实现TCP协议的原理角度来说明更合适:
1 操作系统在TCP发送端创建了一个TCP发送缓冲区,在接收端创建了一个TCP接收缓冲区;
2 在发送端应用层程序调用send()方法成功后,实际是将数据写入了TCP发送缓冲区;
3 根据TCP协议的规定,在TCP连接良好的情况下,TCP发送缓冲区的数据是“有序的可靠的”到达TCP接收缓冲区,然后回调接收方应用层程序来通知数据到达;
4 但是在TCP连接断开的时候,在TCP的发送缓冲区和TCP的接收缓冲区中可能还有数据,那么操作系统如何处理呢?
首先,对于TCP发送缓冲区中还未发送的数据,操作系统不会通知应用层程序进行处理(试想一下:send()函数已经返回成功了,后面再告诉你失败,这样的系统如何设计?太复杂了…),通常的处理手段就是直接回收TCP发送缓存区及其socket资源;
对于TCP接收方来说,在还未监测到TCP连接断开的时候,因为TCP接收缓冲区不再写入数据了,所以会有足够的时间进行处理,但若未来得及处理就发现了连接断开,仍然会为了及时释放资源,直接回收TCP接收缓存区和对应的socket资源。
或者是解析失败,落库失败等
总结一下就是: 发送方的应用层程序,调用send()方法返回成功的时候,数据实际是写入到了TCP的发送缓冲区,而非已经被接收方的应用层程序处理。怎么办呢?只能借助于应用层的ACK机制。
不懂
DNS 劫持