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

Posted im中国人

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了跟着源码学IM:一套基于Netty的分布式高可用IM详细设计与实现(有源码)相关的知识,希望对你有一定的参考价值。

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

本文由will分享,个人博客zhangyaoo.github.io,原题“基于Netty的IM系统设计与实现”,有修订和重新排版。

1、引言

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

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

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

 

技术交流:

(本文已同步发布于:http://www.52im.net/thread-4257-1-1.html

2、配套源码

本文配套源码的开源托管地址是:

如果你访问Github太慢,可直接从以下附件打包下载:

 fastim-master(52im.net).zip (1.12 MB , 下载次数: 5 , 售价: 1 金币)

完整源码的目录结构,如下图:

3、知识准备

关于 Netty 是什么,这里简单介绍下:

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

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

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

有关Netty的入门文章:

1)新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析

2)写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略

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

如果你连Java NIO都不知道,下面的文章建议优先读:

Netty源码和API 在线查阅地址:

4、整体架构设计概览

本次的IM系统设计主要基于可扩展性高可用原则,把网关层、逻辑层、数据层进行了分离,并且还要支持分布式部署。

以下是整体系统的架构设计概览图:

下面将针对整体架构来逐一分享设计的主要思路等。

5、整体架构设计之客户端设计

5.1客户端设计

客户端的设计主要从以下几点出发:

  • 1)client每个设备会在本地存每一个会话,保留有最新一条消息的顺序 ID;
  • 2)为了避免client宕机,也就是退出应用,保存在内存的消息ID丢失,会存到本地的文件中;
  • 3)client需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发;
  • 4)客户端本地生成一个递增序列号发送给服务器,用作保证发送顺序性。该序列号还用作ack队列收消息时候的移除。

5.2客户端序列号设计

1)方案一:

设计思路:

  • 1)数据传输中的大小尽量小用int,不用bigint,节省传输大小;
  • 2)只保证递增即可,在用户重新登录或者重连后可以进行日期重置,只保证单次;
  • 3)客户端发号器不需要像类似服务器端发号器那样集群部署,不需要考虑集群同步问题。

注:上述生成器可以用18年[(2^29-1)/3600/24/365]左右,一秒内最多产生4个消息。

优点:可以在断线重连和重装APP的情况下,18年之内是有序的。

缺点:每秒只能发4个消息,限制太大,对于群发场景不合适。

改进:使用long进行传输,年限扩展很久并且有序。

2)方案二:

设计思路:

  • 1)每次重新建立链接后进行重置,将sequence_id(int表示)从0开始进行严格递增;
  • 2)客户端发送消息会带上唯一的递增sequence_id,同一条消息重复投递的sequence_id是一样的;
  • 3)后端存储每个用户的sequence_id,当sequence_id归0,用户的epoch年代加1存储入库,单聊场景下转发给接收者时候,接收者按照sequence_id和epoch来进行排序。

优点:可以在断线重连和重装APP的情况下,接收者可以按照发送者发送时序来显示,并且对发送消息的速率没限制。

6、整体架构设计之LSB设计

6.1思路

IM接入层的高可用、负载均衡、扩展性全部在这里面做。客户端通过LSB,来获取gate IP地址,通过IP直连。

这样做的目的是:

  • 1)灵活的负载均衡策略 可根据最少连接数来分配IP;
  • 2)做灰度策略来分配IP;
  • 3)AppId业务隔离策略 不同业务连接不同的gate,防止相互影响;
  • 4)单聊和群聊的im接入层通道分开。

6.2优化

上述设计存在一个问题:就是当某个实例重启后,该实例的连接断开后,客户端会发起重连,重连就大概率转移其他实例上,导致最近启动的实例连接数较少,最早启动的实例连接数较多。

解决方法:

  • 1)客户端会发起重连,跟服务器申请重连的新的服务器IP,系统提供合适的算法来平摊gate层的压力,防止雪崩效应;
  • 2)gate层定时上报本机的元数据信息以及连接数信息,提供给LSB中心,LSB根据最少连接数负载均衡实现,来计算一个节点供连接。

7、整体架构设计之GATE层网关设计

GATE层网关设计主要遵从以下几点:

  • 1)任何一个gate网关断掉,用户端检测到以后重新连接LSB服务获取另一个gate网关IP,拿到IP重新进行长连接通信(对整体服务可靠性基本没有影响);
  • 2)gate可以无状态的横向部署,来扩展接入层的接入能力;
  • 3)根据协议分类将入口请求打到不同的网关上去,HTTP网关接收HTTP请求,TCP网关接收tcp长连接请求;
  • 4)长连接网关,提供各种监控功能,比如网关执行线程数、队列任务数、ByteBuf使用堆内存数、堆外内存数、消息上行和下行的数量以及时间。

8、整体架构设计之LOGIC和路由SDK设计

logic按照分布式微服务的拆分思想进行拆分,拆分为多个模块,集群部署。

主要包括:

  • 1)消息服务;
  • 2)红包服务;
  • 3)其他服务。

消息logic服务集成路由客户端的SDK,SDK职责主要是:

  • 1)负责和网关底层通信交互;
  • 2)负责网关服务寻址;
  • 3)负责存储uid和gate层机器ID关系(有状态:多级缓存避免和中间件多次交互。无状态:在业务初期可以不用存);
  • 4)配合网关负责路由信息一致性保证。

针对上述第4)点:

  • 1)如果路由状态和channel通道不一致,比如有路由状态,没有channel通道(已关闭)那么,就会走离线消息流出,并且清除路由信息;
  • 2)动态重启gate,会及时清理路由信息。

SDK和网关底层通信设计:

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

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

9、通信协议设计

9.1目标

