Python基于web的在线即时通信IM方案

     有不少人天然觉得基于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里面聊天记录存多少天合适 ? 

可以按照两个维度,  如果零零散散的话,直接都保存得了。  不然存半个月的数据足矣了. 


还未写完, 好几个图都没画完….  很快就补上了. 

 


大家觉得文章对你有些作用! 如果想赏钱,可以用微信扫描下面的二维码,感谢!
另外再次标注博客原地址  xiaorui.cc

1 Response

  1. 创e 2016年12月21日 / 上午11:44

    图呢?

发表评论

邮箱地址不会被公开。 必填项已用*标注