仅以此文献给李林锋新生的爱女。
Netty 4.1提供了MQTT协议栈,基于此可以非常方便地创建MQTT服务,尽管开发简单,但是在实际环境中会面临各种挑战,甚至会面临一些不遵循MQTT规范的端侧设备接入。
如果服务端没有考虑到各种异常场景,很难稳定运行,本文以生产环境MQTT服务无法提供接入服务为例,详细介绍MQTT服务和Netty在异常场景下的保护机制。
- MQTT服务接入超时问题
1. 生产环境问题现象
生产环境的MQTT服务运行一段时间之后,发现新的端侧设备无法接入,连接超时。分析MQTT服务端日志,没有明显的异常,但是内存占用较高,查看连接数,发现有数十万个TCP连接处于ESTABLISHED状态,实际的MQTT连接数应该在1万个左右,显然这么多连接肯定存在问题。
由于MQTT服务端的内存是按照2万个左右连接数规模配置的,因此当连接数达到数十万个的规模之后,导致了服务端大量SocketChannel积压、内存暴涨、高频率GC和较长的STW时间,对端侧设备的接入造成了很大影响,部分设备MQTT握手超时,无法接入。
2. 连接数膨胀原因分析
通过抓包分析发现,一些端侧设备并没有按照MQTT协议规范进行处理,包括:
(1)客户端发起CONNECT连接,SSL握手成功之后没有按照协议规范继续处理,例如发送PING命令。
(2)客户端发起TCP连接,不做SSL握手,也不做后续处理,导致TCP连接被挂起。
由于服务端是严格按照MQTT规范实现的,上述端侧设备不按规范接入,实际上消息调度不到MQTT应用协议层。MQTT服务端依赖Keep Alive机制进行超时检测,当一段时间接收不到客户端的心跳和业务消息时,就会触发心跳超时,关闭连接。针对上述两种接入场景,由于MQTT的连接流程没有完成,MQTT协议栈不认为这个是合法的MQTT连接,因此心跳保护机制无法对上述TCP连接进行检测。客户端和服务端都没有主动关闭这个连接,导致TCP连接一直保持。
MQTT连接建立过程如下图。
MQTT连接建立过程
3. 无效连接的关闭策略
针对这种不遵循MQTT规范的端侧设备,除了要求对方按照规范修改,服务端还需要做可靠性保护,具体策略如下。
1)端侧设备的TCP连接接入后,启动一个链路检测定时器加入Channel对应的NioEventLoop。
2)链路检测定时器一旦触发,就主动关闭TCP连接。
3)TCP连接完成MQTT协议层的CONNECT之后,删除之前创建的链路检测定时器。
MQTT无效连接检测机制如下图。
MQTT无效连接检测机制
4. 问题总结
生产环境升级版本之后,平稳运行,查看MQTT连接数,稳定在1万个左右,与预期一致,问题得到解决。
对于MQTT服务端,除了要遵循协议规范,还需要对那些不遵循规范的客户端接入进行保护,不能因为一些客户端没按照规范实现,导致服务端无法正常工作。系统的可靠性设计更多的是在异常场景下保护系统的稳定运行。
- 基于Netty的可靠性设计
从应用场景看,Netty是基础的通信框架,一旦出现问题,轻则需要重启应用,重则可能导致整个业务中断。它的可靠性会影响整个业务集群的数据通信和交换,在以分布式为主的软件架构体系中,通信中断就意味着整个业务中断,分布式架构对通信的可靠性要求非常高。
从运行环境看,Netty会面临恶劣的网络环境,这就要求它自身的可靠性要足够好,平台能够解决的可靠性问题需要由Netty自身来解决,否则会导致上层用户关注过多的底层故障,降低Netty的易用性,同时增加用户的开发和运维成本。
Netty的可靠性如此重要,它的任何故障都可能导致业务中断,产生巨大的经济损失。因此,Netty在版本的迭代中不断加入新的可靠性特性来满足用户日益增长的高可靠和健壮性需求。
1. 业务定制I/O异常
在大多数场景下,当底层网络发生故障的时候,应该由底层的NIO框架负责释放资源,处理异常等,上层的业务应用不需要关心底层的处理细节。但是,在一些特殊的场景下,用户可能需要关心这些异常,并针对这些异常进行定制处理,例如:
(1)客户端的断连和重连机制。
(2)消息的缓存重发。
(3)在接口日志中详细记录故障细节。
(4)运维相关功能,例如告警、触发邮件/短信等。
Netty的处理策略是,发生I/O异常时,底层的资源由它负责释放,同时将异常堆栈信息以事件的形式通知给上层用户,由用户对异常进行定制。这种处理机制既保证了异常处理的安全性,也向上层提供了灵活的定制能力。Netty异常通知接口定义如下图。
Netty异常通知接口定义
2. 链路的有效性检测
当网络发生单通、连接被防火墙挡住、长时间GC或者通信线程发生非预期异常时,链路会不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰到来时,由于链路不可用导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。
从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。
心跳检测机制分为三个层面。
(1)TCP层面的心跳检测,即TCP的Keep-Alive机制,它的作用域是整个TCP协议栈。
(2)协议层的心跳检测,主要存在于长连接协议中,例如MQTT。
(3)应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。
作为高可靠的NIO框架,Netty也提供了心跳检测机制,利用IdleStateHandler可以方便地实现业务层的心跳检测。
3. 内存保护
NIO通信的内存保护主要集中在如下几点。
1)链路总数的控制:每条链路都包含接收和发送缓冲区,链路个数太多容易导致内存溢出。
2)单个缓冲区的上限控制:防止非法长度或者消息过大导致内存溢出。
3)缓冲区内存释放:防止因为缓冲区使用不当导致的内存泄漏。
4)NIO消息发送队列的长度上限控制。
防止内存池泄漏
为了提升内存的利用率,Netty提供了内存池和对象池。但是,基于缓存池实现以后需要对内存的申请和释放进行严格的管理,否则很容易导致内存泄漏。
如果不采用内存池技术实现,每次对象都以方法的局部变量形式被创建,用完之后,只要不再继续引用它,JVM会自动释放。但是,一旦引入内存池机制,对象的生命周期将由内存池负责管理,通常是全局引用(含线程级缓存),如果不显式释放,JVM是不会回收这部分内存的。对于从内存池申请的对象,使用完毕一定要及时释放,防止内存泄漏。
缓冲区溢出保护
当我们对消息进行解码的时候,需要创建缓冲区(Netty的ByteBuf)。缓冲区的创建方式通常有两种。
1)容量预分配,在实际读写过程中如果不够再扩展。
2)根据协议消息长度创建缓冲区。
在实际的商用环境中,如果遇到畸形码流攻击、协议消息编码异常、消息丢包等问题,可能会解析到一个超长的长度字段。我曾经遇到过类似问题,报文长度字段值竟然超过2GB,由于代码的一个分支没有对长度上限进行有效保护,导致内存溢出。系统重启几秒后再次发生内存溢出,幸好及时定位到问题的根本原因,没有造成严重的事故。
Netty提供了编解码框架,因此对于解码缓冲区的上限保护就显得非常重要,在实际项目中主要通过如下两种方式对缓冲区进行保护。
1)创建ByteBuf时对它的容量上限进行保护性设置,如下。
ByteBuf容量上限保护
2)在消息解码的时候,对消息长度进行判断,如果超过最大容量,则抛出解码异常,拒绝分配内存,以LengthFieldBasedFrameDecoder的decode方法为例进行说明,代码如下:
3. 消息发送队列积压保护
Netty的NIO消息发送队列ChannelOutboundBuffer并没有容量上限,它会随着消息的积压自动扩展,直到达到0x7fffffff。
如果对方处理速度比较慢,会导致TCP滑窗长时间为0;如果消息发送方发送速度过快或者一次批量发送消息量过大,会导致ChannelOutboundBuffer的内存膨胀,可能会使系统的内存溢出。
建议业务配置合适的高水位(writeBufferWaterMark)对消息发送速度进行控制,同时在发送业务消息时,调用Channel的isWritable方法判断Channel是否可写,如果不可写则不要继续发送,否则会导致发送队列积压,出现OOM异常。
- 总 结
可靠性设计的关键在于对非预期异常场景的保护,应用层协议栈会考虑应用协议异常时通信双方应该怎么正确处理异常,但是对于那些不遵循协议规范实现的客户端,协议规范是无法强制约束对方的,特别是在物联网应用中,面对各种厂家的不同终端设备接入,服务端需要应对各种异常。只有可靠性做得足够好,MQTT服务才能更从容地应对海量设备的接入。
推荐阅读
《Netty进阶之路:跟着案例学Netty》
Netty将Java NIO接口封装,提供了全异步编程方式,是各大Java项目的网络应用开发必备神器。本书作者李林锋是国内Netty技术的先行者和布道者,本书是他继《Netty权威指南》之后的又一力作。
本书内容精选自1000多个一线业务实际案例,真正从原理到实践全景式讲解Netty项目实践,快速领悟Netty专家花大量时间积累的经验,助你提高编程水平及分析解决问题的能力。