通信协议设计的主要目标是:

  • 1)高性能:协议设计紧凑,保证数据包小,并且序列化性能好;
  • 2)可扩展:针对后续业务发展,可以自由的自定义协议,无需较大改动协议结构。

9.2设计

IM协议采用二进制定长包头和变长包体来实现客户端和服务端的通信,并且采用谷歌protobuf序列化协议。

设计如下:

各个字段解释如下:

  • 1)headData:头部标识,协议头标识,用作粘包半包处理。4个字节;
  • 2)version:客户端版本。4个字节;
  • 3)cmd:业务命令,比如心跳、推送、单聊、群聊。1个字节;
  • 4)msgType:消息通知类型 request response notify。1个字节;
  • 5)logId:调试性日志,追溯一个请求的全路径。4个字节;
  • 6)sequenceId:序列号,可以用作异步处理。4个字节;
  • 7)dataLength:数据体的长度。4个字节;
  • 8)data:数据。

PS:如果你对Protobuf不了解,建议详读以下系列文章:

1.《强列建议将Protobuf作为你的即时通讯应用数据传输格式

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

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

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

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

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

7.《IM通讯协议专题学习(六):手把手教你如何在Android上从零使用Protobuf

8.《IM通讯协议专题学习(七):手把手教你如何在NodeJS中从零使用Protobuf

9.《IM通讯协议专题学习(八):金蝶随手记团队的Protobuf应用实践(原理篇)

10.《IM通讯协议专题学习(九):手把手教你如何在iOS上从零使用Protobuf

9.3实践

针对数据data,网关gate层不做反序列化,反序列化步骤在service做,避免重复序列化和反序列化导致的性能损失。

网关层不做业务逻辑处理,只做消息转发和推送,减少网关层的复杂度。

10、安全设计

为防止消息传输过程中不被截获、篡改、伪造,采用TLS传输层加密协议(可参考《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》)。

私有化协议天然具备一定的防窃取和防篡改的能力,相对于使用JSON、XML、HTML等明文传输系统,被第三方截获后在内容破解上相对成本更高,因此安全性上会更好一些。

消息存储安全性:将针对账号密码的存储安全可以通过“高强度单向散列算法”和“加盐”机制来提升加密密码可逆性;IM消息采用“端到端加密”方式来提供更加安全的消息传输保护。

安全层协议设计:基于动态密钥,借鉴类似SSL,不需要用证书来管理(可参考《探讨组合加密算法在IM中的应用》)。

11、消息投递设计

11.1概述

一个正常的消息流转需要如下图所示的流程:

如上图所示:

  • 1)客户端A发送请求包R;
  • 2)server将消息存储到DB;
  • 3)存储成功后返回确认ack;
  • 4)server push消息给客户端B;
  • 5)客户端B收到消息后返回确认ack;
  • 6)server收到ack后更新消息的状态或者删除消息。

需要考虑的是:一个健壮的IM系统需要考虑各种异常情况,比如丢消息,重复消息,消息时序问题。

11.2消息可靠性如何保证(不丢消息)

我的设计和实现思路是这样的:

  • 1)应用层ACK;
  • 2)客户端需要超时与重传;
  • 3)服务端需要超时与重传,具体做法就是增加ack队列和定时器Timer;
  • 4)业务侧兜底保证,客户端拉消息通过一个本地的旧的序列号来拉取服务器的最新消息;
  • 5)为了保证消息必达,在线客户端还增加一个定时器,定时向服务端拉取消息,避免服务端向客户端发送拉取通知的包丢失导致客户端未及时拉取数据。

相关资料可参考:

1.《从客户端的角度来谈谈移动端IM的消息可靠性和送达机制

2.《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递

3.《IM消息送达保证机制实现(二):保证离线消息的可靠投递

4.《IM开发干货分享:如何优雅的实现大量离线消息的可靠投递

5.《理解IM消息“可靠性”和“一致性”问题,以及解决方案探讨

6.《融云技术分享:全面揭秘亿级IM消息的可靠投递机制

11.3消息重复性如何保证(不重复)

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

相关资料可参考:

1.《IM群聊消息如此复杂,如何保证不丢不重?

2.《完全自已开发的IM该如何设计“失败重试”机制?

11.4消息顺序性如何保证(不乱序)

消息乱序影响的因素:

  • 1)时钟不一致,分布式环境下每个机器的时间可能是不一致的;
  • 2)多发送方和多接收方,这种情况下,无法保先发的消息被先收到;
  • 3)网络传输和多线程,网络传输不稳定的话可能导致包在数据传输过程中有的慢有的快。多线程也可能是会导致时序不一致影响的因素。

以上:如果保持绝对的实现,那么只能是一个发送方,一个接收方,一个线程阻塞式通讯来实现。那么性能会降低。

1)如何保证时序:

单聊:通过发送方的绝对时序seq,来作为接收方的展现时序seq。

实现方式:可以通过时间戳或者本地序列号方式来实现

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

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

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

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

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

2)客户端如何保证顺序:

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

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

相关资料可参考:

零基础IM开发入门(四):什么是IM系统的消息时序一致性?

一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等

如何保证IM实时消息的“时序性”与“一致性”?

一个低成本确保IM消息时序的方法探讨

12、消息通知设计

12.1概述

整体消息推送和拉取的时序图如下:

12.2消息拉取方式的选择

本系统是通过推拉结合来进行服务器端消息的推送和客户端的拉取。我们知道单pull和单push有以下缺点。

对于单pull:

  • 1)pull要考虑到消息的实时性,不知道消息何时送达;
  • 2)pull要考虑到哪些好友和群收到了消息,要循环每个群和好友拿到消息列表,读扩散。

