老旧业务重构案例——IM系统如何设计
一年半之前刚来到这个团队,便遭遇了一次挑战:
当时有个CRM系统,老是出问题,之前大的优化进行了4次小的优化进行了10多次,要么BUG重复出现,要么性能十分拉胯,总之体验是否糟糕!技术团队因此受到了诸多质疑,也成了我这边过来外部的第一枪。
当时排查下来,问题反复的核心原因是:
该系统依赖一个核心IM系统;这套IM系统已经有几年时间,之前的同学一来是没有魄力去做重构,二来是没有能力做重构,所以每次只能小打小闹,但里面的服务旁枝错节,总有依赖服务没被修复好。
考虑这种情况,我这边派出了两支团队,一只由小孙带团,给一个月时间做完整重构势必解决问题;一只之前的小分队,应付一下业务团队即可,而真实业务端的压力以及上层的质疑,由我一肩挑起,我这里毕竟是新来的,由一个耍赖皮的窗口期,以下是重新设计的核心模块。
— 1 —业务梳理
在线上,医患沟通、患者与健康管理师、医生和医生助理沟通,这些远程交流的场景离不开IM,它是这些沟通一个基础建设。
健康管理师和医生助理在协助医生帮助患者的场景下,他们使用的工作台是让线上的随诊、问诊快速方便的开展相关重要功能。
如果要做这么一个满足现在业务场景的工作台需要怎么来实现及优化,以下讲解如何搭建和优化工作台IM核心功能。
— 2 —IM核心架构
第一个问题业务底层的IM架构是如何的?
下图中分了三种类型的服务一种是comet,一种是gateway以及内部使用grape-http。
comet:推送核心是长链接接实时推送,comet主要是作为tcp/websocket的一个接入层足够简单负责客户端长链接的维护链接保活的心跳机制和消息下行推送,同时还具备一些比如重链的一些特殊指令下发;
gateway:为了减少服务的复杂度将消息上行的功能抽到http接口来承接,包括登陆功能、comet集群负载均衡,发送图片、音视频,基础信息查询等。
grape-http:作为内部其他服务调用comet推送的内部服务。
需要特别注意:
1)网络传输大小端;
在网络传输中需要注意大小端问题,什么是大小端?
对于一个由2个字节组成的16位整数,在内存中存储这两个字节有两种方法:
一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序;
另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序。
总而言之,大端是高字节存放到内存的低地址;小端是高字节存放到内存的高地址,在网络通信中,不同的大小端CPU需要做数据处理再进行传输。
2)TCP粘包;
在socket网络编程中,都是端到端通信,由客户端端口、服务端端口、客户端IP、服务端IP和传输协议组成的五元组可以明确的标识一条连接。
在TCP的socket编程中,发送端和接收端都有成对的socket,发送端为了将多个发往接收端的包,更加高效的的发给接收端,于是采用了优化算法(Nagle算法),将多次间隔较小、数据量较小的数据,合并成一个数据量大的数据块,然后进行封包。
那么这样一来,接收端就必须使用高效科学的拆包机制来分辨这些数据。
解决方式:
第一个种特定分割符格式化数据,每条数据有固定的格式(开始符,结束符)。这种方式简单,但是选择符号时一定要确保每条数据的内部不包含这些分隔符;
第二种自定义协议发送定长数据,发送每条数据时,将数据长度一并发送,例如规定数据的前4位的数据的长度,应用层在处理时可以根据长度来判断每个分组的开始和结束位置,如下图自定义协议格式:
— 3 —核心流程描述
上面说了整体的架构以及网络编程中需要注意的大小端和TCP粘包问题,接下来描述下大致的流程
1)客户端首先登陆,此处是采用http的方式进行登陆和鉴权
2)鉴权成功后,会返回一个comet的列表ip加端口的列表,客户端可以选择1个节点进行接入,通常负载最少的排在最前面,进行tcp/websocet的Auth认证通过之后链接上一个comet的节点。
3)此时已经建立了长链接,客户端可以通过http接口发送文本消息、图片、视频、语音消息,其中图片、语音、视频都是传到cdn上,然后将链接地址放在消息体中发送。
4)当comet某个节点挂掉了,客户端会尝试重新获取comet节点列表进行链接,如果多次都没有可用节点或者链接不成功,会告知用户服务链接失败。
5)如果comet的节点需要进行灰度升级,服务端会先加入新节点,然后灰度下线某个节点,下线的n节点会分批向该节点链接的client,发送重链的指令让客户端无损的方式断开重新链接其他节点,当client都转移到其他节点上之后,节点自动退出。
TCP链接是有链接的和http不一样,为了comet高可用做多个comet的集群不像http的负载均衡那么简单可以在前面挂一个nginx,每个client链接上一个comet的节点上,要推送到指定的client消息,需要判断这个client是链接到哪一个节点上的,comet的集群为了让所有节点更均衡采用了一致性hash算法的方式来进行comet负载均衡
这样重新加入或者减少comet节点,需要client发起重链的就会变少。
— 4—业务心跳链接保火
先看下comet如何维持链接的存活,TCP协议自身已经有KeepAlive机制,难道不能保持链接存活么?
为什么需要应用层做心跳,这是TCP KeepAlive的机制决定的,KeepAlive存在一个探针以确定链接的可靠性,一般时间为7200s,失败后重试10次,每次超时时间75s,默认值无法满足我们的需求,即使修改设置后还是不能满足,TCP KeepAlive是用于检测链接的死活,而心跳机制则有一些业务的额外功能,检测通讯双方的可用的存活状态,比如TCP是链接成功的。
但是服务器已经CPU使用率100%无法处理业务了,此时链接成功,但是业务上是失败的,基本上心跳回复不了。
在我们comet中有TCP/websocket的心跳机制,简单的做法是客户端定时心跳,比如间隔30s发起一次Ping消息,服务端回复Pong消息,如果15s内没有收到心跳Pong消息,则此链接失效,需要断开之后重新进行链接。
为了节省流量,心跳包要足够小,并且频率也不能太高,尽量拉长心跳间隔,5分钟,10分钟,或者更加优化的方式是5分钟内没有和服务器交互消息空闲才会触发心跳逻辑,减少请求次数,移动端需要考虑心跳定时的范围耗电等资源消耗。
— 5 —消息时序&一致性
严格需要时序的场景:消息发送走的http上行,如何保证群消息的有序性和一致性,根据群id进行sharding到单点串行化写db的inc_id生成的递增id,返回后进入推送的有序队列中进行消息下行阶段
非严格时序场景:分布式集群下,采用分布式id生成器进行递增生成,每个群需要id串行。
分布式场景下,消息的有序性很难,原因很多,时钟不一致,多发送方,多接收方,消息量大网络传输问题等。也可以要有序可以客户端,或者服务端来进行有序标志
绝对有序场景需要严格控制id有序生成,单对单聊天,只需保证发出的时序与接收的时序一致,可以利用客户端有序;群聊,只需保证所有接收方消息时序一致,需要利用服务端seq,方法有两种,一种单点绝对时序,另一种id串行化。
— 6—消息丢失问题
作为严格的医疗问诊场景,用户和医生的聊天记录是不能重复和丢失,使用业务层进行消息的ACK回执保证线上的消息不丢,发送消息出去。
用户在线:推送消息,并且业务上进行ACK回复确认发送成功;
用户离线:服务端会记录未推送的未读列表,当用户再次进入聊天窗口,拉取历史消息进行ACK确认发送成功。
在客户端要进行消息的去重操作,如果ACK回复没有返回或者操作失败的情况下,服务端会再次推送消息。
在线情况下:每发送一条消息,群里有多少用户,就会有多少个消息ACK确认的应答,如何群人数足够多会对服务器造成瞬时的ACK请求,为了减少这种减少瞬时大量请求,通过两个业务逻辑进行优化,每收到X条ACK一次批量ACK回执,则请求降低到1/X了;
但是如果一直达不到X条呢,需要每隔一段时间进行一次批量ACK,能补偿一直达不到X条的情况。
离线情况下:在用户长时间离线,再次登陆时,需要拉取未读消息,如果是APP会保持到本地,需要保证APP和服务端的消息列表数据同步。
1)登陆成功需要拉取好友列表(id+姓名+未读数量+最后一条信息+最后一条信息时间)
2)群组列表(id+群组名称+未读数量+最后一条信息+最后一条信息时间)
3)群详情(按需加载)
设备长期未登陆在未读消息量大的情况下,防止client端拉取大量未读消息卡顿,需要延迟分页拉取,当进入群列表时分页拉取消息,下一页的拉取,同时作为上一页的ACK,这样可以减少与服务器的请求次数。
更换新设备登陆需要拉取全量数据,可以将数据打包下发此场景也需要区分整个数据拉取的分批次,优先是好友记录,群列表,然后拉取部分消息,最后的消息记录需要按需拉取尽可能减少初始化拉取的数据量以及访问服务器的次数。
好友在线状态
好友在线状态,如果对展示的实时性要求高,可以采用推送方式同步,但是如果好友太多,这推送的资源成本太高,好友数几十万进行推送同步这种不太现实。
可以做按需展示,当到好友聊天界面或者进去群聊,采用拉取,延迟拉取的方式同步,界面可视的区域拉取在线状态。
— 6—消息已读功能
在进行聊天中,发出去的消息是否对方已经收到。
谁读了,谁假装没在线,要做这个功能实现前面已经说过,保证消息不丢失有业务层ACK反馈,同样消息已读也需要有回执机制。
和ACK不一样的是已读标记只需要记录last_msg_id标记,在last_msg_id之前的都是已读,有新的msg_id> last_msg_id存在未读消息,当client打开消息对话框,last_msg_id标记为最新的则清空未读数量,并且需要广播未读人数,修改未读数逻辑。
client发送已读的last_msg_id到服务器端,则判断用户进入群的时间和消息时间进行对比,如果进入群时间早则需要修改last_msg_id和之前群成员表中的last_msg_id中消息的unread数量。
群消息:g_msgs(msg_id, gid, suid, time, msg, unread)
群成员表:g_users(gid, uid, last_msg_id)
已读成员列表:g_readers(msg_id,gid,uid,time)
来看群消息流程:
1、client A 发出群消息
2、服务器将消息写入到db,然后查询群成员。
3、分别对群成员进行在线判断并实时推送。
4、根据client 回执的last_msg_id来记录消息的已读人数。
同样已读回执也会出现短时间大量回执请求的情况,也需要减少回执的请求量。
做好一个IM系统支持业务真的很不容易,这些优化只是九牛一毛,欢迎大家一起讨论学习,让自己的系统更加稳定更好的服务业务。
— 6—结语
经过一轮操作,系统首轮重构结束,小孙在团队中的势能得到了很好的提升,我来团队的对外一枪也打响了,为后续机制推行、技术升级都有莫大好处。
老旧业务重构案例——IM系统如何设计的更多相关文章
- 7款适用老旧设备并对初学者非常友好的轻量级Linux发行版
我们由从 7 到 1 的顺序向大家介绍. 7. Linux Lite 正如其名,Linux Lite 是 Linux 发行版的一个轻量级版本,用户并不需要强大的硬件就可以将它跑起来,而且其使用非常简单 ...
- 使用 UML 进行业务建模:理解业务用例与系统用例的相似和不同之处
使用 UML 进行业务建模:理解业务用例与系统用例的相似和不同之处 作者:Arthur V. English 出处:IBM 本文内容包括: 背景 业务用例模型与系统用例模型有什么相似之处? 业 ...
- CentOS7清理老旧内核
CentOS7如果是一步步内核升级上来的将会面临一个问题(一般安装了图形化界面的系统常见),由于内核增多/boot/的容量减少,导致启动变慢.这时就需要清理老旧内核,释放/boot/空间. 使用una ...
- HomeKit 与老旧设备
苹果推了HomeKit,已经有很多厂商在做,可以达到Siri控制所有设备的功能. 但是Siri也不是万能的,对人类的语义理解也会产生差错,不过我相信未来这个问题会解决掉. 如果家里有老旧的电视 ...
- Angular 学习笔记 ( 创建 library, 转换老旧的 library )
更新 : 2018-10-28 不知道为什么在 ng 跑一直做不到 .d.ts 最后发现,如果有一个插件 propagating-hammerjs.ts 那么就在 root create 一个 pro ...
- 用mongodb 固定集合实现只保留固定数量的记录,自动淘汰老旧数据
在一个保存report记录的场景中,我们使用MongoDB进行数据存储 example: db: report Collection: daily_report 创建db: use report; ...
- ElasticSearch业务逻辑案例
ElasticSearch业务逻辑案例 一.业务难题 我们有一个索引: myindex/mytype(为了方便,我们下文以a/b表示) 索引类型中的一个字段group之前是a.b.c(历史遗留问题), ...
- 5G 融合计费系统架构设计与实现(一)
5G 融合计费系统架构设计与实现(一) 随着5G商用临近,5G的各个子系统也在加紧研发调试,本人有兴全程参与5G中的融合计费系统(CCS)的设计.开发.联调工作.接下来将用几篇文章介绍我们在CCS实现 ...
- 转: 透过CAT,来看分布式实时监控系统的设计与实现
评注: 开源的分布式监控系统 转:http://www.infoq.com/cn/articles/distributed-real-time-monitoring-and-control-syste ...
随机推荐
- DevOps的分与合
一.抽象的 DevOps DevOps 是使软件开发和 IT 团队之间的流程自动化的一组实践,以便他们可以更快,更可靠地构建,测试和发布软件.DevOps 的概念建立在建立团队之间协作文化的基础上,这 ...
- 网络安全:关于SecOC及测试开发实践简介
前言 我们知道,在车载网络中,大部分的数据都是以明文方式广播发送且无认证接收.这种方案在以前有着低成本.高性能的优势,但是随着当下智能网联化的进程,这种方案所带来的安全问题越来越被大家所重视. 为了提 ...
- 如何实现 range 函数的参数?
关于 range 函数 Python内置的range函数可以接收三个参数: class range(stop): ... class range(start, stop[, step]): ... 标 ...
- 每一个each(Power Query 之 M 语言)
each _ 固定结构,表示每一个(废话,each这单词中文意思就是每一个) 但后面的下划线,是个省略参数的写法-- 首先,each后面不是非要跟_ 其次,_代表的意思是指定列里同行的每一个,这时的公 ...
- Python3.6+Django2.0以上 xadmin站点的配置和使用
1. xadmin的介绍 django自带的admin站点虽然功能强大,但是界面不是很好看.而xadmin界面好看,功能更强大,并完全支持Bootstrap主题模板.xadmin内置了丰富的插件功能. ...
- mysql 在删除数据出现Cannot delete or update a parent row: a foreign key constraint fails 这个该如何解决
mysql 在删除数据出现Cannot delete or update a parent row: a foreign key constraint fails 这个该如何解决 可以这样解决: S ...
- python requests库的简单运用
python requests的简单运用 使用pycharm获取requests包 ctrl+alt+s Project:pythonProject pythoninterpreter 点+号搜索 使 ...
- mybatis注解版in查询、字符串判空模糊匹配 、批量插入、插入返回主键
IN查询 @Select({"<script> " + " select * "+ " from business_threat bt \ ...
- JAVA将一个pdf按页码拆分成多个pdf
引入maven <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf< ...
- java源码——统计字符串中字符出现的次数
对于任意输入的一段字符串,读取并且计算其中所有字符出现的次数. 使用HashMap存储字符和其对应的出现的次数,输出时,对HashMap进行遍历. 难点在于对HashMap的遍历,第一次使用,也是学习 ...