一、基础概念
2.IM的应用场景
IM其实并不局限于聊天、社交这类“典型”应用中,实际上它已经广泛运用于我们身边形形色色的软件中。
聊天、直播、在线客服、物联网等所有需要实时互动、高实时性的场景等等,都需要应用到 IM 技术。
下面这些场景是我们大家都熟悉的,都用到了IM技术:
- 1)微信、qq、钉钉等主流IM应用:这是IM技术的典型应用场景;
- 2)微博、知乎等社区应用:它们利用IM技术实现了用户私信等点对点聊天;
- 3)抖音、快手等直播/短视频应用:它们利用IM技术实现了与主播的实时互动;
- 4)米家等智能家居物联网应用:利用IM技术实现实时控制、远程监控等;
- 5)滴滴、Uber等共享家通类应用:利用IM技术实现位置共享;
- 6)在线教育类应用:利用IM技术实现在线白板。
一、顶层设计
1.设计思考
1.为什么要单独做一个微服务?
1.基于业务代码的复用性,消息推送本身是一个比较通用的功能,不具备业务属性所以不用考虑复杂额业务逻辑。如果让每一个业务系统去集成第三方消息sdk,从业务的管理角度是维护起来将是非常恐怖的。
2.基于服务的高扩展性,单独抽离出来,方便水平扩展,减轻业务系统的服务压力(如业务服务会创建大量的发送消息的线程,肯定会消耗业务系统的资源)
2.为什么要控制消息幂等?
首先从业务角度来说,如果一个用户在3s内收到同样的消息肯定是不合理的,所以从业务上说,是需要控制幂等的。
然后从系统设计上来说,让业务系统去保障幂等是不现实的,鬼知道那个业务系统会出现莫名其妙的系统bug,导致我会重复发生一摸一样的消息。
最后在从成本管理角度来说,发消息或者短信是要钱的,对于没有业务价值的重复消息,是要钱的!!!!!
2.web消息推送机制
和app推送机制不同,web端更适用于拉模型。
即把将要产生的消息写入到消息表中,web端每次登录或者刷新页面时主动去消息表拉未读的消息,查看消息后并更新消息的状态。
3.短信中台设计
目前大多数企业都是使用第三方云平台如腾讯云、阿里云等平台,所以不用过多考虑底层的设计,只需要按照他们对应的接入文档接入即可。一般都是进行资质认证,申请模版等等。
这里有一些设计点要考虑一下
1.存储消息的发送记录,包括状态等等。因为第三方云平台可能只会存储最近三个月左右的数据,其次如果我们想基于短信数据做分析或者查询搜索可以不用受制于第三方接口。
2.服务端控制好幂等。
3.设计模式
模版模式:抽象发送流程,提供各模型构造方法和子流程抽象方法
工厂模式:生产各种模型实例对象(策略工厂)
策略模式:每种模型对各自的接口实现(消息body解析等)
建造者模式:构造消息体和参数等
命令模式:处理消息回调请求
二、系统交互架构
1.交互流程
素材来自于我的飞书文档 https://gi8fipx0b7.feishu.cn/docx/U3XpdiTYaolJGexdgwucisEQnGd
1.架构角色
客户端:构建消息内容,发送请求到服务端
服务端:接受消息请求,并调用底层sdk发送消息,并存储消息发送记录
第三方sdk:如腾讯sdk发送消息
2.安全控制
1.鉴权
业界主流是通过appKey和appSecret控制,即客户端传入对应appKey和appSecret,服务端进行检查是否存在,然后校验对应的值是否正确。
3.数据表模型
1.消息推送表设计
1.消息推送表:
1.业务类型和业务ID:定义消息类型的一级分类,业务ID用于app端反查消息的明细信息
2.消息类型:定义消息的二级分类
3.扩展字段:用来定义业务个性化的字段需求
CREATE TABLE `push_record` (
`id` bigint NOT NULL AUTO_INCREMENT,
`biz_id` bigint DEFAULT NULL COMMENT '业务ID',
`biz_type` tinyint DEFAULT NULL COMMENT '业务类型,1:预警推送',
`msg_type` tinyint DEFAULT NULL COMMENT '消息类型',
`title` varchar(255) DEFAULT NULL COMMENT '推送标题',
`content` varchar(255) DEFAULT NULL COMMENT '推送内容',
`receiver` bigint DEFAULT NULL COMMENT '消息接受者',
`callback_status` int DEFAULT NULL COMMENT '消息反馈状态',
`push_type` tinyint DEFAULT NULL COMMENT '推送方式,0:极光推送',
`push_level` tinyint DEFAULT NULL COMMENT '推送级别',
`ext` varchar(1024) DEFAULT NULL COMMENT '业务自定义扩展字段(json格式)',
`push_success` bit(1) DEFAULT NULL COMMENT '是否推送成功',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=65 DEFAULT CHARSET=utf8mb3 COMMENT='推送记录';
2.消息明细表:
用于存储消息的明细信息。
1.为何不存储在主表?
占用一个字段存储太耗数据表存储空间
2.聊天消息表设计
1.im账号基础信息
主要包含:Im账号,所属的群组等信息。
/**
* tencent_im_account数据库映射实体
*/
public class TencentImAccount {
private Long id;
private String appCode;
private Long involvedId;
/**
* 1:customer,2:teacher
*/
private Integer type;
private String identifier;
private Date createTime;
private Date updateTime;
}
2.群信息表
主要包含群组的基础信息如群头像,群公告等
/**
* tencent_im_group数据库映射实体
*/
public class TencentImGroup {
/**
* 主键id
*/
private Long id;
/**
* 群名称
*/
private String groupName;
/**
* 类型,1:班级群
*/
private Integer type;
/**
* app编码
*/
private String appCode;
/**
* 关联id,如果是班级群就是班级id
*/
private Long involvedId;
private String groupIdentifier;
private String avatar;
/**
* 是否全部禁言,1:是,0:否
*/
private Integer forbidSendMsg;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}
3.群成员信息表
主要包含所属的群ID,包含成员im账号
/**
* tencent_im_group_member数据库映射实体
*/
public class TencentImGroupMember {
/**
* 主键id
*/
private Long id;
/**
* 群id
*/
private Long tencentImGroupId;
/**
* 帐号id
*/
private Long tencentImAccountId;
private String groupNickName;
/**
* 是否删除,1:是,0:否
*/
private Integer deleted;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}
4.私聊消息明细表
主要包含发送者账号,接受者账号,消息ID或者消息内容,发送时间等。
/**
* @Description this is a description of this class
* @Author liujunjie
* @Date 2018/1/14
*/
public class TecentImMqMsg {
private AppCodeEnum appCode;
private SendTypeEnum sendType;
private String fromAccount;
private String toAccount;
private Long timestamp;
private String msgId;
private MsgTypeEnum msgType;
}
5.群聊消息明细表
public class GroupTopMessage {
/**
* 主键id
*/
private Long id;
/**
* app编码,精进-jjxt
*/
private String appCode;
/**
* 群id
*/
private String groupId;
/**
* 消息内容
*/
private String content;
/**
* 消息发送者
*/
private String fromAccount;
/**
* 创建人id
*/
private Long createUserId;
/**
* 更新人id
*/
private Long updateUserId;
/**
* 创建时间
*/
private Date createDate;
/**
* 更新时间
*/
private Date updateDate;
/**
* 是否删除,0-否,1-是
*/
private Boolean deleted;
}
三、高并发与高可用探究
1.消息存储设计
1.为什么要存储消息?
因为用户需要查看历史聊天记录,如近10天聊天记录,根据关键词搜索聊天记录等,如微信就已经是一个非常常用的功能了。
2.为什么不能直接写数据库而是要通过异步写?
分析:
1.消息的发送并发量大,而写库是一个耗时事务,做成异步是为了提供系统的吞吐量,而且用户关心的是否收到消息是主业务,而消息的存储主要为了运营人员数据分析为副业务实时型要求没
2.消息的发送并发量大,直接写数据库,数据库无法承受巨大的并发写压力,所以中间会通过mq进行销峰处理。
3.消息发送和存储解耦合,存储的逻辑和成败,与消息的发送成败没有关系,且写失败我们可以设计对应的补偿重试方案。
3.异步存储消息有哪些解决方案?
1.设计消息回调微服务且单独部署,与消息处理微服务解耦合。单独接收腾讯sdk的回调消息。
这样设计的缺点是
- 占用服务器资源,有一定的硬件成本。
- 无法解决消息并发的问题,要解决并发问题要将消息回调和存储使用mq解耦合
优点是系统结构逻辑清晰,消息的发送和存储完全解耦合了。
2.消息回调和存储在同一个微服务下,使用mq解耦合
这样是大多数系统参考使用的解决方案,mq销峰机制能解决消息的并发问题。
4.消息存储的数据库选型
分析:
消息不涉及到事务特性,可以不用考虑使用关系性数据库。
1.es进行存储
优点
- 检索速度快
缺点:
- 引入一套新的中间件,增加了维护成本,且增加系统结构复杂度
1.批量多接受者异步
思考:批量多接受者的消息,如果使用同步依次批量调用底层sdk,那么性能也不够好,如果超时时间设置的较短,也有可能出现超时等情况。
方案:可以使用mq进行异步和削峰处理
2.消息幂等设计
消息幂等最常见的设计方案就是使用redis做一套分布式锁。
详细的分布式锁设计请参考我的另一篇博文
三、im即时通讯系统架构
1.im服务端基础架构
1.整体设计
1.用户登录聊天系统,并与聊天服务器建立连接
2.用户1对用户2发送消息,通过用户ID找到对应netty服务端并将消息写进通道推给用户2
2.如何解决断线重连
1.服务端宕机后,客户端如何重连
服务端宕机后,根据netty通信机制,客户端能感知到,客户端可主动删除原来用户ID和netty服务端之间的映射关系,并从zk中选中一台netty服务器建立重连
2.客户端宕机后,服务端如何重连
以上面的机制一样,服务端感知到客户端宕机后,服务端主动删除原来用户ID和netty服务端之间的映射关系,并强制用户下线。待用户重新登录后,建立连接关系
3.心跳机制
利用netty框架自带的心态机制功能
1.客户端心跳机制
源码如下:
com.tuling.tim.client.init.TIMClientHandleInitializer
2.服务端心跳机制
com.tuling.tim.server.init.TIMServerInitializer
2.im服务端2.0版本架构
2.0版本增加如下特性
- 增加了消息的异步存储,通过引入mq中间件进行流量削峰处理
- 增加消息可靠性传输机制,消息发送通过mq的可靠性机制保障,然后聊天服务拉起消息进行消息的消费投递和存储。
1.如何保障消息的可靠性传输
使用mq的可靠性传输机制进行保障。详细的设计思路如下:
1. IM客户端发送消息如果超时或失败需要重发,客户端在发送消息时需要给每条消
息生成一个id,IM服务端根据此id做好去重机制
2. 为保证服务端消息不丢失,我们可以使用Rocket MQ的可靠消息机制来保证
3. 通过客户端的ACK确认接收消息的机制来保证不丢消息。即会存储消息的接受状态,当消息送达之后会进行回调到客户端更新消息的接受状态,如果一直未被接受则有可能是客户端掉线了等情况,此时可以使用离线消息的解决方案。
2.离线消息服务处理离线消息机制
1. 离线消息就是用户不在线时别人发给他的消息,到用户上线时这些消息需要接收到,因为用户上下线可能是非常频繁的操作,一般是在用户上线时会主动拉取服务端的离线消息,如果直接从数据库里拉,则会对数据库造成极大的压力,所以对于离线消息我们一般会选择一些高性能的缓存来存储,比如Redis,这样能抗住高并发的访问压力。
2.redis存储的都是离线未读的消息,消息在存储时会进行双写。在消息送达后,会更新消息接受状态,此时已经成功接受了的消息将会从redis删除,以降低redis的存储容量
3. 当然Redis肯定是集群架构,而且会是很多节点,当然有同学也会担心这些离线消息肯定也是非常多的,Redis集群能存下吗,在大厂里Redis都是有很多节点的,可以存储很多T的数据,据说十年前新浪微博后端的Redis存储数据就已经达到几百T级别了,当然我们是可以设置一些存储策略的,比如,限制只存储最近一周或一个月的数据,然后再加一个存储消息的条数限制,比如一个用户的离线消息最多就存储最近的1000条。或者都按照存储条数的限制。
4. 因为本身用户上线后查看离线消息很少会把历史所有的离线消息全部看完的,我们就展示最近的一些离线消息,如果用户一直往上翻离线消息,后面的消息可以从数据库查询,这种小概率的操作让数据库抗下来是没问题的。
3.Redis数据结构存储离线消息选型
1. 添加消息:zadd offline_msg_#{receiverId} #{mid} #{msg} // score就存储消息的id
2. 查询消息:zrevrange offline_msg_#{receiverId} 0 9 // 按消息id从大到小排序取最新的
十条消息,上拉刷新继续查
3. 删除消息:zremrangebyscore offline_msg_#{receiverId} min_mid max_mid // 删除客户端已
读取过的介于最小的消息id和最大的消息id之间的所有消息
4. 如果单个key消息存储过大,可以考虑按周或者按月针对同一个receiverId多搞几个key分段来存储
4.群聊数据收发机制-读扩散与写扩散详解
1. 群聊我们目前的设计是基于读扩散,就是说用户在群里发一条消息只存一份数据,群里所有人都
读同一份消息数据,这种方式比较简单,但是会有些问题,比如钉钉或企业微信的群聊消息已读用户列表功能就不太好实现了。
2. 写扩散机制:就是说用户在群里发一条消息会针对群里每个用户都存一条消息索引,然后再单独
存储一份消息内容,这样可以针对用户是否已读做一些处理,但是写扩散有一个问题就是群的人数不能太多,否则性能会有问题,而且会有大量存储浪费,比如万人群聊,要是用写扩散,每个用户发一条消息,要存储上万条索引,这个对性能以及存储耗费太大。