对于单push:

  • 1)push实时性高,只要将消息推送给接收者就ok,但是会集中消耗服务器资源;
  • 2)并且再群聊非常多、聊天频率非常高的情况下,会增加客户端和服务端的网络交互次数。

对于推拉结合:

  • 1)推拉结合的方式能够分摊服务端的压力,能保证时效性,又能保证性能;
  • 2)具体做法就是有新消息时候,推送哪个好友或者哪个群有新消息,以及新消息的数量或者最新消息ID,客户端按需根据自身数据进行拉取。

12.3推拉隔离设计

为什么做隔离?

如果客户端一边正在拉取数据,一边有新的增量消息push过来。

如何做隔离?

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

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

相关资料可参考:

阿里IM技术分享(六):闲鱼亿级IM消息系统的离线推送到达率优化

阿里IM技术分享(七):闲鱼IM的在线、离线聊天数据同步机制优化实践

13、消息ID生成设计

以下是我设计的场景:

  • 1)单机高峰并发量小于1W,预计未来5年单机高峰并发量小于10W;
  • 2)有2个机房,预计未来5年机房数量小于4个 每个机房机器数小于150台;
  • 3)目前只有单聊和群聊两个业务线,后续可以扩展为系统消息、聊天室、客服等业务线,最多8个业务线。

根据以上业务情况,来设计分布式ID:

优点:

  • 1)不同机房不同机器不同业务线内生成的ID互不相同;
  • 2)每个机器的每毫秒内生成的ID不同;
  • 3)预留两位留作扩展位。

缺点:当并发度不高的时候,时间跨毫秒的消息,区分不出来消息的先后顺序。因为时间跨毫秒的消息生成的ID后面的最后一位都是0,后续如果按照消息ID维度进行分库分表,会导致数据倾斜。

两种解决方案:

  • 1)方案一:去掉snowflake最后8位,然后对剩余的位进行取模;
  • 2)方案二:不同毫秒的计数,每次不是归0,而是归为随机数,相比方案一,比较简单实用。

相关资料可参考:

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

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

解密融云IM产品的聊天消息ID生成策略

深度解密美团的分布式ID生成算法

开源分布式ID生成器UidGenerator的技术实现

深度解密滴滴的高性能ID生成器(Tinyid)

14、消息未读数设计

14.1基本

实现思路大致如下:

  • 1)每发一个消息,消息接收者的会话未读数+1,并且接收者所有未读数+1;
  • 2)消息接收者返回消息接收确认ack后,消息未读数会-1;
  • 3)消息接收者的未读数+1,服务端就会推算有多少条未读数的通知。

分布式锁保证总未读数和会话未读数一致:

  • 1)原因:当总未读数增加,这个时候客户端来了请求将未知数置0,然后再增加会话未读数,那么会导致不一致;
  • 2)保证:为了保证总未读数和会话未读数原子性,需要用分布式锁来保证。

14.2群聊消息未读数的难点和优化思路

对于群聊来说,消息未读数的技术难点主要是:一个群聊每秒几百的并发聊天,比如消息未读数,相当于每秒W级别的写入redis,即便redis做了集群数据分片+主从,但是写入还是单节点,会有写入瓶颈。

我的优化思路是:按群ID分组或者用户ID分组,批量写入,写入的两种方式:定时flush和满多少消息进行flush。

15、网关设计

15.1概述

本套IM系统在设计时,将网关分为了接入层网关和应用层网关两种。

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

15.2接入层网关设计

我的设计目标是:

  • 1)网关的线程池实现1+8+4+1,减少线程切换;
  • 2)集中实现长连接管理和推送能力;
  • 3)与业务服务器解耦,集群部署缩容扩容以及重启升级不相互影响;
  • 4)长连接的监控与报警能力;
  • 5)客户端重连指令一键实现。

主要技术要点:

  • 1)支持自定义协议以及序列化;
  • 2)支持websocket协议;
  • 3)通道连接自定义保活以及心跳检测;
  • 4)本地缓存channel;
  • 5)责任链;
  • 6)服务调用完全异步;
  • 7)泛化调用;
  • 8)转发通知包或者Push包;
  • 9)容错网关down机处理。

设计方案(一个Notify包的数据经网关的线程模型图):

15.3应用层API网关设计

我的设计目标是:

  • 1)基于版本的自动发现以及灰度/扩容 ,不需要关注IP;
  • 2)网关的线程池实现1+8+1,减少线程切换;
  • 3)支持协议转换实现多个协议转换,基于SPI来实现;
  • 4)与业务服务器解耦,集群部署缩容扩容以及重启升级不相互影响;
  • 5)接口错误信息统计和RT时间的监控和报警能力;
  • 6)UI界面实现路由算法,服务接口版本管理,灰度策略管理以及接口和服务信息展示能力;
  • 7)基于OpenAPI提供接口级别的自动生成文档的功能。

主要技术要点:

  • 1)Http2.0;
  • 2)channel连接池复用;
  • 3)Netty http 服务端编解码;
  • 4)责任链;
  • 5)服务调用完全异步;
  • 6)全链路超时机制;
  • 7)泛化调用。

设计方案(一个请求包的数据经网关的架构图):

16、高并发设计

16.1架构优化

主要从以下几个方面入手:

  • 1)水平扩展:各个模块无状态部署;
  • 2)线程模型:每个服务底层线程模型遵从Netty主从reactor模型;
  • 3)多层缓存:Gate层二级缓存,Redis一级缓存;
  • 4)长连接:客户端长连接保持,避免频繁创建连接消耗。

16.2万人群聊优化

技术难点主要是:消息扇出大,比如每秒群聊有50条消息,群聊2000人,那么光一个群对系统并发就有10W的消息扇出。

