MINA及其在高性能通讯应用中的突出问题(NIO架构及应用之二)

前面写过一个博文高:高吞吐高并发Java NIO服务的架构,http://maoyidao.iteye.com/admin/blogs/1149015 。这个架构是和MINA一致的,或者可以说MINA是基于同样的思路构架的。想阅读MINA源代码的朋友可用参考这个架构来研究MINAsource code。但是考虑到在已经有比较可靠的开源实现的情况下,现在朋友们很少会自己去实现一个NIO模块。就想到把它做成一个系列,第一篇主要是讲解一种业内普遍采用的NIO架构,及其架构上的两个要点,即:1. 基于提高的性能的线程池设计;2. 基于网络通讯量的通讯完整性校验。这一篇来讲讲在基于MINA搭建高性能NIO服务时如何具体实施以上两点。有部分代码选自openfire(openfire是一个实现XMPP协议的开源项目,其底层通讯模块为MINA)

MINA的使用相信朋友们已经很熟悉了。把MINA应用于高性能分布式应用的时候,表现出来比较突出的问题还是协议解析的问题和容量规划的问题。

1,协议解析的正确性问题

MINA已经给我们提供了非常方便的接口来实现NIO。coding的重点可能会是在协议解析上,即实现ProtocolCodecFactory接口中规定的encoder、decoder。当收到一个NIO包并从中解析出协议消息体并不困难,且容易测试。但高负荷的通信使得协议数据不能在TCP的一个packet中完全到达,即所谓断包。我经历过某团队到性能测试时才发现这一问题,花了很多时间在海量的tcpdump包中寻找问题,花费了很多时间,而且非常痛苦。如能从一开始了解TCP通讯和MINA架构,则轻松很多。

TCP socket发送大数据时会将其拆分成一个个小数据包发出,由于接收窗口和拥塞窗口的限制,这些数据包可能不能一次发送出去。这时后继的数据包等待在发送缓冲区,等待窗口的扩大。比如在Linux系统,通过修改/proc/sys/net/core/rmem_max改变最大的TCP数据接收缓冲,通过修改/proc/sys/net/core/wmem_max改变最大的TCP数据发送缓冲。另外,即使是比较小的数据包,为了达到最好的性能,我们总是尽可能多的把小数据包拼接起来填充每个报文,以减少发送数据包的个数。比如MINA默认的参数会把John Nagle算法设为false。如下代码,socketSessionConfig.isTcpNoDelay()返回false。

SocketAcceptorConfig socketAcceptorConfig = socketAcceptor.getDefaultConfig();
SocketSessionConfig socketSessionConfig = socketAcceptorConfig.getSessionConfig();
socketSessionConfig.setTcpNoDelay(JiveGlobals.getBooleanProperty("xmpp.socket.tcp-nodelay", scocketSessionConfig.isTcpNoDelay()));
 

这些TCP传输的特性导致NIO收到的packet对于应用协议来说可能是不完整的。因此需要在协议解码类中实现拼接消息的逻辑。

解决办法也很简单,即将这次没有解析完的byte再放到下次数据包头重新解析。openfire的协议解析实现类XMLLightweightParser中startLastMsg记录的就是上次完整消息的解析后ByteBuffer的位置。而StringBuilder类型的buffer则是用来存储上一次收到的数据包中没有解析完的数据。XMPP是XML流协议,解析起来比较复杂,朋友们不看也罢。这些都是代码实现的细节,等用到了再做测试不迟。但抛去其解析协议的复杂逻辑,这不起眼的buffer和startLastMsg则是具有通用性的逻辑。有些协议比如RTSP协议会规定在消息头中传递数据包的长度,其逻辑实现就简洁很多,但也需要实现类似的buffer和startLastMsg的逻辑。

2,容量规划问题。

在本系列第一篇中已经阐述过NIO的瓶颈在于后端架构的实现。当时给出了一个线程计算公式来估计所需的线程。但我在实际情况中发现工程师们往往不愿意做这样的测试,而喜欢简单的将线程池设为CPU核数+1。比如openfire在MINA中设置处理线程池:

// Customize Executor that will be used by processors to process incoming stanzas
ExecutorThreadModel threadModel = ExecutorThreadModel.getInstance("CommServer");
int eventThreads = getCPUCoreNumber();
ThreadPoolExecutor eventExecutor = (ThreadPoolExecutor) threadModel.getExecutor();
eventExecutor.setCorePoolSize(eventThreads + 1);
eventExecutor.setMaximumPoolSize(eventThreads + 1);
eventExecutor.setKeepAliveTime(60, TimeUnit.SECONDS);
commSocketAcceptor.getDefaultConfig().setThreadModel(threadModel);
 

这里我还是建议朋友们在线程池大小上多花些时间,这对提高单台NIO服务效率是很有好处的。特别是在特定场景下更是如此,

1,每个操作都需要调用数据库。数据库操作是重IO,相对来讲CPU比较闲,这个时候就可以多设置些线程。这里还可以有一个优化,就是在db上增加一个写缓冲。对db采用batch write,还可以合并update操作,速度可以提高很多。

2,每个操作都要访问互锁的资源。这就是说实际上线程之间要争夺一个mutex锁,这样的逻辑最好是可以避免的。或者缩小锁的影响范围。如果实在避免不了,则要考虑是否有必要减少线程数量,因为既然都要抢一个mutex,实际并行的也是一个线程。

当然即使线程池大小适度,也不能完全解决容量问题。比如我这里有一个实际模型



这里需要调用远程资源,可以认为是memcached,也可以是其他socket资源。最后还要通过socket发送到其他模块。这时则要求即使这些资源访问全部挂掉,也不能让线程等待很久,否则前端的queue就会堆积溢出。因此resource的调用必须有很快的超时返回,而socket则必须有心跳和很快的超时返回。在这个项目中,经过容量规划我们认为后端socket的超时应该为3秒。这个时间相对是比较短的,但是为了保证整个系统的健康,是必须的。

在发送端设置发送缓冲的意义我想引用Timyang的一篇博客中所说的,

1. Socket占满后写socket会block, 或者设置了TCP_NODELAY则不管SOCKET buffer是否占满都会block
2. 如果应用写入操作没有队列概念则应用程序会出现异常,所有操作会阻塞。
3. 强壮的应用会对发送数据做应用层的buffer,像Connection Manager/Openfire之间是使用发送 Thread Pool,在对方不可到达的时段内,内存占用会急剧上升。
4. 如果网络层恢复正常,首先是socket buffer中的数据会被发送,然后应用层堆积的数据也随后发送。
5. 如果网络层长时间不可用,有2种方法可以判断,通过达到SO_SNDTIMEO Socket返回错误检测,应用层如果需要更早知道错误,可以调低SO_SNDTIMEO。另外一种检测方法是应用检测发送队列达到上限临界值来做进一步 处理。避免服务器内存溢出造成崩溃。实际环境中需要结合这2种方式一起考虑。

对于第五点,我提供的经验是,对LinkedBlockingQueue size做一个定时健康监控。如果前端queue内容发生堆积,说明系统中有锁定的情况;如果后端queue堆积,说明网络状况不好。

总结一下,设计和开发广泛适用于分布式的通讯模块,需处理

1,收不到,

2,收到处理不了,

3,和发不出去

这三种情况下的健壮性。

猜你喜欢

转载自maoyidao.iteye.com/blog/1236904