实现 IP Config - 长连接调度引擎
实现 IP Config - 长连接调度引擎
设计目标
用于调度客户端连接哪个im gateway的ip地址的http server,提供一个查询endpint信息的列表接口,对外仅暴露查询接口,对内连接服务发现&配置中心&redis等服务用于实时获取统计数值计算最佳ip地址以达到长连接网关的负载均衡的目标。
因此我们要开发一个支持在线查询,近线实时计算的http server服务。
约束条件
负载均衡: 针对当前的客户端计算候选ip对其建立连接的状态是否为最佳的分值,这一数学模型的拟合度是最重要的指标,数学模型过于简单则会欠拟合计算的分值误差过大,数学模型过于复杂则会导致过拟合,现实网络环境发生一些变换则模型难以适应,导致指标劣化,其结果就是负载不均衡。
查询延迟: 在线查询需要在线的计算当前客户端的ip地址对于候选ip的得分,这是一个典型的分配问题,需要一定的算法策略,这导致计算复杂度升高,查询延迟成为瓶颈,如果延迟升高此查询会影响用户客户端启动时建立连接的端到端延迟,将直接影响用户体验。
查询吞吐: 为应对突发情况,当imgateway出现故障,大量用户断开链接等情况出现时,会导致大量查询ip config 服务这会造成QPS的突增,ip config server需要应对此种情况。
稳定性: ip config服务不可用,客户端拿不到ip地址自然无法建立长连接,整个im服务通信功能不可用,因此其稳定性应该是p0级别的。
**安全性****:** 此接口对客户端暴露使用,所以需要有一定的安全认证机制。
架构设计
如何计算的更准确,才能保证长连接的负载均衡?
长连接调度与短链接的不同之处是连接是持续的,所以长连接的负载均衡问题本质上是一个持续任务的调度问题,其次长连接每个连接消耗的资源是不同的,有一些客户端可能一直不说话,有一些客户端却在活跃的万人群聊中聊天,消耗的带宽是不同的,长连接建立连接的过程还会存在延迟,这一延迟是秒级的,这会导致统计的状态过期导致计算不准确。总结起来为计算准确的负载均衡分值由三个技术约束:持续负载/资源不均衡分配/计算状态的时效性。
由于约束二的存在我们必须统计机器本身的负载情况并计算分值,以分值作为权重进行多权重分配,我们使用: ip之间的地理距离/连接数/带宽消耗/内存占用/CPU 使用率/CPU load值/im gateway协程数等指标作为统计分值,计算融合分。
由于约束三,需要解决计算状态的过期问题,简单的思想是取每个计算状态在一个窗口内的平均值/中位数,使其更加稳定,但每个窗口中时刻的点并不是等效的,距离当前时刻更近的点有效性更大,所以对于不同时刻的点应该有不同的置信度,以便于更好的刻画负载情况。
融合分会带来新的问题,那就是其计算的复杂性,以及可解释性差,很难评估负载是否均衡或者为什么均衡,出现不均衡的情况时难以排查问题。
为此我们需要寻找一种可解释性强的计算方法,根据上述三个约束的特性,可以发现gateway会存在两种不同的状态,一种时活跃状态: 机器上存在大量活跃的连接收发消息进行通信,此时整个机器的性能瓶颈在于网络带宽,而计算网络带宽是复杂的其干扰噪音较大,因此我们可以计算1s之内gateway收发消息的总字节数来衡量其带宽的吞吐情况,由于活跃状态的gateway最先耗尽的资源是网络带宽,那当网络带宽剩余不足时,我们就应该对其进行降权,我们需要计算一个动态状态分值。
其次,gateway还存在一种静态状态:机器上大量的连接处于非活跃状态此时性能瓶颈在于内存等静态资源,一个长连接建立后要维护心跳与收发消息的协程等,哪怕在不进行通信时也要消耗网关机器大量的资源。因此为了能刻画此种情况的负载,此时应该使用gateway以及创建连接的连接数量来衡量负载情况,所以我们需要计算一个静态状态分值。
那何时使用静态分?何时使用动态分呢? 活跃分的变化是极快的,难以准确合理通常需要采样计算,这就会存在一些误差,为了平衡这种误差的影响,通常采用近似计算的方法,讲网关机的手法消息字节数规约为以GB为单位的值并保留两位小数。因此如果网关机收发消息的字节数近似规约后等于0GB,那么此时就会采用静态分进行计算,同时规约后如果网关机的动态分值相等时,也会使用静态分进行比较。
例如: 如果当一台网关机动态分为1GB时其他网关机只有0.8GB时,我们会优先调度0.8GB的机器,哪怕在1GB动态分的机器上只有2个连接(假设此机器的最大网络带宽时2GB,此时有理由假设,新加入的连接很可能最先耗尽网络带宽资源,因此应该将其调度到网络带宽最富裕的机器上)。
但是: 如果0.8GB的机器上其连接数也已经接近内存瓶颈时该怎么处理? 这时就需要对二者进行加权平均,两害取其轻,这就是一个简单的线性回归模型,但这已经足够了。
事实上这种刻画依然是不精准的,没有考虑ip的地理位置,一个连接创建后其内存占用确定的,但其是否活跃却是预估的,这些依旧是一个问题,事实上对精度的追求是无止尽的,因此需要专门的算法工程师来优化,这超脱了架构的基本范畴。
如何降低查询的延迟?
客户端的ip传递过来时,会根据当前各个im gateway节点负载状态实时的计算出分值,然后进行排序取top 5返回。这一操作如何能更加高效? 问题的瓶颈在于计算量。
上述计算方法中计算窗口的方法是对窗口中的时刻都分配权重然后加权平均计算,事实上可以仅存储上一秒的负载分值,然后与当前分值做差后求积分并除以一个置信度系数(表示上一秒对这一秒负载的影响程度) 这样就使得整个计算量大幅度减少。
同时, ip config server可以对一些计算状态进行缓存避免重复计算,比如ip距离等。
如何提高查询的吞吐能力?
Ip config server 本身的计算逻辑相对简单,通过上述优化后计算不再是瓶颈,那么整个吞吐的关键在于网络带宽,整体的性能趋近于网络的端到端时间,此时仅需要横向扩展机器增加集群的吞吐能力即可,亦或者使用高性能网卡,os by pass等策略优化单机网络性能即可。
如何保证ip config的稳定性?
由于im gateway 会存在不断增加机器的情况,而不同时期增加的机器可能配置是不同的,未来增加的机器可能配置更好,那么使用消耗了多少资源来表示负载可能会导致资源利用率不足,为此我们可以使用资源剩余量来表达负载情况,这样就能充分考虑每台机器的真实负载的健康状态。
关键在于评估负载均衡的监控状态,当出现不健康的情况时应当能及时报警或自动降级,其次当计算状态许久未更新的情况下,可退化为仅基于ip距离的计算或是随机策略做兜底,并具有手动设置的开关,当线上出现稳定性问题时可以快速切换流量并配置新的im gateway机器保证对外工作。
其次要尽可能的保证ip config的旁路工作的特性,不要与数据中心的其他基础组件耦合,当下游出现问题时,可以尽可能的保证ip config server服务的正常工作,这将有助于我们快速切流恢复线上稳定性。
Ip conf server 底层依赖etcd集群,etcd基于raft算法所开发的分布式kv,本身具有高可靠性设计,一次保证数据的一致性与可用性,其次所有的计算均在本地进行以保证其可靠。
Im gateway 也必须具有拒绝策略,应对计算状态统计延迟等问题导致的突发流量的情况,此时每个imgateway 需要有一个限流拒绝策略,当单机负载不在健康时,就需要给客户端直接返回拒绝连接的信号,客户端将调度下一个备选地址。
核心逻辑
使用web框架: hertz,基于gin封装的简洁高效的web server框架。
使用ETCD做服务发现,为追求高可用性,使用ETCD这种CP的分布式kv是合适的。
使用viper作为配置解析模块。
使用etcd做服务发现,并且上报连接数和每秒收发消息的数据包字节数的剩余值作为统计值。
然后使用5s的时间窗口取均值来屏蔽噪声,得到一个稳定的数值进行负载均衡的预估。
由于etcd的watch机制是与客户端建立长连接进行push的,所以etcd的压力取决于机器的数量。
所需的数值都是在本地计算的,在线请求无需进行网络调用,因此性能极高,本身可水扩展。
稳定性由etcd集群来保证其可靠性。
定义全局使用的plato.yaml 配置,定义一个common包用来做依赖倒置,封装所有的基础设施组件
实现 IM Gateway (一) - 持有socket长连接
设计目标
维护长连接socket状态
协议解析和消息包转发
状态统计与上报
技术约束
稳定性高, 极少进行变更,减少重启的次数
转发过程性能高
内存占用小,单机持有更多的连接数量: 垂直/水平
维护长连接socket状态
技术方案
如何在连接处于静态状态时,消耗更少的内存资源?
plato选择tcp长连接作为传输层通信协议,因此需要在服务端维护socket的状态,最简单的方法就是两个协程对应一个socket,一个处理此socket的读,另一个处理此scoket的写事件,并且需要有一个协程作为server socket 监听服务端口,执行accept 逻辑,因此需要1+2N的协程数来监听维护socket的状态,其中N是gateway的持有连接句柄数量。
这就不满足我们说的内存使用尽可能小的要求,因为一个协程要维护自己的写协程栈信息,所以就算是在静止状态没有消息收发时,也需要消耗基本的协程栈的内存占用高达4kb,所以对于一个64GB的机器来说最多可以存储800w的连接,如果我们以一半的内存容量用于避免突发状况为消息收发留足资源,那么也能存储400w的连接,然后加上一下runtime的其他内存使用,以及注册表信息,其实际能建立的连接远小于理论值,不超过30w,并且大量的协程为调度器增加了负担,使得整个gateway即使在非活跃时间段也维护着大量的内存资源,造成性能问题。
如果我们使用epoll的多路复用技术在业务层再次实现,那么即可通过reactor模式减少在连接静止状态下内存的消耗。
只有当有读事件或者写事件发生时才会从协程池中获得一个协程去读写socket,当有消息push的时候才会创建一个协程,然后查询注册表信息找到socket进行读写,因此在连接静止状态下将没有协程的资源消耗,大部分的资源消耗会转化为os中epoll的红黑树的内存占用,而内核的性能将远高于业务进程,没有复杂的协程调度整体的GC和阻塞情况都将得到缓解,性能得到提升,完全可以做到单机突破百万长连接。
参考百万 Go TCP 连接的思考我们将得到一个不错的实现方案。
关于reactor的详细资料请参考: 网络编程系列(select、poll、epoll、Reactor模型、Proactor模型)
注册表如何维护?
维护一个全局的大map,key是did,value是conn对象的指针(这样在下行消息转发时可以少查一跳),仅需要维护这样的一个map即可,在push时使用,整个更新过程都由state server来控制,gateway做的事情越少越好,这样才能保证其不会频繁发生变更,不会重启,连接才能被稳定持有。
如何提高单机持有连接的数量?
【尽可能少的创建内存结构】 socket的内存是不易节省的,因此去压榨应用层的内存roi较高,整个通信过程中 gateway最大的内存消耗在于创建协程以及读取消息的buffer对象上,通过epoll机制我们能解决静态连接占用应用层内存过多的问题,通过多进程架构的思想,我们将心跳定时器等等逻辑全部交由state server,让gateway保持简洁,我们还可以通过内存分配器优化读取消息的buffer结构,仅能的复用内存并确保内存都有相应的池化工厂类分配避免资源耗尽导致OOM。
所以我们需要一种协程池,一种**内存分配器(高性能优化专题会讲解)**。【反应堆模式,异步回调】 【工作外包,多进程拆分】【资源池化,对象复用】三个策略
实现细节
流程图
多accept机制,为提高吞吐量,我们可以监听一个listenTCP,但是由多个协程进行accpet,这样当某个协程因为未知原因panic后依旧可以工作避免了单点,并且有效的提高了整体的并发度。
accept创建conn后交由全局的channel处理fd,然后有多个轮询器:epoller处理,每个epoller有两个协程和一个os层的epoll对象组成,epoller的数量等于cpu的逻辑核数,这样能过保证最大的并行度,进一步的优化可以绑定协程到固定的cpu上,避免跨核任务切换造成的性能损失。
epoller中有一个 sub accpect 监听 全局的channel,当channel中有fd后将其消费,然后注册到自身的地产epoll对象中监听此socket上的读写事件,并且设置为水平触发。
另一个协程负责轮询此epoll对象,并且以200ms为timeout 防止忙轮询造成cpu load的拉高。
当每次wait到conn后,会在tcp基础之上做一层网关层的解析len+data的简单模式。解析出来的data作为一个完整的消息包交由work pool 协程池去处理,协程池会发起rpc,将data交由state server去处理,这时他会上报fd+endpoint信息,方便state server知道该设备的连接在哪一台网关机上。
gateway,作为rpc server需要创建一个 rpc server容器组件,用来接受 state server的操作。
通常 state server会发送一些rpc来操作 gateway的行为,state server存储 did到endpoint+fd的映射,快速定位连接在哪台机器上,然后通过rpc将消息传递过来,携带的fd信息会快速的定位具体的conn将消息发送出去。
实现 IM Gateway (二) - 控制长连接收发消息
现在我们能控制长连接被gateway所持有,那么下一步就是使用长连接来收发消息, 控制长连接的单元是由 state server实现的,在接入层我们的设计思想是将资源和控制分离。
根据上节的内容,我们还需要一个rpc server,这个rpc server用来接收 state server的rpc 请求,向具体的conn发出消息,我们叫这个rpc 为 push,是下行消息操作,由state server调用。
还需要一个rpc client去请求 state server,将上行消息传递给state server,state server负责协议解析,然后根据具体的内容决定是控制信号,还是消息传递,然后进行一定的业务逻辑处理,根据策略将消息转发到业务层,或者是关于连接的控制信号(心跳,超时,ack等),则直接作出判断调用push rpc 通知gateway server 作出相应的处理过程。
流程图
技术约束
转发过程延迟尽可能的低
尽可能的减少解析性操作,使得内存/cpu消耗更少
处理rpc异常,和资源隔离,确保稳定性
技术方案
如何优化gateway 和 state 通信的延迟?
由于拆解为两个进程,那么整个转发过程性能瓶颈还是在于网络通信,计算成本极低可忽略不计。
RPC 仅是一种通信协议,本质上还是一种应用层协议,跟http对应,那么根据网络的分层模型,其实传输层和网络层其实可以不仅仅是tcp/ip协议,完全可以实现基于domain socket的本机多进程通信机制来代替传输层协议,上层还是rpc,也就是说将两个进程放在同一台物理机上,然后二者的rpc通信实际上是对多进程通信的封装。进程之间的通信速度,远快于网络通信,因此达到了极高的延迟优化效果。
但RPC本地化带来的问题就是部署复杂度的上升以及在设计state server时需要考虑本地内存和分布式内存的差异,比如定时器等数据需要在本地存储,路由信息需要在分布式内存中,并且要携带endpoint信息等。
同时在资源上,相同物理机上会抢占内存/CPU/IO等资源,state server 会占用大量内存资源,在资源隔离性上较差。
一种折中的方式是将state 和 gateway部署到两台物理机但是相同的机柜上,这样既可以做到机器的资源隔离,也能尽可能的应用 就近原则 优化其延迟,但这些部署策略会在后面逐步完善。
RPC 需要实现哪些接口?
调用state server 需要提供两种cmd:cancel/send, state server 调用gateway控制长连接的逻辑只需要两个cmd: del/push ,将接口做成batch的模式有利于提高吞吐,做成stream模式则可以降低延迟。
如果使用cmd聚合的设计模式即定义cmd+data为参数,在一个rpc request/response中处理,好处是rpc的变更简单,方便扩展, 坏处是在使用batch/stream时难以区分cmd之间的优先级。
假设使用本地通信rpc的成本不再是瓶颈,那么batch的处理ROI不高,可以再state server 与 gateway server 内部维护一个 cmd channel,异步处理cmd,这样做到消峰保证服务稳定,也能具有stream处理的优势,同时可以拆分多个cmd channel 以便于不同的cmd处理优先级的区分。
push的时候,不知道fd在哪一个轮询器? 该怎么解决? -> socket 引用计数为0时会真正被删除,epoll中的socket句柄也会被os回收,所以不需要太过在意。
实现 State Server(一) - 长连接生命周期管理
系统现状
当client与gateway建立连接后,将会收发消息,但是连接作为一个通道,在互联网上经过不可控的路由节点,很难保证连接的可用性,这里定义可用指的是能够用连接收发消息。
那么连接通常会在什么场景异常断开? 所谓异常断开强调的是,连接的断开既不是客户端也不是服务端主动断开的场景。
通常公网长连接要经过运营商,而运营商为了节约成本会周期性将没有消息收发的连接断开,每个地区每个运营商都是不同的,运营商会向客户端和服务端都发送fin信号断开连接,所以客户端和服务端都会以为是彼此端口,难以排查。
其次,当tcp连接到数据中心时,现代数据中心都会有多层网关这些网关为了节约自身资源也会周期性清理不活跃的连接,这时也会发送fin信号断开彼此。
再有由于移动互联网场景,客户端通常处于不确定的网络场景中,比如离开室内wifi到达户外使用移动信号,此时手机会连接基站长连接也必然会断开。
当客户端高速移动时,手机移动出漫游区域覆盖范围,进入到其他运营商网关范围,此时会导致手机被分配新的ip地址,网络层IP地址发生变化,那么上游的传输层TCP也会断开,此时也会导致连接不可靠。 详细参考这里。
在进入一些弱网场景中时也会导致连接断开,例如隧道/地铁/山区等等,此种情况均会导致连接端口。
连接断开会极其影响用户的使用体验,轻则影响消息的延迟,重则导致消息错乱,对比所有app你会发现在地铁上的时间,可能只有聊微信网络是比较好的,这就是弱网场景的一些优化,其中连接可靠性是重要一环。
优化目标
如何优化这种情况?被断开的原因可以总结为两种,第一是被中间代理资源收回 第二种是底层ip的切换。
针对第一种方法我们能够想到的是 心跳机制,只要保证在整个链路上周期性的收发一条空消息,即可防止资源的回收,但前提是消息收发的周期要小于整个链路最小回收周期,
谁来发送消息,如果是服务端那么每一次都要遍历一遍所有的socket,这是巨大的耗时,因此只能由客户端主动发起心跳消息,服务端检查这是一个心跳然后对其进行回复,这里需要注意的是,连接创建成功时服务端需要创建一个定时器,当定时时间到达后就要主动断开连接,这是防止客户的因为某些原因没有断开连接但也变得不可用无法在收发消息,此时连接占用着服务端资源必须回收,否则就会发生泄露问题。
针对第二种现象,ip地址的更新一定会导致连接断开,此时我们的目标不是防止不断,而是如果让连接断开后但是用户无感知,这需要客户在后台进行多次连接重建的请求,当连接断开后,如果客户端没有问题此时他会主动去重新连接,此时服务端需要能够快速重连,由于之前具有的一些连接状态在这里都可以被再次继承,无需重新分配因此重连速度就会增加。
约束条件
虽然上述以及阐述了解决问题的基本思想,但是要想真正的落地实现,必须考虑一些现实的约束场景。
资源成本, 尽可能的减少服务端资源的损耗是接入层的重要目标,因为长连接接入层需要维护极易改变的状态,这造成了重大的工程技术挑战。
可靠性, 连接应该在各种极端情况下都能以最快速度恢复连通状态。
低延迟,不能因为维护连接的可靠性就导致收发消息的延迟加大。
技术方案
编解码器 基于现在的架构,state server需要实现一个协议解析模块,这个模块用来解析消息协议,然后根据消息的类型进行逻辑处理的路由。协议的解析与IOS的协议分层机制有些类似,gateway 只解析len与data,state 会解析data中的msg type,然后基于type进一步选择相应的解码器实现并将剩余打playload传递给解码器,最后路由到业务层处理业务。
控制信令 在本章节中,我们需要实现 登陆/心跳/重连 三个信令,用来实现我们上面提到的思路。
这需要我们进行协议的设计,整体上会使用pb进行序列化,协议的设计要充分考虑扩展性,支持未来做更加复杂的优化策略。
如何尽可能的优化资源成本?
心跳的定时器维护是一个巨大的开销,他将跟持有的长连接数量保持一致水平扩展,如果在某一段时间有大量的用户登陆plato就会导致定时器的触发点出现潮汐现象。严重情况下会导致系统直接卡死,因为回调协程被直接唤醒,造成调度潮汐,cpu利用率被直接拉满,同时定时器的存储也是一个巨大的内存消耗,go 底层使用的是 四叉堆来存储定时器,这种设计精度会很高,但是在我们的这个场景其实不需要很高的精度,只要资源能够被及时回收秒级的误差是能够接受的,因此我们其实只需要使用时间轮算法代替原生的定时器算法,时间轮的精度较低但性能足够好,插入定时器和触发定时器都可以做到O(1)的时间复杂度,但golang原生的定时器是O(lngN)的。
登陆时我们都需要做什么?
创建一个定时器,然后从中解析出did的信息,将connID(等价于fd,fd存在bug,上一节课已经讲过)+endpoint 信息与did进行绑定,形成路由(did作为key,endpoint+connid作为value),这一映射是用来业务层下推消息时使用,因为业务层仅感知did的信息,第三步将消息下发给业务server,一旦业务server回复ok,就直接回复客户端ack,这里假设业务的处理都是同步的,即使有异步操作也会通过MQ等手段保证可靠执行。
心跳的时候我们需要做什么?
当客户端接收到心跳消息时,通过endpoint+connID作为key找到定时器对象,然后将其重制重新定时,此时从心跳中可以解析出一些客户端周期性上报的消息,在这些上报消息中服务端可以做一些统计行为,然后回复客户端ack,代表消息已经收到。
重连的时候我们需要做什么?
在登陆成功时,服务端回复的ack中会携带此时链接的connID,在链接端口后发送重连信令时将connID发送过来。
在服务端感知到连接断开时,不要立刻进行资源的回收,而是启动一个定时器,当这个定时器超时后再回收资源,这段等待时间就是为了等待客户端进行快速重连,进行资源的复用,这有助于故障连接的快速恢复。
如果gateway进程崩溃,state server并没有及时清理资源,此时客户端使用上一个gateway创建的connID来进行重连时,state server本质上同样可以识别并正常工作因为endpoint+connID并没有变化。
如果state server崩溃了呢? 该连接将失去所有的状态,定时器信息,此时将无法正常工作,除非使用分布式缓存,将一些关键数据缓存在其中,在链接可靠性这一层次只有消息路由信息是需要的,其他一些数据会在消息可靠性一节中描述。
实现 state server(二) - 实现消息状态机
背景分析
当长连接保证了稳定,那么下一步就是要保证消息的可靠。 通常对于消息来说,用户视角要求消息从A客户端到B客户端发出的顺序和接收的顺序保持一致,不重,不漏,有序,及时。 这是对im系统来说,所要保证的第一要务。 对于消息可靠性的保证业内其实已经有很多成熟的方法,但哪一种比较适合im的场景是值得分析的。
技术约束
高可用 首先就是要足够的高可用,对于最基本的收发消息来说保证其核心链路的可用性非常重要,属于im产品的生命线,对于此必须提供5个9以上的SLA。
低延迟 即时通讯系统强调的是即时,因此将消息毫秒级的发送到对端是基本诉求。
- 高吞吐 对于极端的群聊场景,万人群聊在特定时间全部活跃每发送一条消息都是一次DDos攻击。
当然对于一个始终处于高速迭代状态的im系统来说,极度的复杂性更加来自于人的因素,迭代效率与质量是一个永恒的话题,但这不在本课程的讨论范围内,我们仅考虑架构上的核心要素。
技术方案
消息的可靠性可以分为,上行消息可靠性与下行消息可靠,客户端A发送的消息送到服务端后被回复ack则必然会送达到对端B上,这就是上行消息可靠。 消息从服务端下发给客户端B时,只要客户端B回复了ack则说明消息一定能送达到客户端B并且是按照规定(客户端定序or服务端定序) 的顺序,这就是下行消息可靠性。
消息可靠性 = 不漏 + 不重 + 有序 + 及时
最简单的消息传输就是两端的消息通信,a将消息跨越不可靠的网络传输给b时,就会出现四种情况,消息被遗漏,消息重复,消息到达不及时,消息乱序到达。
消息被遗漏,a通过<ack+超时重试>策略即可,但造成的问题是如果网络不可靠消息并没有丢失,而是延迟达到,则会使得b接收到两次重复的消息。
对此,需要一种策略能够识别出两次消息是重复的,则需要定义具有唯一性的id,服务端通过这个id可以判断消息是否被接收过。
消息延迟会导致被误认为消息丢失,进而触发超时重试机制就会导致消息的乱序,为此这个上行消息的id必须还要可比较,基于其递增性来进行排序,确保消息的有序性。
b端存储接收过的max_id,只有接收到消息id == max_id+1时才会确认回复ack,其余情况都会被拦截忽略,但在网络不稳定的情况下,超时重试的概率增加,那么这种无效的消息通信将会影响整个网络的负载,整体网络延迟就会增高。
通常的做法可以批处理,服务端维护一个消息队列来实现tcp的滑动窗口的功能,来保证消息有序,并且当小于max_id的id过来时,直接回复对方取消对方的重试定时器无效的网络通信,当接收到比max_id+1还要大的id时,可以直接告诉对方有消息漏洞,直接一个批量请求将消息法过来即可。
这都有助于降低整个网络延迟,但是对于plato来说,他是构建在tcp上的协议,tcp至少保证了消息从a端服务器协议栈到达b端服务器协议栈,那么消息丢失的可能性已经非常非常小了,维护如此复杂的通信协议,对于业务层来说是不必要的,所以最简单的做法就是 check max_id+1这一条规则即可,大于和小于max_id+1的id均被忽略即可。
但想要做到及时性,那么消息在服务器的转发过程就不能有同步落库的操作,因为落库会严重限制并发度(数据层是共享资源),必须保证整个收发消息的流程中不会有全局共享的资源,以便于高度并发,这其中的瓶颈就需要异步落库的方式来保证,将消息存入MQ中(MQ保证消息不丢失)后就要回复上行消息ack,然后MQ异步消费到此消息时对其分配msg_id,然后发送push,下推到客户端B完成下行消息。
上行消息可靠
基于上述描述的消息可靠性模型来看,需要一个具有唯一且严格递增的消息ID才行,这个id必须由消息产生的源头来生成否则无法定序。
所以对于上行消息来说,客户端要维护一个client_id,他是客户端自行生成的,在会话维度的一个严格递增的uint64的id。
当客户端与服务端建立长连后进行登陆时,必须把此client_id携带过来,这样就完成了服务端的初始化,state server会将其记录到自身的session state中,作为max_id来保证上行消息可靠。
当断线快速重连时,session state被复用,max_id也会被复用不受影响。
当用户换了一个设备登陆时,由于也创建了一个新的连接不会有之前发过的消息存在,此时client_id从0开始自增即可。
每次state server都会比较max_client_id+1 是否等于当前的client_id,如果是则放行此消息写入MQ,并回复ack,否则直接忽略即可(简单可靠)。
下行消息可靠
当消息进入MQ后就已经进入了下行消息的阶段,此时由MQ保证消息的可靠性;消息最终会被消费,业务层消费消息,为其分配msg_id 。msg_id 用来保证消息的可靠性,其唯一性是在一次session范围内的,毕竟一条消息脱离了会话框是没有意义的。
msg_id 由服务端分配,服务端就必须考虑竞争全局资源造成的并发性问题,每发一个消息就要分发一个msg_id,QPS至少是万亿级别,肯定是性能瓶颈所在,其次服务端要维护大量会话的msg_id的严格递增,在可用性上也具有极大的挑战,这是一个分布式ID生成经典场景。
对于服务端来说,要维护整个会话消息的有序性,这与上行消息中的client_id极其不同,client_id在设备切换后建立新的连接后可以从0开始,而不用一直保持着有序性。
推送给客户端B时,客户端B用其进行排序,如果发现有缺失id,则主动发送pull请求进行拉取,每次客户端仅接收mag_msg_id+1的id消息,一旦存在消息漏洞,则直接拉取消息,这样的好处是可以减少下行消息的重试机制(这需要大量的内存资源) ,消息补洞操作和消息漫游同步历史消息的接口可以是一个,这样简化了整体设计,用拉模式作为推送失败的兜底手段,可以保证消息的有序性。
但是仅靠拉模式还是不够,没有消息回执的话,将会导致消息不能及时收到,比如如果客户端B收到客户端A发送的第一条消息,就在服务端下行push的时候丢失了,客户端B并不知道最新的msg_id是什么,他也就不知道消息存在漏洞,此时要么他还是轮询来拉,但这就导致无效的网络请求变多,失去了push消息的意义,要么就要让服务端进行超时重试,这就会对state server增加内存成本,但这是必要的,否则会影响下行消息的及时性,极度影响用户体验。
一种有效的优化方案是,在state server中对conn state仅保存一个msgTimer,那就是 对于当前链接最新的push消息,仅对这个push消息进行超时重试即可,如果有新的push消息到来可以直接将之前的定时器取消(这就是为什么时间轮的优势比较大,当然这里后面还可以再优化),此时state server内存中仅保留最新push的msg,这样既解决了最后push消息丢失客户端无感知的问题,也解决了维护大量消息重发定时器的资源浪费,此时客户端B仅需要回复最新消息的ack即可,其他消息不需要回复,一旦出现消息漏洞直接走兜底的pull模式拉取数据即可,这样降低了对长链接的依赖,弱网环境下表现更好。
在极端情况下,像微信等应用为了应对弱网环境,都会退化为pull模式,所以pull模式会作为一种兜底手段,较好的补充了长链接的不稳定性。
这种方式最大的问题是需要一个严格递增的消息ID,这就是一个老大难的问题了。
先要确认的是这个ID的生成不能是单点的否则会有可用性问题,因此需要分布式ID生成,分布式ID的生成很难保证严格递增这个特性,因为ID生成通常在10ms以下进行响应,这导致数据密集型系统中常用复制来存储多个数据副本来提高可用性的这个手段无效(任何复制都需要通过网络,难以保证在10ms以下的p99) ,那要如何进行设计呢?
首先,必须要明确这个ID生成器的生命周期,在一个会话创建时被创建,在会话终止时被回收,所以ID生成器只需要保证在一个会话范畴内严格增即可,那么uint64的id足够了。
seq_id是不需要担心由于其顺序性被人爬取,会通过其他一次一密的策略保证消息安全性,后续会详细讲解。
其次,我们其实不需要完全严格递增的id,下行消息的通信过程中可以允许偶尔的跳变,因为可以使用拉模式进行消息补洞,那么如果某一次msg_id 123 紧接着的msg_id 为300,那么客户端只需要进行一次拉取请求即可,从time line 中拉取消息进行消息补洞(当然不存在msg_id124的消息,拉取会是失败的,但此时我们是可以定义某种错误码来通知客户端的),所以我们仅需要一个全生命周期中大多数时候保持严格递增而在少数场景允许跳变的id,当然跳变不允许id出现回推,即id生成了msg_id 122,这将直接导致系统崩溃乱序。
那么,下一个问题是如何保证连续递增? 如果使用redis(没错,万物皆可redis😁),那么使用incrby这个命令即可实现一个连续递增的ID,但是当redis的master切换时将会受限于redis的主从同步延迟,出现ID的跳变问题,解决办法通常是通过lua脚本来实现,但这样有悖于云原生的理念,小型/中型公司 通常都用redis做分布式ID生成了,但是 这样通常不好运维,因为没有持久化,稍微有点问题,可能数据就丢失了,造成消息混乱,其次内存容量大小有限,单机存不了太多,分布式的话,lua脚本很难保证云原生的特性(频繁的容器调度会导致一些不一致性,lua脚本容易造成崩溃)
解决的办法是设计一个完善的分布式ID生成系统,这个系统应该专门为消息ID来设计,考虑连续性与递增性,同时一个msgID的有序性仅在一个会话的生命周期中存在。
使用存算分离设计方式,底层使用raft维护的分布式kv保证持久数据的一致性并突破单机容量的限制,对于sessionID对应作为一个key,value是一个uint64的值,每次执行incrby操作。
为了请对万亿级的并发数,需要有一个缓存层,但是需要避免缓存层与存储层的交互减少IO放大,
在缓存节点中每次进行分段划分,对于每个key存储 cur_seq与max_seq,每次当cur_seq自增到max_seq的时候,再去存储层按step去请求一次分段,max_seq = cur_seq+step。
对于sdk层,使用向redis cluster模式的slot概念进行key的hash划分即可,这样当进行扩容或者失败重启时直接去存储层load一次max_seq即可,此时会发生跳变,id的跳变。因此我们能做到给业务方的承诺就是大多数情况下seq_id是严格递增的,在扩容与重启的时候会发生跳表但一定保证递增性。
Cache server 是可以水平扩展的,根据业务的容量规划,划分合理的slot,理论上可以无限扩展,存储层使用raft算法联合成 kv 集群提供分布式存储能力,关于分布式的seq_id系统plato第二部分会详细实现,讲述其中细节。
因此业务上需要通过客户端主动拉取的方式来进行消息补洞,这一操作即可作为消息漫游时的历史消息同步,也可以作为群聊消息风暴时的批处理优化,又可以作为下行消息丢失时的补洞操作,一举三得。
任务分解
实现上行消息可靠
a.
客户端生成client_id
b.
登录时记录到state server的状态中
c.
断线重连,max_client_id被复用(connID复用,那么其中状态也自然被复用)
d.
断开连接后,state server的conn 应该被回收
e.
State server check client_id 调用rpc写入业务
f.
在业务rpc回复ok后对max_client_id自增
g.
并回复客户端ack
实现下行消息可靠
a.
业务push消息发送给state server,只需要直接转发即可。
b.
客户端检查下行消息的msg_id 是否等于max_msg_id + 1,如果是则直接显示。
c.
如果不是,则调用pull请求进行兜底拉取,通过msg_id作为offset取历史同步接口进行拉取。
d.
客户端每次都要将接收确认的msg_id进行持久化保存(有的一些im会将其称之为seq_id)。
实现 State Server(三) -分布式化(高可用&可伸缩性)
背景介绍
plato目前state server中的状态,都存储在本地内存中这就导致如果state server进程退出后状态将完全丢失,这样就违背了设计的初衷,将gateway server与state server进行拆分,就是为了将state server设计成无状态服务,可以满足迭代的效率需求不断重启升级,gateway server持有长链接轻易不重启,提供最大程度的稳定性。
无状态带来的好处是显而易见的,第一是可伸缩性,可以通过增加机器进行水平扩展应对长连接的增长。 第二是高可用性,可用性体现在state server可以重启,重启时将进行故障恢复只要长连接被持有服务就可以正常运行用户无感知。
实现我们服务可靠性的终极目标: 客户端/gateway server/ip conf server/state server 系统内任意组件的异常都不会影响用户使用体验。
目标及约束
因此本章节的目标就是改造state server,将其无状态服务化,以提供较高的可伸缩性与可用性。
【无状态】服务可随时重启,不会因为重启造成系统功能不可用,对用户无感知,同时尽可能的支持水平扩展。
【低延迟】无状态化必然是以中心化存储为代价的,则必然存在跨机通信,这将增加延迟。
技术方案
1.如何标记一个唯一的连接?
connID是gateway在本地生成的,分布式化面对的第一个问题就是需要将连接状态能跟跨时空的唯一标识。
一个连接目前从属于一个gateway进程,所以通过endpoind+connID可以跨机器唯一标记,但是如果gateway server重启后,connID将重新计数,则有可能导致两个连接拥有同一个标记。那么再加上一个运行时标识runID,即runID+endpoint+connID唯一标记一个连接。如果runID和endpoint都是字符串的话,那么gateway server处理connID的生成以及传输时的网络带宽都将会增加,应该通过一些简单的编码技巧对其进行改造。
connID是64位的,一个进程生成的连接数实际上远远用不了64位的整数去存储,事实上单机100w应该是一个极限,生产环境中不会有人会把100w的用户寄托在一台机器上,继续优化单机毫无意义。
所以本质上,我们就是要在分布式的场景下能够生成一个单机的唯一的int64 类型的ID即可,这就是典型的雪花算法。
我们直接使用雪花算法,来生成一个唯一id即可,他只要在单机上可保持全局高并发且全局唯一即可。
最高位是无效位,用来今后的灰度升级,41位是精确度毫秒的时间戳,剩下的10位表示当前的节点编号,通常前5位表示数据中心,后5位表示数据中心中机器的编号,10位可以表示1024台机器,如果每台机器容纳50w长连接,可以同时在线5亿条长连接,最后12位表示在这1毫秒内的一个并发序号(是连续递增的),事实上如果今后接入层真的有超过5亿长连接的场景,可以降低后面的12位序号,因为连接的创建单机通常不用保持这么高的并发,需要的时候可以改造雪花算法。
2.State server 如何改造成无状态服务?
首先要将连接的connID存储在redis中,在state server重启时进行batch的读取操作,因此较为适合使用redis的set数据结构,因为connID都是int64的整数可以在redis中较好的压缩,占用空间较小,通常分布式的redis场景下,一个set数据集合中元素的个数不要超过5k否则我们将其称之为大Key,所以必须对set集合进行分片,否则存储不下这么多的connID,因此我们使用connID进行shard,hash(connID % 1024) 将得到一个slot,使用slot作为set的key,确保同一个connID 一定落入到该slot中,这样我们才能可伸缩性的对其进行读取。
在state server重启进行读取的时候要去加载set中的数据,在其配置中划分其所要读取的slot的range信息即可,初始化阶段遍历slot批量读取set集合中的connID,基于connID的信息恢复一些信息。
但是这里需要注意的是,如果state server 写入slot是hash的,也就是说他可以任意的写入不同的slot上,但是当state server宕机重启后,仅取加载其配置的slot上的conn信息,这就导致conn的状态发生了迁移,不再原来的state server上,但是按照现在的架构必须保证gateway server请求唯一的state server,否则将找不到对应的状态。如此说来conn信息的迁移将导致conn状态的丢失,因此hash是可行的。
为此,我们只能是配置的slot,state server的connID只能写入到配置的slot上,并重启时加载配置slot中的connID即可,如此才能实现state server的重启。
注: 这样的设计将使得state server难以扩缩容,这就是有状态服务相对于web server的技术挑战所在,对于状态我们只是将其分布式存储而已。
当connID我们读取到state server内存后要做什么? 第一就是要检查一下是不是在崩溃之前存在没有push下去的消息,所以我们得把msgTimer以及msg存储下来,这需要涉及复杂的状态转移,但是我们其实可以简化这个设计。
仔细想想,如果长连接的作用不是数据消息仅仅是发送控制消息,也就是通知客户端这里有新的消息,你可以来拉取,也就是所谓的推拉结合的设计,这样即解决了轮询造成的网络风暴以及时延问题,也可以简化消息的存储。
State server 仅需要存储当前连接最后一次push的消息即可,称之为lastMsg并且启动一个定时器,该定时器要加上一个lock来保证客户端ack时确实ack的是当前的lastMsg否则会导致 连续push消息时ack乱序将lastMsg确认,导致消息丢失问题。这个lock就是lastMsg的一些元信息即可,比如connID与msgID二者确认一定是当前ack的lastMsg,如果连接在state server崩溃期间也崩溃了,这并不要紧,因为客户端连接登陆后会主动的拉一次消息。
而msgTimer其实可以忽略,因为只要通过connID拿到lastMsg,再启动一次定时器即可,最坏情况也是等待两个定时周期后将其再次rePush,用户几乎无感知,并且这样设计可以大大简化state server的状态机,如果重启后连接也崩溃了,重启的msgTimer会正常运行在两个定时周期清除redis中的lastMsg。
下行消息ack的时候,正常回收lastMsg数据即可。
第二步,我们需要恢复连接的心跳功能,如果state server启动后,connID所对应的连接也失效了怎么办? 重启后的连接如果没有失效那也需要恢复其心跳定时器,这样才能继续实现连接可靠性。所以我们只需要恢复connID对应的心跳定时器即可,同样最坏情况是这个连接已经失效,那么在心跳周期后进入重连,重连也超时后就会直接清理掉该连接的状态实现整个连接状态生命周期的闭环。
第三步,就是对上行消息中max_client_id的恢复,为保证上行消息的强可靠性需要将其存储在redis中,但是他的生命周期是什么呢?应该在login connID的时候由客户端自行初始化其值,在每次上行消息中进行比较并自增操作,其生命周期跨越一个连接,应该是在一次回话内保持自增,属于业务范畴,为保证redis中内存的最终回收,在连接断开时由重连定时器过期时触发redis中key的删除,以此实现上行消息的完整可靠性保证。
3.分布式场景下gateway与state server如何交互?
针对以上的设计,state server本质上还是维护了一些定时状态,如果同一个连接的处理过程不在一个state server上进行,则会导致错误发生,例如在stateA上进行了连接,然后重连请求却发送给了stateB,这将导致stateB认为这是一个过期请求而忽略,stateA在重连定时器超时后将连接状态断开,用户就会感知到莫名其妙的随机断线。
再例如,gateway持有长连接socket,那么state server必须在push消息时正确的找到socket所在的gateway机器本身,才能发送push rpc,否则下行消息将发送失败。
因此在分布式场景下需要一个router sdk,来作为gateway和state 交互时的路由表,为什么是sdk而不是一个server?这是为了减少不必要的网络消耗,本地化可以减少多次网络调用。
这个router sdk 对于点对点通信提供两个接口,一个是add,一个是query,在state server注册连接时接入路由信息,key是did,value是endpoint+connID。 直接通过rpc进行调用,这里还要实现一个del操作,在连接登出时调用,那就得在connState中存储did信息,这样在close连接状态时才能删除远程的路由记录。
对于群组通信,如果push 量过大可以使用消息队列消峰填谷,但是为了防止重复消费造成网络带宽浪费,可以使每个state server消费,确定的几个分区数据,通常是一个。
因此整个接入层网关就实现了,水平扩展的能力,但目前还无法做到动态的扩缩容,我们会在之后的架构升级中,逐步完善设计。
为了减缓业务层的操作,router Sdk 可以维护sessionID到did的倒排列表,在发送消息时,通过mget did操作获得分区号和endpoint 以及connID信息,通过分区号将消息发送给对应的state server然后其内部通过connID进行内部路由找到,长链socket 完成push 通信, 这一步骤的开发将在业务层中实现。
这里还要说明一点,为了保证同一个gateway持有的长连接状态都能够被同一个state server处理,最简单的方式就是确保gateway server仅与一个state server进行通信,这种方式降低了下游state server的可用性(一个state srever挂掉后该gateway上的连接将不再可用),但实现简单易于扩展(类似于kafka的架构),为此state server的可靠性将依赖其快速重启的能力,由于上述的数据都存储在分布式的缓存中,可以在秒级完成重启,对外继续提供长连服务。
基于当前的架构,我们发现,整体上就是一个分片策略,关于连接的状态都是完全隔离的,因此我们其实在设计connID时不需要考虑节点编号,只需要最高位作为灰度发布位空闲下来,接下来的47位用来存储时间戳,这已经完全够用了,剩下的16位用来代表当前时间戳下的自增计数位。
任务分解
ConnID 全局化改造
Gateway 绑定 state server改造
Sate server 绑定 gateway改造
State server根据slot操作redis的shard
完成router sdk的接口开发 add和query(MQ逻辑和RPC逻辑在im server中开发)
State server 重启故障恢复
保证 up msg 中max_client_msg的连续性