优化思路:

  • 1)批量ACK:每条群消息都ACK,会给服务器造成巨大的冲击,为了减少ACK请求量,参考TCP的Delay ACK机制,在接收方层面进行批量ACK;
  • 2)群消息和成员批量加载以及懒加载:在真正进入一个群时才实时拉取群友的数据;
  • 3)群离线消息过多:群消息分页拉取,第二次拉取请求作为第一次拉取请求的ack;
  • 4)对于消息未读数场景,每个用户维护一个全局的未读数和每个会话的未读数,当群聊非常大时,未读资源变更的QPS非常大。这个时候应用层对未读数进行缓存,批量写+定时写来保证未读计数的写入性能;
  • 5)路由信息存入redis会有写入和读取的性能瓶颈,每条消息在发出的时候会查路由信息来发送对应的gate接入层,比如有10个群,每个群1W,那么1s100条消息,那么1000W的查询会打满redis,即使redis做了集群。优化的思路就是将集中的路由信息分散到msg层 JVM本地内存中,然后做Route可用,避免单点故障;
  • 6)存储的优化:扩散写写入并发量巨大,另一方面也存在存储浪费,一般优化成扩散读的方式存储;
  • 7)消息路由到相同接入层机器进行合并请求减少网络包传输。

相关资料:

1.《网易云信技术分享:IM中的万人群聊技术方案实践总结

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

3.《融云IM技术分享:万人群聊消息投递方案的思考和实践

16.3代码优化

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

16.4推拉结合优化合并

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

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

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

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

17、高可用设计

17.1心跳设计

主要是:

  • 1)服务端检测到某个客户端迟迟没有心跳过来可以主动关闭通道,让它下线,并且清除在线信息和路由信息;
  • 2)客户端检测到某个服务端迟迟没有响应心跳也能重连获取一个新的连接。

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

相关资料:

为何基于TCP协议的移动端IM仍然需要心跳保活机制?

一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等

微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)

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

融云技术分享:融云安卓端IM产品的网络链路保活技术实践

移动端IM实践:实现Android版微信的智能心跳机制

万字长文:手把手教你实现一套高效的IM长连接自适应心跳保活机制

17.2系统稳定性设计

背景:高峰期系统压力大,偶发的网络波动或者机器过载,都有可能导致大量的系统失败。加上IM系统要求实时性,不能用异步处理实时发过来的消息。所以有了柔性保护机制防止雪崩。

柔性保护机制开启判断指标,当每个指标不在平均范围内的时候就开启。

这些判断指标主要是:

  • 1)每条消息的ack时间 RT时间
  • 2)同时在线人数以及同时发消息的人数
  • 3)每台机器的负载CPU和内存和网络IO和磁盘IO以及GC参数

当开启了柔性保护机制,那么会返回失败,用户端体验不友好,如何优化?

以下是我的优化思路:

  • 1)当开启了柔性保护机制,逻辑层hold住多余的请求,返回前端成功,不显示发送失败,后端异步重试,直至成功;
  • 2)为了避免重试加剧系统过载,指数时间延迟重试。

17.3异常场景设计

gate层重启升级或者意外down机有以下问题:

  • 1)客户端和gate意外丢失长连接,导致 客户端在发送消息的时候导致消息超时等待以及客户端重试等无意义操作;
  • 2)发送给客户端的消息,从Msg消息层转发给gate的消息丢失,导致消息超时等待以及重试。

解决方案如下:

  • 1)重启升级时候,向客户端发送重新连接指令,让客户端重新请求LSB获取IP直连;
  • 2)当gate层down机异常停止时候,增加hook钩子,向客户端发送重新连接指令;
  • 3)额外增加hook,向Msg消息层发送请求清空路由消息和在线状态,并且清除redis的路由信息。

17.4Redis宕机高可用设计

Redis的作用背景:

  • 1)当用户链接上网关后,网关会将用户的userId和机器信息存入redis,用作这个user接收消息时候,消息的路由;
  • 2)消息服务在发消息给user时候,会查询Redis的路由信息,用来发送消息给哪个一个网关。

如果Redis宕机,会造成下面结果:

  • 1)消息中转不过去,所有的用户可以发送消息,但是都接收不了消息;
  • 2)如果有在线机制,那么系统都认为是离线状态,会走手机消息通道推送。

Redis宕机兜底处理策略:

  • 1)消息服务定时任务同步路由信息到本地缓存,如果redis挂了,从本地缓存拿消息;
  • 2)网关服务在收到用户侧的上线和下线后,会同步广播本地的路由信息给各个消息服务,消息服务接收后更新本地环境数据;
  • 3)网络交互次数多,以及消息服务多,可以用批量或者定时的方式同步广播路由消息给各个消息服务。

18、核心表结构设计

核心设计要点:

  • 1)群消息只存储一份,用户不需要为每个消息单独存一份。用户也无需去删除群消息;
  • 2)对于在线的用户,收到群消息后,修改这个last_ack_msg_id;
  • 3)对于离线用户,用户上线后,对比最新的消息ID和last_ack_msg_id,来进行拉取(参考Kafka的消费者模型);
  • 4)对应单聊,需要记录消息的送达状态,以便在异常情况下来做重试处理。

群用户消息表 t_group_user_msg:

群消息表 t_group_msg:

参考资料:

1.《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)

2.《基于Netty,从零开发一个IM服务端

19、红包设计

