IM服务器:编写一个健壮的服务器程序需要考虑哪些问题

如果是编写一个服务器demo,比较简单,只要会socket编程就能实现一个简单C/S程序,但如果是实现一个健壮可靠的服务器则需要考虑很多问题。下面我们看看需要考虑哪些问题。

一、维持心跳

为何要维持心跳,TCP难道不是一个安全可靠的连接么?正常情况下,C端和S端无论是谁掉线,对方都能感知到。从而进行后续处理,比如释放维持的资源并通知业务层进行相应的业务处理。

如果TCP通道非常繁忙,C端和S端都能通过正常的业务通信感知到对方的存在与否。但如果TCP通道长时间无数据往来,这种感知就无法主动获取到,这时就需要通过心跳包来进行检测。 看看下面的情况:

1.1、突然死亡

客户端突然断电、死机等,这种情况下对方都来不及跟你道别就驾鹤西游了,只留下服务器搁那傻等。

1.2、突然失联

比如:网线突然脱落或防火墙强行关闭TCP通道。防火墙为何会关闭TCP通道呢
防火墙认为C端和S端长时间没通信,可能感情破裂了,因此继续维持两者之间的联系毫无意义,所以单方面宣布两者离婚,强制执行,立即生效。
上面是我猜的,实际情况是防火墙出于对服务器的爱,防火墙时刻监视着所有连接到服务器上的TCP通道,如果有长期占着茅坑不拉屎的连接,防火墙就会认为该连接是恶意的,是在对服务器耍流氓,因此有必要立即断开连接。
此时C端和S端虽然都活着,但两者之间已经阴阳两地,不可能在碰面了。
上述情况下,如果不进行心跳检测,服务器长期运行后,可能存在大量的“僵尸”连接,从而过多的占用系统资源。对于业务层来说如果不及时处理这些“僵尸” 可能造成业务处理的混乱。

二、处理超时

为何要处理超时? 我们通常理解的超时处理,大部分是基于套接字(socket)这层,超时有可能是网络拥塞导致,也有可能是上述的突然死亡突然失联导致。
如果send或recv长期无法完成,则有可能是TCP通道失效或对方已不在服务区,因此服务器端有必要主动进行关闭操作。对于超时,你可以粗暴的直接关闭连接,也可以在尝试N次发送或接收都超时后进行关闭

对于这种socket超时,我们只需要通过setsockopt函数在网络层进行超时设置。对于阻塞套接字而言,这种方式是可行的,但对于异步模型,这种方式则无法采用,比如IOCP模型。在IOCP模型下,所有投递的读、写操作都需要业务层进行超时判断。

上面的超时大家都比较清楚,其实超时处理最重要的作用是防止恶意连接,从而增强服务器的健壮性。
以HTTP协议为例,服务器需要读取HTTP请求头,这个请求头会以两个连续的回车换行(\r\n)来标记结束。
服务器只有读取完请求头后才能进行下一步的解析和业务处理工作。如果请求方在发送一半请求头后,迟迟不发送结束标记,就会导致服务器傻等,因为服务器会认为一次完成会话(HTTP Sesstion)并没有结束。
或者,对方在content-length字段中指明长度为100字节,却只给服务器发送了99字节后跑路。如果没有超时,服务器会一直痴痴的等着这最后一个字节的到来。
因此有必要在超时后进行会话关闭,否则这种恶意连接会很轻松的耗尽服务器有限的连接资源。

因此处理超时,不仅能解决网络层的意外问题,也能有效解决业务层的耍流氓行为。 当然超时也可能导致误伤,但相较于整体安全而言,这点误伤是可以理解的,大不了重联,重新培养感情。

三、实现定时器

这个好理解,上面的心跳检测,需要定时器来周期性的发起(如果你的超时判断不是依赖socket自带实现机制,即通过setsockopt函数设置KEEP_ALIVE参数来实现的话)。ngnix、redis、libuv(nodejs使用的底层库)等服务器都有自己的定时器实现逻辑,设计一个好的定时器有助于减少不必要的资源浪费
定时器可以帮服务器维持心跳检测,同时也能帮服务器做一些自身维护方面的工作,比如定期检查内存、CPU使用情况,定期同步(保存)数据等。
此外,处理超时也需要定时器来进行检测,对于IOCP模型,无法通过setsockopt函数来设置套接字层的超时,只能通过业务层来自己实现,也就是对于每个发出的IO请求(读写操作)记录时间,并在IO请求完毕后更新时间。定时器要周期性的检查所有IO请求是否完成,或者是否超时。比如投递一个写操作,如果长时间没有写完毕,则需要进行超时处理。

