有不少人天然觉得基于web的在线聊天很麻烦,其实如果只是单纯的实现聊没什么难的,难点在于怎么保证性能啊. 貌似 node.js 、java netty 、golang 的方案及文档较多一点,谁让人家靠着异步非阻塞成名已久….
据我所知大多数基于web即时通信性能都不强,这也其实跟业务导向有关系的。 比如一个足球论坛的app里面有即时通信,那么我们会经常在这聊么 ? 可能吧, 但更多时候是为了方便沟通吧 . 好了, 如果你的主站和app应用对即时通信的性能、及时性要求不高,可以参考这个方案。 我尽量的描述的通俗易懂一些. 当然这方案也只适合量级不大的场景,但绝对够用了…. 在分布式压测场景下同时连接过几万不是问题 .
开发语言是python,本是打算用golang构建,但类似鉴权、签名、逻辑心跳包、redis一致性hash的库包等等都是python完备的…. 在前面的接入层使用nginx, 后端的服务可以使用tornado,存储可以使用redis做缓存及关系映射,mysql存历史数据. 这里主要使用长轮询的方式来通信. 绝对不是所有的请求连接都需要长轮询,在用户没有点开聊天窗口期间, 你可以使用长期长一点的短轮训方式…. 目的是为了减少开销, 毕竟后端聊天系统能hang住的长连接是有数的….
长轮询优缺点:
长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求。
缺点:服务器hold连接会消耗资源。
实例:WebQQ、Hi网页版、Facebook IM。
如果问我为什么不采用那种底层tcp、udp方案? 那么我只能说个人能力有限,精力也有限. 如果直接使用tcp开发服务端需要考虑一些协议及粘包等问题. 用tcp进行通信聊天的话,其实也需要像http那种解决数据格式化,序列化等问题. 比如使用 thrift、google buffer protocol什么的。 mqtt、xmpp的方案是否靠谱 ? xmpp 这个渣就算了,毕竟被伤过…. mqtt是个比较轻快的协议,但也放弃了,究其原因还是业务趋向不需要复杂的架构,另要考虑快速迭代开发…
该文章写的有些乱,欢迎来喷 ! 另外文章后续不断更新中,请到原文地址查看更新. http://xiaorui.cc/?p=3969
我们不可能每次相关的数据都从mysql里面取,这样对于db的压力还是很大的,所有我们需要加万金油缓存层,推荐使用数据结构丰满的redis. 这里把几个设计到的关键数据结构说明下:
标记某个用户的未读消息个数. 下面是采用redis hash格式来映射关系的, 对于 用户a 来说, user_b 有一个未读消息. user_c 有三个未读消息.
not_read_event_user_a user_b 1 not_read_event_user_a user_c 3
(可不加, 下面的windows里有时间戳id,我们可以配合sendbox算出来,当然浪费网络io)
窗口更新关系 , 这样用户一打开即时通信服务的时候,就能方便拿到 跟该用户聊天过的用户们的聊天记录的更新点. (通俗点说,a跟b聊到哪里了,跟c又聊到哪里了…. )
windows_user_a user_b 时间戳ID windows_user_a user_c 时间戳ID
对话的历史记录,缓存聊天数据的地方, 使用redis sorted set的score做时间id,另外sorted set是有hash去重的功能, 这样也能解决信息重复投递的问题。
sendbox_a_b 时间戳ID json( [{'type': 'url', 'content':'www.xiaorui.cc'}, {'type': 'img': '图片地址'}, {'type': 'text', '风云就她了'}] ) sendbox_b_c .....
这里的a_b , b_c 是排序过的, 原本设计方案是 每个人都有独立的sorted set 数据结构. 但这样没有时间的排序性了. 其实也可以把这些数据都放在一个地方存储,然后用sendbox来引用这些数据的全局ID.
记录用户的存活心跳
keepalive_user_a last_timestrap , 配置 expire超时时间.
记录持久化数据点
dump_sendbox_a_b 时间戳ID dump_sendbox_b_c 时间戳ID
关于具体的聊天记录冗余到一个redis list里, 用途? 当然是做持久化了
[data, data ...]
客户端跟服务端是怎么运作起来的 ?
缺少一个图,后期补上….xiaorui.cc
对于客户端来说,主要分为两大动作,信息投递及信息拉取.
信息投递,比较简单,我只要标明谁是发送者,谁是接受者即可,如果是多媒体相关的信息,那么需要前端和移动端做包装, 比如一次性发送的信息里有图片、url、富文本 ,对于图片需要来说,需要单独调用后端的图片上传接口,然后返回图片的url, 到此为止,我们会组装成json,扔到后面.
信息拉取, 这个比较有意思的,上面我有说过,需要用长轮询. 他的好处是什么? 最大的优点是不用总是轮询后端了 . 所谓的长轮询就是我访问后端试图拿取记录,但问题是现在没有可更新的数据, 我可以把你请求事件暂时放起来,当有事件触发的时候再把你唤醒起来。
我最初的做法是,一个长轮询请求过去后,一看没有新数据,那么我会加个非阻塞定时器,比如5秒的定时器,当五秒过后,我又被唤醒起来,看看有没有新数据,就是这么一个来回的过程…. 先前想过太多的异步非阻塞的概念,我这里不想再浪费事件阐述原理了。
后来发现,当并发太多再加上超时密集发生,瞬时间压力真心不小呀。 16个cpu核心的服务器跑了16个tornado进程,每个进程都干到了60%左右 …. 所以说,定时自我查询的方法在量级破 3万 下有些浪费系统资源 …
接着改用基于事件的就绪通知方法,我在tornado当前线程下设立一个全局hash,记录了每个用户可唤醒事件连接,第一次long pull过来请求拿不到数据后,他会挂在这个event上,该coroutine会让出当前的执行单元. 当有人跟前面用户说话时,我们会把event唤醒,那么他自然而然也就取到了数据并返回…. 那么他的问题在于什么 ? 不能横向扩展,事件集只能在一个进程里,tornado又没有多进程方案…
上面的架构模式不能横扩展的原因是 事件集不能跨进程,更不能跨机器… 所以接着上面的想法,把唤醒的event事件换成redis了….. 借助于redis是可以横向扩展机器…. 这里用的是redis list数据结构.
有三个原因让我这么选择:
1. redis单线程,所以原子不是问题.
2. 可以往list添加 发信人, 这样通过窗口关系立马找到新消息.
3. redis brpop为阻塞接口会挂起coroutine , 直到 io就绪.
这样就妥了么? 还真是妥了… 但问题来了,群消息不好实现呀…. 一个群有100个人,小王在里面说话了,难道我要一个个的向其他99个通知… 如果用上面的模型,还是这样的….
最后好的解决方法是经典的 PUB / SUB 发布订阅模式 . sub可以同时订阅很多信道,在聊天系统下每个用户只需要关注自己信道和群信道就可以了… 我曾经以为 pub/ sub 会引起惊群的效果,后来根据抓包分析才知道,像redis 这样的 broker 会帮你做信息路由处理,不会触发唤醒.
发送方及接受方的详细流程:
当用户A给用户B发信息的流程, 用户A的信息流首先会入redis的信息表里面,然后根据发送人和收件人组成一个sendbox对话key,然后也入库。 这时候会判断用户B是否在线 ?
如果不在线,那么会找到用户B的窗口映射那边关于用户A的上次记录,如果该hash的字段缺失,那么把时间戳ID – N 给hash set上去,为什么要减去N ?如果写当前的时间戳ID,用户B上线后是看不到未读通知的. 另外在窗口关系用户A里的用户B也要更新额, 最后由服务端返回ack.
如果在线,那么会找到用户B关联的event并唤醒起来,这时候用户B的Long Pull 得知用户A来信息了,然后取数据返回到前端, 当服务端给用户B返回数据后,用户A和用户B的窗口关系也会跟着更新.
在线讨论组, 群聊天又怎么实现?
原理跟上面用户之间的聊天逻辑很相像的,但多出了用户成员及群主的概念. 我下面简单说个大概,现实中的即时通信开发还是要靠近业务的.
1 . 首先需要创建一个group ID
群信息可以直接入mysql库.
2. 需要组成员的管理,拉进去,踢出去
跟上面一样,可以在mysql里维护关系. 在redis里设立set集合用来快速判断发信用户是否来自群用户.
3. 窗口关系管理,标记出在群里的上次信息点.
uid3 ts windows_group_xxxx uid4 ts
4. 需要创建以group ID 为名字的sendbox历史记录
#xiaorui.cc sendbox_group_id ts json(xxxx) ts json(xxxx)
5. 群用户唤醒
难点在于如何唤醒这么多对应群用户, 以前的用户之间的唤醒简单,我直接告诉你就可以了…. 但是群的人数太多,我需要怎么一一通知 ? 还是有什么高级点的技巧 ?
上面已经说了…. 不在重复了.
常见的几个问题:
1. 聊天服务的鉴权怎么做?
这是我使用http的原因之一, 直接照着web的样子撸就可以了.
2. 发送者发送信息,server端收到了,入库了,但是返回ack时出问题,肿么办?
每条信息都有计算好的gtid,首先gtid不可能重复 . 对于发送者来说,没有ack,我就认为是没有收到,在异常和timeout之后,我会重新发送该gtid的信息,但因为信息的存储是用跳跃表和hash结构组成的sorted set , 所以他们帮你去重了….
3. 接收者拉取信息后,返回时出错怎么办?
我们会记录对话框角色的时间戳id,当然也可以拿着起始时间来获取区间的数据, 如果起始时间为None,那么会从redis取上次的时间点,如果数据太多,那么会从最后面拿数据…. 用户看到数据后,可向上滑动拉取数据.
4. 如果redis缓存崩溃导致数据丢失怎么办?
会有一个服务专门来把mysql的一些数据映射到redis里面 .
5. redis里面聊天记录存多少天合适 ?
可以按照两个维度, 如果零零散散的话,直接都保存得了。 不然存半个月的数据足矣了.
还未写完, 好几个图都没画完…. 很快就补上了.
图呢?