抢红包的大致核心逻辑如下:

  • 1)银行快捷支付,保证账户余额和发送红包逻辑的一致性;
  • 2)发送红包后,首先计算好红包的个数,个数确定好后,确定好每个红包的金额,存入存储层【这里可以是redis的List或者是队列】方便后续每个人来取;
  • 3)生成一个24小时的延迟任务,检测红包是否还有钱方便退回;
  • 4)每个红包的金额需要保证每个红包的的抢金额概率是一致的,算法需要考量;
  • 5)存入数据库表中后,服务器通过长连接,给群里notify红包消息,供群成员抢红包;
  • 6)群成员并发抢红包,在第二步中会将每个红包的金额放入一个队列或者其他存储中,群成员实际是来竞争去队列中的红包金额。兜底机制:如果redis挂了,可以重新生成红包信息到数据库中;
  • 7)取成功后,需要保证红包剩余金额、新插入的红包流水数据、队列中的红包数据以及群成员的余额账户金额一致性;
  • 8)这里还需要保证一个用户只能领取一次,并且保持幂等。

相关资料:

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

社交软件红包技术解密(二):解密微信摇一摇红包从0到1的技术演进

社交软件红包技术解密(三):微信摇一摇红包雨背后的技术细节

社交软件红包技术解密(四):微信红包系统是如何应对高并发的

社交软件红包技术解密(五):微信红包系统是如何实现高可用性的

社交软件红包技术解密(六):微信红包系统的存储层架构演进实践

社交软件红包技术解密(七):支付宝红包的海量高并发技术实践

社交软件红包技术解密(八):全面解密微博红包技术方案

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

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

社交软件红包技术解密(十一):解密微信红包随机算法(含代码实现)

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

20、核心业务流程梳理

20.1单聊流程

假设是用户A发消息给用户B ,以下是完整的业务流程。

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

2)服务端检测接收用户B是否在线,在线直接推送给用户B。

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

4)服务端接收ACK后,将消息标为已送达。

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

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

20.2群聊流程

假设是用户A发消息给群G  ,以下是完整的业务流程。

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

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

3)在线成员:并行发送拉取通知,等待在线成员过来拉取,发送拉取通知包如丢失会有兜底机制。

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、设计IM系统时的常见疑问

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

主要是在线状态维护。

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

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

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

MQ作为解耦可以有以下好处:

  • 1)易扩展:gate层到logic层无需路由,logic层多个有新的业务时候,只需要监听新的topic即可;
  • 2)解耦:gate层到logic层解耦,不会有依赖关系;
  • 3)节省端口资源:gate层无需再开启新的端口接收logic的请求,而且直接监听MQ消息即可。

但是缺点也有:

  • 1)网络通信多一次网络通信,增加RT的时间,消息实时性对于IM即使通信的场景是非常注重的一个点;
  • 2)MQ的稳定性,不管任何系统只要引入中间件都会有稳定性问题,需要考虑MQ不可用或者丢失数据的情况;
  • 3)需要考虑到运维的成本;
  • 4)当用消息中间代替路由层的时候,gate层需要广播消费消息,这个时候gate层会接收大部分的无效消息,因为这个消息的接收者channel不在本机维护的session中。

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

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

21.3为什么接入层用LSB返回的IP来做接入呢?

可以有以下好处:

  • 1)灵活的负载均衡策略 可根据最少连接数来分配IP;
  • 2)做灰度策略来分配IP;
  • 3)AppId业务隔离策略 不同业务连接不同的gate,防止相互影响。

21.4为什么应用层心跳对连接进行健康检查?

因为TCP Keepalive状态无法反应应用层状态问题,如进程阻塞、死锁、TCP缓冲区满等情况。

并且要注意心跳的频率,频率小则可能及时感知不到应用情况,频率大可能有一定的性能开销。

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

21.5MQ的使用场景?

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

21.6群消息存一份还是多份,读扩散还是写扩散?

我的设计是存1份,读扩散。

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

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

21.7消息ID为什么是趋势递增就可以,严格递增的不行吗?

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

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

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

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

关于消息ID的生成,可以参考下面的系列文章:

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

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

解密融云IM产品的聊天消息ID生成策略

深度解密美团的分布式ID生成算法

开源分布式ID生成器UidGenerator的技术实现

深度解密滴滴的高性能ID生成器(Tinyid)

21.8gate层为什么需要开两个端口?

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

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

维护在每个msg层内存中有状态:多级缓存避免和中间件多次交互,并发高。

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

业务初期为了减少复杂度,可以维护在Redis中。

21.10网关层和服务层以及msg层和网关层请求模型具体是怎样的?

网关层到服务层,只需要单向传输发请求,网关层不需要关心调用的结果。

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

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

21.11本地写数据成功,一定代表对端应用侧接收读取消息了吗?

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

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

21.12为什么用netty做来做http网关, 而不用tomcat?

主要是从以下方面考虑:

  • 1)netty对象池,内存池,高性能线程模型;
  • 2)netty堆外内存管理,减少GC压力,jvm管理的只是一个很小的DirectByteBuffer对象引用;
  • 3)tomcat读取数据和写入数据都需要从内核态缓冲copy到用户态的JVM中,多1次或者2次的拷贝会有性能影响。

21.13为什么消息入库后,对于在线状态的用户,单聊直接推送,群聊通知客户端来拉取,而不是直接推送消息给客户端(推拉结合)?

在保证消息实时性的前提下,对于单聊,直接推送。

对于群聊,由于群聊人数多,推送的话一份群消息会对群内所有的用户都产生一份推送的消息,推送量巨大。

解决办法是按需拉取,当群消息有新消息时候发送时候,服务端主动推送新的消息数量,然后客户端分页按需拉取数据。

21.14为什么除了单聊、群聊、推送、离线拉取等实时性业务,其他的业务都走http协议?

IM协议简单最好,如果让其他的业务请求混进IM协议中,会让其IM变的更复杂,比如查找离线消息记录拉取走http通道避免tcp 通道压力过大,影响即时消息下发效率。

