IM服务器:开发一个高并发的IM服务器难在哪

IM服务器要实现的最基本功能就是消息的转发。——好像是一句废话!

这就意味着IM服务器要为每个登录用户创建一个与该用户信息相关的内存上下文,为方便描述我们在这里称之为:user_context。user_context中一般包含这些基本信息:用户id、昵称、peer端的ip和端口,以及最重要的用于通信的socket。

用户连接上线时,需要malloc一个user_context块,用于存储上述信息,用户断开连接时,需要free这个user_context块。

IM服务器要随时维护这张user_context列表,这张表我们在这里称之为:list_user_context。这张表非常重要,im服务器要根据这张表进行消息的转发。如果100个用户登录,list_user_context表中就有100个元素,10万个用户就有10万个元素,用户间聊天时,IM服务器就需要反复查询list_user_context,从而确定转发的消息要发送到哪个用户的机器上。

举个例子:用户A发消息给用户B,基本流程如下:
1、A将消息发送给IM服务器;
2、IM服务器解析消息,获取该消息的接收人为B;
3、IM服务器查询list_user_context表,找到B的user_context(里面有B的连接通道socket);
4、IM服务器将A的消息转发给B;

正常流程都没有问题,我们说下特殊的情况(注意不是异常情况):

【特殊情况一】

A在发送消息给B时,B突然退出客户端程序。
此时IM服务器接收到2个来自IO层的事件:
事件1:A发给B的聊天数据
事件2:B的掉线通知

这两个事件会触发IM服务器进行如下两个操作,
操作1:查询list_user_context表,找到B的user_context(一个指向该内存的指针),并准备转发A的消息。
操作2:查询list_user_context表,找到B的user_context,从表中移除并准备释放指向该内存的上下文。

这两个操作可能是在不同的线程中执行,实际上在IOCP这种完全异步的模型下,这种可能的几率非常大。这时候B的user_context所在的内存区就是“临界区”,操作不当就会导致访问“野指针”,从而导致整个IM服务器挂掉。当然你可以给list_user_context表加把锁,加锁可以减少访问野指针的几率但还是无法完全避免这种情况的发生。

如果IM服务器先执行释放操作,也就是“操作2”,则是安全的,当“操作1”执行时,由于查找不到B的user_context,就会认为B已离线,并放弃发送操作。但如果“操作1”先执行,IM服务器首先获得了指向B的user_context指针,刚准备发送数据时,CPU的时间片切换到了“操作2”上,并把B的user_context释放,之后,CPU时间片又切换到“操作1”上,此时im server会访问之前 查到的B的user_context内存区,这时访问异常,服务器程序崩溃。这种几率看似很小,但在高并发且聊天繁忙时,还是会发生。注意这种情况不是异常情况,而是在真实的业务场景中会实实在在并且经常发生的情况。

当然,你可以将锁的范围扩大,也就是从“临界区”数据访问扩大到操作层面上,也就是将整个发送操作和释放操作进行加锁,从而确保CPU在时间片切换时仍能保证读、写、删除等操作的原子性。这种方式虽然安全了,但显然会让你的服务器从底层IOCP的完全异步,退化为一个业务层面上的完全同步。
如果1万人同时聊天的话,其结果将是灾难性的。如果是群聊的话,就会更加复杂,如果A所在的群有100人,这就意味着IM服务器要将消息转发给群中的其他99人。这99人可能会在此时发生各种情况,比如某些人突然退出或者突然退群。

【特殊情况二】

先说一下IM服务器和WEB服务器在设计上的最大不同。理解这一点,就能体会到IM服务器设计上的复杂性。 WEB服务器,也就是基于HTTP协议的服务器,其业务可以抽象为:请求应答式服务, 即客户发送请求,服务器响应请求,一问一答。即使是用POST命令上传文件也是基于请求应答式,只不过发送请求的数据特别长而已。服务器在没有收到请求时,不会主动发送数据给客户端,这点非常重要,也就是说同一时间要么只有一个读操作,要么只有一个写操作。

“请求应答式”业务,如果放在IO层看就是读写同步,服务器从IO中读完请求后开始向IO中写响应。实际上大部分应用协议都是基于请求应答式,比如:Telnet、FTP、POP3、SMTP。。。,这种方式在业务层面处理起来比较简单。

另一种业务模式,就是:“非请求应答式”,比如IM,读写之间没有联系,读写操作可能同时存在。

A在给B发送消息的时候,可能会同时收到B发来的消息,甚至还有其它人的消息,这时A要时刻保持“读”监听状态,同时也会进行“写”操作。对于IM服务器来说,既要保持对A的“读”监听(用于接收A发来的消息),也可能要对A进行“写”操作 (转发其他人发给A的消息)。

假设A和100人同时进行聊天,就意味着IM服务器可能要不停的对A的IO进行“写”操作。即使A不发送消息,IM服务器也要保持对A的“读”监听。如果此时,A退出聊天客户端程序,而此时尚有98条消息正在准备发送A。那些存在于内存中的98条消息,该如何释放?
服务器捕获到A离线,开始准备释放A的user_context,此时服务器正在向A转发群聊中来自不同用户的消息给A(上述98条消息),这时一旦处理不当就会导致内存访问异常,从而造成服务器崩溃。
当然这种情况下,你也可以通过加锁来解决,但遇到的问题和上述 【特殊情况一】 一样,你可能要锁的不是一个数据临界区,而是一个完整的操作,从而确保操作的原子性,避免内存访问异常。但过多的加锁会导致IM服务器性能大打折扣。

如果是IM服务器集群则会更加复杂,不同的用户会登录在不同的IM服务器上,A可能在服务器1上,B可能在服务器2上。A给B转发消息时,可能B已从服务器2上离线。如果支持群聊的话,就更加复杂了。

举一个极端的例子:
假设存在一个100台IM服务器的集群,你有99个好友,你和他们分别登录在上述100台服务器上,也就是说大家彼此登录在不同的IM服务器上。此时你要和每一位好友聊天就需要知道每位好友当前登录在哪台IM服务器上,你给每个好友发送的消息都需要进行服务器间的转发。如果你和他们分别建立N个群组的话,则每台IM服务器都要知道每个人所在的群组,从而进行群组消息的转发。

总结一下,IM服务器的业务复杂就在于:用户间会频繁的进行交互。回到文章开头的那句废话:IM服务器要实现的最基本功能就是消息的转发。正是由于消息的转发,才会导致临界区的存在,因为某一时刻在线的用户,可能在你给他发送消息时,已经下线。
IM服务器编写的难度和复杂性就源于这句废话。 因为你编写不是一个简单的demo,而是要处理和解决所有可能的意外和异常情况,从而让你的服务器健壮、可靠和稳定。

最后多说几句:

单机并发量越高,需要的集群机器就越少,成本就越低,整个系统的复杂度也会降低。假设需要开发一个支持千万级在线聊天的IM服务器。
如果单机支持1万,则需要1000台IM服务器,如果单机支持10万,则只需要100台。显性成本就是需要多购买900台服务器,如果1台服务器价格1万,则要多付出900万。隐性成本就是每台服务器每年的电费或机房托管费,假设每台每年成本为1千元,则每年要多付出90万。此外,服务器集群越多,复杂度越高,开发成本越高,运维成本也就越高。所以要尽量采用好的IO模型开发服务器端,比如linux下的epoll,windows下的iocp,从而提升单机的并发量。

posted @ 2021-11-02 16:19  一只会铲史的猫  阅读(960)  评论(3编辑  收藏  举报