对于上万连接,该如何设计自己的定时器? 如果对每个连接socket(TCP连接)都启动一个定时器进行超时或心跳检测,则定时器本身就会消耗大量的系统资源,显然这种方式是不明智的。
如果只启动一个定时器,去检测成千上万连接,则需要考虑如何在CPU空闲或IO空闲时的去做这些事。比如当服务器准备向某个TCP通道发送心跳包时,该通道正在进行正常业务会话,此时心跳包可能会干扰正常的业务数据。比如CPU很繁忙的时候,如何让你的定时器进行错峰检测。
也就是说,你的定时器要根据你的服务器业务特点亲自实现,并融合到整体的IO调度中。

四、有罪推论

这个和现实中的无罪推论相反。服务器设计上,一定要假设所有请求可能都是非法的,要做有罪推论。 我们不能想当然的认为每个连接请求都会按照标准的协议与服务器通信。

大部分协议都是通过特定的结束标记(\r\n)来表示一次完整的请求或数据响应的完成。比如HTTP、FTP、TELNET、POP3、SMTP等协议。上古时期,早期操作系统UNIX(或DOS),用户操作界面就是控制台,控制台的输入输出方式就决定了用户只能通过敲击键盘将协议命令输入到网络,这也就导致了回车换行"\r\n"会作为一次命令结束的标识。 比如HTTP协议,与主机建立连接后,输入"GET / HTTP/1.1\r\n"即可获取网站的主页。

还是以HTTP协议为例,HTTP请求头是以两个回车换行(\r\n\r\n)来标记结束。如果对方一直发送数据,而不发送结束标记该如何处理? 假设我们开辟一个4K(4096)字节的缓冲区用于接收HTTP请求头,对方发送的请求头超过4K怎么办,当然你可以remalloc内存继续接收,但如果是恶意请求呢?比如对方一直发送数据,直到把你的服务器内存消耗殆尽。这时候就需要我们设置一个阀值,超过该值时要立即断开连接。
这种方式可以理解为对讲机模式,一句话讲完后必须要带上一句over,属于后付费 。对方在你没有发送over之前无法知道最终数据有多长。这种后付费方式容易让对方吃霸王餐,比如吃完之后没说over(没付钱)就跑了。。。。

还有一种协议不是以“over”标记符来表示请求的完整性。而是通过请求头中的“长度字段”来表示后续数据的大小。这种方式可以理解为报文方式。 属于预付费 ,就是一开始就告诉对方自己要发送数据的大小,或者告诉对方自己有多少钱,可以消费多少,让对方提前准备好缓冲区。

这种方式下会有一个固定大小的报文头,报文头的字段有严格的定义,用于指示后续数据的实际情况或者意义。
后付费能吃霸王餐,预付费也是可以的,也就是数据长度可能是假的,长度字段虽然是1000个字节,但最后给你2000个怎么办?或者只给你500个怎么办?

以websocket协议为例,虽然websocket协议是基于HTTP协议,但这仅限于建立会话阶段。一旦会话建议,websocket就会通过固定格式的报文来进行数据交流。这种情况下我们要严格检验报文的格式,比如长度是否合法。
此外,对于所有recv来说,一次接收的数据不一定是你想要的结果,不是缓冲区开辟了多大,对方就一次性发给你多大。极端情况下,对方可以一个字节一个字节的发送数据,这时候你就要进行数据的封装和实时校验。

上述情况都会涉及到内存的分配和访问,一旦处理不当就可能造成系统资源耗尽或这服务器的直接coredown。

五、使用内存池

从上面我们可以看到,内存的分配和销毁是频繁发生的事,服务器长期运行就会导致内存碎片的产生。我的这篇文章对此有详细的说明。
【超值分享】为何写服务器程序需要自己管理内存,从改造std::string字符串操作说起

写累了,到此为止吧,考虑的问题还有很多,比如你的上层业务是IO密集型还是CPU密集型,这就会对你程序架构产生影响,比如是否考虑使用线程池?这就是为何redis采用单线程,nginix采用多线程的原因之一。

posted @ 2021-12-24 15:48  一只会铲史的猫  阅读(541)  评论(0编辑  收藏  举报