在比如上传图片和大文件,可以利用HTTP的断点上传和分段上传特性。

21.15机集群机器要考虑到哪些优化?

主要有:

  • 1)网络宽带;
  • 2)最大文件句柄;
  • 3)每个tcp的内存占用;
  • 4)Linux系统内核tcp参数优化配置;
  • 5)网络IO模型;
  • 6)网络网络协议解析效率;
  • 7)心跳频率;
  • 8)会话数据一致性保证;
  • 9)服务集群动态扩容缩容。

22、系列文章

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

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

跟着源码学IM(三):基于Netty,从零开发一个IM服务端

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

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

跟着源码学IM(六):手把手教你用Go快速搭建高

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

本文原题“搭建高性能的IM系统”,作者“刘莅”,内容有修订和改动。为了尊重原创,如需转载,请联系作者获得授权。

1、引言

相信很多朋友对微信、QQ等聊天软件的实现原理都非常感兴趣,笔者同样对这些软件有着深厚的兴趣。而且笔者在公司也是做IM的,公司的IM每天承载着上亿条消息的发送!

正好有这样的技术资源和条件,所以前段时间,笔者利用业余时间,基于Netty开发了一套基本功能比较完善的IM系统。该系统支持私聊、群聊、会话管理、心跳检测,支持服务注册、负载均衡,支持任意节点水平扩容。

这段时间,网上的一些读者,也希望笔者分享一些Netty或者IM相关的知识,所以今天笔者把开发的这套IM系统分享给大家。

本文将根据笔者这次的业余技术实践,为你讲述如何基于Netty+Zk+Redis来搭建一套高性能IM集群,包括本次实现IM集群的技术原理和实例代码,希望能带给你启发。

2、本文源码

主地址:https://github.com/nicoliuli/chat

备地址:https://github.com/52im/chat

源码的目录结构,如下图所示:

3、知识准备

* 重要提示:本文不是一篇即时通讯理论文章,文章内容来自代码实战,如果你对即时通讯(IM)技术理论了解的太少,建议先详细阅读:《新手入门一篇就够:从零开发移动端IM》。

可能有人不知道 Netty 是什么,这里简单介绍下:

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

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

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

以下是有关Netty的入门文章:

如果你连Java的NIO都不知道是什么,下面的文章建议优先读:

Netty源码和API的在线查阅地址:

4、系统架构

系统的架构如上图所示:整个系统是一个C/S系统,客户端没有做复杂的图形化界面而是用Java终端开发的(黑窗口),服务端IM实例是Netty写的socket服务。

ZK作为服务注册中心,Redis用来做分布式会话的缓存,并保存用户信息和轻量级的消息队列。

对于整个系统架构中各部分的工作原理,我们将在接下来的各章节中一一介绍。

5、服务端的工作原理

在上述架构中:NettyServer启动,每启动一台Server节点,都会把自身的节点信息,如:ip、port等信息注册到ZK上(临时节点)。

正如上节架构图上启动了两台NettyServer,所以ZK上会保存两个Server的信息。

同时ZK将监听每台Server节点,如果Server宕机ZK就会删除当前机器所注册的信息(把临时节点删除),这样就完成了简单的服务注册的功能。

6、客户端的工作原理

Client启动时,会先从ZK上随机选择一个可用的NettyServer(随机表示可以实现负载均衡),拿到NettyServer的信息(IP和port)后与NettyServer建立链接。

链接建立起来后,NettyServer端会生成一个Session(即会话),用来把当前客户端的Channel等信息组装成一个Session对象,保存在一个SessionMap里,同时也会把这个Session保存在Redis中。

这个会话特别重要,通过会话,我们能获取当前Client和NettyServer的Channel等信息。

7、Session的作用

我们启动多个Client,由于每个Client启动,都会先从ZK上随机获取NettyServer的的信息,所以如果启动多个Client,就会连接到不同的NettyServer上。

熟悉Netty的朋友都知道,Client与Server建立接连后会产生一个Channel,通过Channel,Client和Server才能进行正常的网络数据传输。

如果Client1和Client2连接在同一个Server上:那么Server通过SessionMap分别拿到Client1和Client2的会话,会话中包含Channel信息,有了两个Client的Channel,Client1和Client2便可完成消息通信。

如果Client1和Client2连接到不同的NettyServer上:Client1和Client2要进行通信,该怎么办?这个问题放在后面解答。

8、高效的数据传输

无论是IM系统,还是分布式的RPC框架,高效的网络数据传输,无疑会极大的提升系统的性能。

数据通过网络传输时,一般把对象通序列化成二进制字节流数组,然后将数据通过socket传给对方服务器,对方服务器拿到二进制字节流后再反序列化成对象,达到远程通信的目的。

在Java领域,Java序列化对象的方式有严重的性能问题,业界常用谷歌的protobuf来实现序列化反序列化(见《Protobuf通信协议详解:代码演示、详细原理介绍等)。

protobuf支持不同的编程语言,可以实现跨语言的系统调用,并且有着极高的序列化反序列化性能,本系统也采用protobuf来做数据的序列化。

关于Protobuf的基本认之,下面这几篇可以深入读一读:

  1. 强列建议将Protobuf作为你的即时通讯应用数据传输格式
  2. 全方位评测:Protobuf性能到底有没有比JSON快5倍?
  3. 金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(原理篇)

另外:一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》一文中,“3、协议设计”这一节有关于protobuf在IM中的实战设计和使用,可以一并学习一下。

9、聊天协议定义

我们在使用各种聊天APP时,会发各种各样的消息,每种消息都会对应不同的消息格式(即“聊天协议”)。

