开云在线本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。

  本文中针对这套架构和系统设计,同时还会提供完整的源码,比较适合有一定Java开发能力和Netty知识的IM初学者。

  * 友情提示:如果你对IM即时通讯的基础技术理论了解的太少,建议可以先读:《新手入门一篇就够:从零开发移动端IM》。

  Netty 是一个 Java 开源框架。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。

  也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。

  Netty 相当简化和流线化了网络应用的编程开发过程,例如,TCP 和 UDP 的 Socket 服务开发。

  Netty源码和API 在线.x 完整源码(在线.x API文档(在线、整体架构设计概览

  client每个设备会在本地存每一个会话,保留有最新一条消息的顺序 ID;

  为了避免client宕机,也就是退出应用,保存在内存的消息ID丢失,会存到本地的文件中;

  3)client需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发;

  3)客户端发号器不需要像类似服务器端发号器那样集群部署,不需要考虑集群同步问题。

  1)每次重新建立链接后进行重置,将sequence_id(int表示)从0开始进行严格递增;

  2)客户端发送消息会带上唯一的递增sequence_id,同一条消息重复投递的sequence_id是一样的;

  3)后端存储每个用户的sequence_id,当sequence_id归0,用户的epoch年代加1存储入库,单聊场景下转发给接收者时候,接收者按照sequence_id和epoch来进行排序。

  客户端会发起重连,跟服务器申请重连的新的服务器IP,系统提供合适的算法来平摊gate层的压力,防止雪崩效应;

  2)gate层定时上报本机的元数据信息以及连接数信息,提供给LSB中心,LSB根据最少连接数负载均衡实现,来计算一个节点供连接。

  3)根据协议分类将入口请求打到不同的网关上去,HTTP网关接收HTTP请求,TCP网关接收tcp长连接请求;

  3)负责存储uid和gate层机器ID关系(有状态:多级缓存避免和中间件多次交互。无状态:在业务初期可以不用存);

  网关层到服务层,只需要单向传输发请求,网关层不需要关心调用的结果。而客户端想要的ack或者notify请求是由SDK发送数据到网关层,SDK也不需要关心调用的结果,最后网关层只转发数据,不做额外的逻辑处理。

  SDK和所有的网关进行长连接,当发送信息给客户端时,根据路由寻址信息,即可通过长连接推送信息。

  1)headData:头部标识,协议头标识,用作粘包半包处理。4个字节;

  2)version:客户端版本。4个字节;3)cmd:业务命令,比如心跳、推送、单聊、群聊。1个字节;

  5)logId:调试性日志,追溯一个请求的全路径。4个字节;6)sequenceId:序列号,可以用作异步处理。4个字节;

  2.《IM通讯协议专题学习(一):Protobuf从入门到精通,一篇就够!》

  3.《IM通讯协议专题学习(二):快速理解Protobuf的背景、原理、使用、优缺点》

  4.《IM通讯协议专题学习(三):由浅入深,从根上理解Protobuf的编解码原理》

  5.《IM通讯协议专题学习(四):从到Protobuf,详解Protobuf的数据编码原理》

  6.《IM通讯协议专题学习(五):Protobuf到底比JSON快几倍?全方位实测!》

  3)服务端需要超时与重传,具体做法就是增加ack队列和定时器Timer;

  业务侧兜底保证,客户端拉消息通过一个本地的旧的序列号来拉取服务器的最新消息;

  为了保证消息必达,在线客户端还增加一个定时器,定时向服务端拉取消息,避免服务端向客户端发送拉取通知的包丢失导致客户端未及时拉取数据。

  3.《IM消息送达保证机制实现(二):保证离线.《IM开发干货分享:如何优雅的实现大量离线.《理解IM消息“可靠性”和“一致性”问题,以及解决方案探讨》

  超时与重传机制将导致接收的client收到重复的消息,具体做法就是一份消息使用同一个消息ID进行去重处理。

  网络传输和多线程,网络传输不稳定的话可能导致包在数据传输过程中有的慢有的快。多线程也可能是会导致时序不一致影响的因素。

  缺点:本地时间戳不准确或者本地序列号在意外情况下可能会清0,都会导致发送方的绝对时序不准确

  群聊:因为发送方多点发送时序不一致,所以通过服务器的单点做序列化,也就是通过ID递增发号器服务来生成seq,接收方通过seq来进行展现时序。

  实现方式:通过服务端统一生成唯一趋势递增消息ID来实现或者通过redis的递增incr来实现。

  缺点:redis的递增incr来实现,redis取号都是从主取的,会有性能瓶颈。ID递增发号器服务是集群部署,可能不同发号服务上的集群时间戳不同,可能会导致后到的消息seq还小。

  群聊时序的优化:按照上面的群聊处理,业务上按照道理只需要保证单个群的时序,不需要保证所有群的绝对时序,所以解决思路就是同一个群的消息落到同一个发号service上面,消息seq通过service本地生成即可。

  为什么要保证顺序?因为消息即使按照顺序到达服务器端,也会可能出现:不同消息到达接收端后,可能会出现“先产生的消息后到”“后产生的消息先到”等问题。所以客户端需要进行兜底的流量整形机制

  如何保证顺序?可以在接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号就将当前消息追加在会话里。否则继续往前查找倒数第二条、第三条等消息,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。

  pull要考虑到哪些好友和群收到了消息,要循环每个群和好友拿到消息列表,读扩散。

  本地设置一个全局的状态,当客户端拉取完离线(表示离线消息拉取完毕)。当客户端收到拉取实时消息,会启用一个轮询监听这个状态,状态为1后,再去向服务器拉取消息。

  如果是push消息过来(不是主动拉取),那么会先将消息存储到本地的消息队列中,等待客户端上一次拉取数据完毕,然后将数据进行合并即可。

  接入层网关需要有接收通知包或者下行接收数据的端口,并且需要另外开启线程池。应用层网关不需要开端口,并且不需要开启线)接入层网关需要保持长连接,接入层网关需要本地缓存channel映射关系。应用层网关无状态不需要保存。

  批量ACK:每条群消息都ACK,会给服务器造成巨大的冲击,为了减少ACK请求量,参考TCP的Delay ACK机制,在接收方层面进行批量ACK;

  群消息和成员批量加载以及懒加载:在真正进入一个群时才实时拉取群友的数据;

  群离线消息过多:群消息分页拉取,第二次拉取请求作为第一次拉取请求的ack;

  对于消息未读数场景,每个用户维护一个全局的未读数和每个会话的未读数,当群聊非常大时,未读资源变更的QPS非常大。这个时候应用层对未读数进行缓存,批量写+定时写来保证未读计数的写入性能;

  路由信息存入redis会有写入和读取的性能瓶颈,每条消息在发出的时候会查路由信息来发送对应的gate接入层,比如有10个群,每个群1W,那么1s100条消息,那么1000W的查询会打满redis,即使redis做了集群。优化的思路就是将集中的路由信息分散到msg层 JVM本地内存中,然后做Route可用,避免单点故障;

  6)存储的优化:扩散写写入并发量巨大,另一方面也存在存储浪费,一般优化成扩散读的方式存储;

  2.《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》

  本地会话信息由一个hashmap保持,导致锁机制严重,按照用户标识进行hash,讲会话信息存在多个map中,减少锁竞争。同时利用双buffer机制,避免未读计数写入阻塞。

  消息下发到群聊服务后,需要发送拉取通知给接收者,具体逻辑是群聊服务同步消息到路由层,路由层发送消息给接收者,接收者再来拉取消息。

  如果消息连续发送或者对同一个接收者连续发送消息频率过高,会有许多的通知消息发送给路由层,消息量过大,可能会导致logic线程堆积,请求路由层阻塞。

  发送者发送消息到逻辑层持久化后,将通知消息先存放一个队列中,相同的接收者接收消息通知消息后,更新相应的最新消息通知时间,然后轮训线程会轮训队列,将多个消息会合并为一个通知拉取发送至路由层,降低了客户端与服务端的网络消耗和服务器内部网络消耗。

  好处:保证同一时刻,下发线程一轮只会向同一用户发送一个通知拉取,一轮的时间可以自行控制。

  比如正在发包的时候,不需要发送心跳。等待发包完毕后在开启心跳。并且自适应心跳策略调整。

  《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》

  《融云技术分享:融云安卓端IM产品的网络链路保活技术实践》《移动端IM实践:实现Android版微信的智能心跳机制》

  2)消息服务在发消息给user时候,会查询Redis的路由信息,用来发送消息给哪个一个网关。

  消息服务定时任务同步路由信息到本地缓存,如果redis挂了,从本地缓存拿消息;

  网关服务在收到用户侧的上线和下线后,会同步广播本地的路由信息给各个消息服务,消息服务接收后更新本地环境数据;

  网络交互次数多,以及消息服务多,可以用批量或者定时的方式同步广播路由消息给各个消息服务。

  2)对于在线的用户,收到群消息后,修改这个last_ack_msg_id;

  7)取成功后,需要保证红包剩余金额、新插入的红包流水数据、队列中的红包数据以及群成员的余额账户金额一致性;

  《社交软件红包技术解密(一):全面解密QQ红包技术方案——架构、技术实现等》

  《社交软件红包技术解密(九):谈谈手Q红包的功能逻辑、容灾、运维、架构等》

  《社交软件红包技术解密(十):手Q客户端针对2020年春节红包的技术实践》

  《社交软件红包技术解密(十二):解密抖音春节红包背后的技术设计与实践》

  1)A打包数据发送给服务端,服务端接收消息后,根据接收消息的sequence_id来进行客户端发送消息的去重,并且生成递增的消息ID,将发送的信息和ID打包一块入库,入库成功后返回ACK,ACK包带上服务端生成的消息ID。

  3)如果没有本地消息ID则存入,并且返回接入层ACK信息;如果有则拿本地sequence_id和推送过来的sequence_id大小对比,并且去重,进行展现时序进行排序展示,并且记录最新一条消息ID。最后返回接入层ack。

  5)如果用户B不在线,首先将消息存入库中,然后直接通过手机通知来告知客户新消息到来。

  6)用户B上线后,拿本地最新的消息ID,去服务端拉取所有好友发送给B的消息,考虑到一次拉取所有消息数据量大,通过channel通道来进行分页拉取,将上一次拉取消息的最大的ID,作为请求参数,来请求最新一页的比ID大的数据。

  1)登录,TCP连接,token校验,名词检查,sequence_id去重,生成递增的消息ID,群消息入库成功返回发送方ACK。

  2)查询群G所有的成员,然后去redis中央存储中找在线状态。离线和在线成员分不同的方式处理。

  4)在线成员过来拉取,会带上这个群标识和上一次拉取群的最小消息ID,服务端会找比这个消息ID大的所有的数据返回给客户端,等待客户端ACK。一段时间没ack继续推送。如果重试几次后没有回ack,那么关闭连接和清除ack等待队列消息。

  5)客户端会更新本地的最新的消息ID,然后进行ack回包。服务端收到ack后会更新群成员的最新的消息ID。6)离线成员:发送手机通知栏通知。离线成员上线后,拿本地最新的消息ID,去服务端拉取群G发送给A的消息,通过channel通道来进行分页拉取,每一次请求,会将上一次拉取消息的最大的ID,作为请求参数来拉取消息,这里相当于第二次拉取请求包是作为第一次拉取的ack包。

  7)分页的情况下,客户端在收到上一页请求的的数据后更新本地的最新的消息ID后,再请求下一页并且带上消息ID。上一页请求的的数据可以当作为ack来返回服务端,避免网络多次交互。服务端收到ack后会更新群成员的最新的消息ID。

  21.1相比传统HTTP请求的业务系统,IM业务系统的有哪些不一样的设计难点?

  相比于HTTP请求的业务系统,接入层有状态,必须维持心跳和会话状态,加大了系统设计复杂度。

  请求通信模型不一样。相比于HTTP请求一个request等待一个response通信模型,IM系统则是一个数据包在全双工长连接通道双传输,客户端和服务端消息交互的信令数据包设计复杂。

  21.2对于单聊和群聊的实时性消息,是否需要MQ来作为通信的中间件来代替rpc?

  当用消息中间代替路由层的时候,gate层需要广播消费消息,这个时候gate层会接收大部分的无效消息,因为这个消息的接收者channel不在本机维护的session中。

  是否考虑使用MQ需要架构师去考量,比如考虑业务是否允许、或者系统的流量、或者高可用设计等等影响因素。本项目基于使用成本、耦合成本和运维成本考虑,采用Netty作为底层自定义通信方案来实现,也能同样实现层级调用。

  《阿里IM技术分享(九):深度揭密RocketMQ在钉钉IM系统中的应用实践》。

  因为TCP Keepalive状态无法反应应用层状态问题,如进程阻塞、死锁、TCP缓冲区满等情况。并且要注意心跳的频率,频率小则可能及时感知不到应用情况,频率大可能有一定的性能开销。

  参考资料:《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》、《彻底搞懂TCP协议层的KeepAlive保活机制》。

  IM消息是非常庞大的,比如说群聊相关业务、推送,对于一些业务上可以忍受的场景,尽量使用MQ来解耦和通信,来降低同步通讯的服务器压力。21.6群消息存一份还是多份,读扩散还是写扩散?

  存多份的话(也就是写扩散)下同一条消息存储了很多次,对磁盘和带宽造成了很大的浪费。可以在架构上和业务上进行优化,来实现读扩散。

  当然,对于IM是使用读扩散还是写扩散来实现,这需要根据IM产品的业务定位来决定。比如微信就是写扩散(详见《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》),而钉钉却是读扩散(详见《深度解密钉钉即时消息服务DTIM的技术设计》)。

  严格递增会有单点性能瓶颈,比如MySQL auto increments。

  redis性能好但是没有业务语义,比如缺少时间因素,还可能会有数据丢失的风险,并且集群环境下写入ID也属于单点,属于集中式生成服务。

  小型IM可以根据业务场景需求直接使用redis的incr命令来实现IM消息唯一ID。

  本项目采用snowflake算法实现唯一趋势递增ID,即可实现IM消息中,时序性,重复性以及查找功能。

  《微信的海量IM聊天消息序列号生成实践(算法原理篇)》《微信的海量IM聊天消息序列号生成实践(容灾方案篇)》

  gate会接收客户端的连接请求(被动),需要外网监听端口;entry会主动给logic发请求(主动);entry会接收服务端给它的通知请求(被动),需要内网监听端口。一个端口对内,一个端口对外。

  21.9用户的路由信息,是维护在中央存储的redis中,还是维护在每个msg层内存中?

  维护在中央存储的redis中,msg层无状态,redis压力大,每次交互IO网络请求大。

  网关层到服务层,只需要单向传输发请求,网关层不需要关心调用的结果。而客户端想要的ack或者notify请求是由SDK发送数据到网关层,SDK也不需要关心调用的结果,最后网关层只转发数据,不做额外的逻辑处理。

  SDK和所有的网关进行长连接,当发送信息给客户端时,根据路由寻址信息,即可通过长连接推送信息

  本地TCP写操作成功,但数据可能还在本地写缓冲区中、网络链路设备中、对端读缓冲区中,并不代表对端应用读取到了数据。

  如果你还不理解,可以读读这篇文章《从客户端的角度来谈谈移动端IM的消息可靠性和送达机制》。

  netty堆外内存管理,减少GC压力,jvm管理的只是一个很小的DirectByteBuffer对象引用;

  tomcat读取数据和写入数据都需要从内核态缓冲copy到用户态的JVM中,多1次或者2次的拷贝会有性能影响。

  《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》

  《跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM》

  《跟着源码学IM(四):拿起键盘就是干,教你徒手开发一套分布式IM系统》

  《跟着源码学IM(五):正确理解IM长连接、心跳及重连机制,并动手实现》

  《跟着源码学IM(六):手把手教你用Go快速搭建高性能、可扩展的IM系统》

  《跟着源码学IM(七):手把手教你用WebSocket打造Web端IM聊天》

  《跟着源码学IM(八):万字长文,手把手教你用Netty打造IM聊天》

  《跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)》

  《跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)》(* 本文)

  《SpringBoot集成开源IM框架MobileIMSDK,实现即时通讯IM聊天功能》

  [1] 史上最通俗Netty框架入门长文:基本介绍、环境搭建、动手实战

  [3] IM通讯协议专题学习(一):Protobuf从入门到精通,一篇就够!

  平台声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。