聊天协议中主要包含几种重要的信息:

  • 1)消息类型;
  • 2)发送时间;
  • 3)消息的收发人;
  • 4)聊天类型(群聊或私聊)。

我的这套IM系统中,聊天协议定义如下:

syntax = "proto3";

option java_package = "model.chat";

option java_outer_classname = "RpcMsg";

message Msg

    string msg_id = 1;

    int64 from_uid = 2;

    int64 to_uid = 3;

    int32 format = 4;

    int32 msg_type = 5;

    int32 chat_type = 6;

    int64 timestamp = 7;

    string body = 8;

    repeated int64 to_uid_list = 9;

如上面的protobuf代码,字段的具体含义如下:

  • 1)msg_id:表示消息的唯一id,可以用UUID表示;
  • 2)from_uid:消息发送者的uid;
  • 3)to_uid:消息接收者的uid;
  • 4)format:消息格式,我们使用各种聊天软件时,会发送文字消息,语音消息,图片消息等等等等,每种消息有不同的消息格式,我们用format来表示(由于本系统是java终端,format字段没有太大含义,可有可无);
  • 5)msg_type:消息类型,比如登录消息、聊天消息、ack消息、ping、pong消息;
  • 6)chat_type:聊天类型,如群聊、私聊;
  • 7)timestamp:发送消息的时间戳;
  • 8)body:消息的具体内容,载体;
  • 9)to_uid_list:这个字段用户群聊消息提高群聊消息的性能,具体作用会在群聊原理部分详细解释。

10、私聊消息发送原理

Client1给Client2发消息时,我们需要构建上节中的消息体。

具体就是:from_uid是Client1的uid、to_uid是Client2的uid。

NettyServer收到消息后的处理逻辑是:

  • 1)解析到to_uid字段;
  • 2)从SessionMap或者Redis中保存的Session集合中获取to_uid即Client2的Session;
  • 3)从Session中取出Client2的Channel;
  • 4)然后将消息通过Client2的Channel发给Client2。

11、群聊消息发送原理

群聊消息的分发通常有两种技术实现方式,我们一一来看看。

方式一:假设一个群有100人,如果Client1给一个群的所有人发消息,其实相当于Client1分别给其余99人分别发一条消息。我们可以直接在Client端,通过循环,分别给群里的99人发消息即可,相当于Client发送给NettyServer发送了99次相同的消息(除了to_uid不同)。

上述方案有很严重的性能问题:Client1通过循环99次,分别把消息发给NettyServer,NettyServer收到这99条消息后,分别将消息发给群内其余的用户。先抛开移动端的特殊性(比如循环还没完成手机就有可能退到后台被系统挂起),显然Client1到NettyServer的99次循环存在明显不合理地方。

方式二:上节的消息体中to_uid_list字段就是为了解决这个方式一的性能问题的。Client1把群内其余99个Client的uid保存在to_uid_list中,然后NettyServer只发一条消息,NettyServer收到这一条消息后,通过to_uid_list字段解析群内其余99的Client的uid,再通过循环把消息分别发送给群内其余的Client。

可以看到:方式二的群聊时,Client1与NettyServer只进行1次消息传输,相比于方式一,效率提高了50%。

11、技术关键点1:客户端分别连接在不同IM实例时如何通信?

针对本文中的架构,如果多个Client分别连接在不同的Server上,Client之间应该如何通信呢?

为了回答这个问题,我们首先要明白Session的作用。

我们做过JavaWeb开发的朋友都知道,Session用来保存用户的登录信息。

在IM系统中也是如此:Session中保存用户的Channel信息。当Client与Server建立链接成功后,会产生一个Channel,Client和Server是通过Channel,实现数据传输。当两端链接建立起来后,Server会构建出一个Session对象,保存uid和Channel等信息,并把这个Session保存在一个SessionMap里(NettyServer的内存里),uid为key,我们可以通过uid就可以找到这个uid对应的Session。

但只有SessionMap还不够:我们需要利用Redis,它的作用是保存整个NettyServer集群全部链接成功的用户,这也是一种Session,但这种Session没有保存uid和Channel的对应关系,而是保存Client链接到NettyServer的信息,如Client链接到的这个NettyServer的ip、port等。通过uid,我们同样可以从Redis中拿到当前Client链接到的NettyServer的信息。正是有了这个信息,我们才能做到,NettyServer集群任意节点水平扩容。

当用户量少的时候:我们只需要一台NettyServer节点便可以扛住流量,所有的Client链接到同一个NettyServer上,并在NettyServer的SessionMap中保存每个Client的会话。Client1与Client2通信时,Client1把消息发给NettyServer,NettyServer从SessionMap中取出Client2的Session和Channel,将消息发给Client2。

随着用户量不断增多:一台NettyServer不够,我们增加了几台NettyServer,这时Client1链接到NettyServer1上并在SessionMap和Redis中保存了会话和Client1的链接信息,Client2链接到NettyServer2上并在SessionMap和Redis中保存了会话和Client2的链接信息。Client1给Client2发消息时,通过NettyServer1的SessionMap找不到Client2的会话,消息无法发送,于是便从Redis中获取Client2链接在哪台NettyServer上。获取到Client2所链接的NettyServer信息后,我们可以把消息转发给NettyServer2,NettyServer2收到消息后,从NettyServer2的SessionMap中获取Client2的Session和Channel,然后将消息发送给Client2。

那么:NettyServer1的消息如何转发给NettyServer2呢?答案是通过消息队列,如Redis中的list数据结构。每台NettyServer启动后都需要监听一个自己的Redis中的消息队列,这个队列用户接收其他NettyServer转发给当前NettyServer的消息。

* Jack Jiang点评:上述集群方案中,Redis既作为在线用户列表存储中心,又作为集群中不同IM长连接实例的消息中转服务(此时的Redis作用相当于MQ),那Redis不就成为了整个分布式集群的单点瓶颈了吗?

12、技术关键点2:链接断开,如何处理?

如果Client与NettyServer,由于某种原因(客户端退出、服务端重启、网络因素等)断开链接,我们必须要从SessionMap删除会话和Redis中保留的数据。

如果不清除这两类数据的话,很有可能Client1发送给Client2的消息,可能会发给其他用户,或者就算Client2处于登录状态,Client2也收到不到消息。

我们可以在Netty框架中的channelInactive方法里,处理链接断开后的会话清除操作。

13、技术关键点3:ping、pong的作用

当Client与NettyServer建立链接后,由于双端网络较差,Client与NettyServer断开链接后,如果NettyServer没有感知到,也就没有清除SessionMap和Redis中的数据,这将会造成严重的问题(对于服务端来说,这个Client的会话实际处于“假死”状态,消息是无法实时发送过去的)。

此时就需要一种ping/pong机制(也就是心跳机制啦)。

实现原理就是:通过定时任务,Client每隔一段时间给NettyServer发一个ping消息,NettyServer收到ping消息后给客户端回复一个pong消息,确保客户端和服务端能一直保持链接状态。如果Client与NettyServer断连了,NettyServer可以立即发现并清空会话数据。Netty中的我们可以在Pipeline中添加IdleStateHandler,可达到这样的目的。

如果你不明白心跳的作用,务必读以下文章:

  1. 为何基于TCP协议的移动端IM仍然需要心跳保活机制?
  2. 一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等

也可以学习一下主流IM的心跳逻辑:

  1. 微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)
  2. 微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)
  3. 移动端IM实践:实现Android版微信的智能心跳机制
  4. 移动端IM实践:WhatsApp、Line、微信的心跳策略分析

如果觉得理论不够直观,下面的代码实例可以直观地进行学习:

  1. 正确理解IM长连接的心跳及重连机制,并动手实现(有完整IM源码)
  2. 一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)
  3. 自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)
  4. 手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制

其实,心跳算法的实际效果,还是有一些逻辑技巧的,以下两篇建议必读:

  1. Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?
  2. 融云技术分享:融云安卓端IM产品的网络链路保活技术实践

14、技术关键点4:为Server和Client添加Hook

如果NettyServer重启了或者进程被kill掉,我们需要清除当前节点的SessionMap(其实不用清理SessionMap,数据在内存里重启会自动删除的)和Redis保存的Client的链接信息。

我们需要遍历SessionMap找出所有的uid,然后一一清除Redis的数据,然后优雅退出。此时,我们就需要为我们的NettyServer添加一个Hook,来做数据清理。

15、技术关键点5:对方不在线该如何处理消息?

Client1给对方发消息,我们通过SessionMap或Redis拿不到对方的会话数据,这就表明对方不在线。

此时:我们需要把消息存储在离线消息表中,当对方下次登录时,NettyServer查离线消息表,把消息发给登录用户(最好是批量发送,提高性能)。

IM中的离线消息处理,也不是个简单的技术点,有兴趣可以深入学习一下:

  1. IM消息送达保证机制实现(二):保证离线消息的可靠投递
  2. 阿里IM技术分享(六):闲鱼亿级IM消息系统的离线推送到达率优化
  3. IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的
  4. IM开发干货分享:如何优雅的实现大量离线消息的可靠投递
  5. 喜马拉雅亿级用户量的离线消息推送系统架构设计实践

16、写在最后

代码写成这样,也算是了确了自已手撸IM的心愿。唯一遗憾的是,时间比较紧张,还没来得及实现消息ack机制,保证消息一定会送达,这个笔者以后会补充上去的。

好了,这就是我开发的这个简易的聊天系统,麻雀虽小,五脏俱全,大家有什么不明白的地方,可以直接在下方留言,笔者会一一回复的,谢谢大家。

17、系列文章

18、参考资料

[1] 新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析

[2] 写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略

[3] 史上最强Java NIO入门:担心从入门到放弃的,请读这篇!

[4] Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!

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

[6] 理论联系实际:一套典型的IM通信协议设计详解

[7] 浅谈IM系统的架构设计

[8] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端

[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)

[10] 一套原创分布式即时通讯(IM)系统理论架构方案

[11]  一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践

[12] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等

[13] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等

[14] 从新手到专家:如何设计一套亿级消息量的分布式IM系统

[15] 基于实践:一套百万消息量小规模IM系统技术要点总结

本文已同步发布于:http://www.52im.net/thread-3816-1-1.html )

作者:Jack Jiang (点击作者姓名进入Github)
出处:http://www.52im.net/space-uid-1.html
交流:欢迎加入即时通讯开发交流群 215477170
讨论:http://www.52im.net/
Jack Jiang同时是【原创Java Swing外观工程BeautyEye】【轻量级开源移动端即时通讯框架MobileIMSDK】的作者,可前往下载交流。
本博文 欢迎转载,转载请注明出处(也可前往 我的52im.net 找到我)。

以上是关于跟着源码学IM:一套基于Netty的分布式高可用IM详细设计与实现(有源码)的主要内容,如果未能解决你的问题,请参考以下文章

基于Netty实现一套分布式IM系统

基于Netty实现分布式IM即时通讯开发

java700多个G架构师项目实战,高并发集群分布式,大数据高可用,视频教程获取方式

跟着狼哥学高性能框架Netty

java学完框架后学什么?java架构师进阶学习,java高并发集群分布式,java大数据高可用,视频教程,

跟着案例学Netty:Netty内存池泄漏问题