MQTT 长连接网关优化实战

稳定 70 万长连接的优化记录,妥妥的实战。

前言

本次压测是为了测试单个节点下,直连 MQTT 接入服务的连接承载力。

模拟方式

按正常线上设备模拟业务交互,TLS 双向认证,消息量如下:

  • MQTT PUBLISH

    • 心跳

      20bytes,间隔 15 秒

    • 状态上报

      500bytes,间隔 1 分钟

  • MQTT PING

    2 分钟一次

业务心跳与状态上报为最主要的两类消息,心跳包 15 秒一次,与真实设备一致,这里的频率比例设置为了 8:2,主要考虑状态上报一般需要先下行控制报文,才被动触发。

这里由于下行触发的代码改造工作量较大,仅模拟“设备上报状态、服务端给予响应”的业务交互过程。

压测过程

首先,我们仅开启 MQTT PING。

大量 TCP 断连

建连时发现连接数上不去,大量断连,观察客户端日志发现大量未 ack 的 PINGREQ(服务端使用 Netty IdleStateHandler 处理读超时),应该是服务端资源不足以处理心跳的 PINGRESP 导致的,于是适当调大 Netty I/O 线程个数(8 改 64)。

SSL 握手超时

更新后重试,发现客户端大量 SSL 握手超时:

image-20200410231921298

应该只是表象,查看服务端日志,线程耗尽:

image-20200410232002154

我们查找使用“Message-Event-Worker”所属线程池的代码。

第一处的逻辑为,收到 PubAck 报文,取消 QoS 1 重发任务:

image-20200410232432025

image-20200410232715552

第二处 MQTT CONNECT 成功后写路由表:

image-20200410232441995

image-20200410232836544

第三处,连接断开删除路由表:

image-20200410232924898

image-20200410232958054

第二处有打日志,排除;client 暂未实现 PUBLISH 报文,第一处排除,故报错为第三处,即大量连接断开事件打满线程池队列。

查看服务端和客户端的 SSL 握手超时,均为 20 秒:

image-20200410231446370

那么原因应该是建立大量连接,SSL 握手超时,导致大量 TCP 断连,触发大量 channelInactive 逻辑所导致。我们尝试调大 Message-Event-Worker 线程池的队列大小(8 改 10240)后问题暂时解决。

Client 内存配置问题

压测过程中还发现模拟设备的 Client 端出现进程被 Kill:

image-20200411144625301

使用 dmesg -T| grep -E -i -B100 'killed process' 查看发现是 cgroups 限制 2G,而 Client 启动参数也是 2G,系统本身还要一定内存,所以被 Killed 了,调大 MQTT-Client 内存限制并设置 -Xmx 小于容器限制后重试。

Memory cgroup out of memory: Kill process

线程池耗尽

重试发现 Message-Event-Worker 线程池依然报错:

image-20200411145634193

可以看到,队列依然溢出了,走到了拒绝策略中,我们调整核心线程数(4 改 256)。

HTTP 连接不足

再次重试,这次直接报鉴权服务连接数问题:

image-20200411194520546

观察鉴权服务,load 很低,基本无压力:

image-20200411194725190

持续一段时间后报错消失:

image-20200411194703417

观察 MQTT-Server CPU load,发现在 19:34 附近 CPU load 飙升,之后恢复正常:

image-20200411194846675

之后也偶现同样的报错日志:

image-20200411195045972

调用的鉴权服务接口是内网 HTTP 接口,使用 AsyncHttpClient,其底层使用 Netty 传输,我们将其连接池调大(per host 100 改 1000)。

Client 可用端口不足

image-20200411200208341

Server 端由于大量客户端断开导致 Message-Event-Worker 线程池耗尽:

image-20200411201506107

果然还是要关心一下断连的逻辑。

查看端口限制:

image-20200411205806261

观察连接数,果然客户端到 28000 个连接左右就上不去了:

image-20200411210008699

image-20200411210017897

image-20200411210027192

这里需要注意,重启会有很多 TIME_WAIT 端口不能用,需要等待一分钟(默认),虽然可以适当调小,但这里先不调整:

image-20200412184434935

6 万连接,解决端口问题

我们先解决端口问题,调小客户端容器的连接数,之后稳定连上 6w 客户端,使用 top -H 查看 CPU load 占比较高的线程,通过 jstack 锁定 Log4j 使用的 Disruptor 缓冲线程的 CPU 占用较高:

image-20200413175406581

image-20200413175410656

image-20200413180218677

image-20200413180222966

image-20200413180226388

继续调大线程池队列(10240 改 102400)大小暂时解决一下报错。

15 万连接,内存瓶颈

我们往上继续加连接到 20 万,发现之后是 SSL 加解密和 GC 线程占用较高(CMS):

image-20200413182057151

image-20200413182101954

image-20200413182105877

到 15 万连接时出现连接超时断开,其中 client 端 TCP 连接一直上不了 2 万(会有断连):

image-20200413182750639

server 端报错如下:

image-20200413182809149

集中在 18:19 附近集中出现报错,之后恢复正常,连接数最终达到 20 万附近,但并不能稳定在 20 万整,不断有超时重连:

image-20200413184013146

观察日志发现通过鉴权的有效连接实际上不到 20 万,只有 15 万左右:

image-20200414021825227

其他连接还在不停的超时然后重试。

CPU load 过高的几个线程情况:

image-20200413184003646

image-20200413191450664

观察 GC 情况,Full GC 次数明显增多:

image-20200413192958324

可以看到,GC 压力是比较大的。

由于频繁 STW,导致 Redis Client 等也开始超时报错(Redisson 使用 Netty 作为网络层,I/O 超时):

image-20200413184008844

image-20200413191504062

CPU load 图:

image-20200413195612033

我们停止 Client 后,Server 端大量断连进入 close 逻辑,原本维系的 FullGC 终于扛不住了,触发了 OOM:

image-20200414002735029

我们分析内存 dump,发现都是正常的建连所需对象:

image-20200414002157920

如图所示,每个 SSL 连接都对应一个 OpenSslEngine 对象,缓存 SSL 通道的证书等信息:

image-20200414002356191

且每个连接都会绑定一个 EpollEventLoop:

image-20200414002434194

显然内存本身不够用了,现阶段瓶颈可以通过增大内存分配来解决。

由于只是 PING 报文,Netty 堆外内存使用无变化,且已池化,先忽略:

image-20200414023635135

我们作以下调整:

  1. 调整 JVM 参数,最大堆由 2G 改成 5.4G(GC 暂且继续使用 CMS)

  2. 减少 Server 端日志打印以降低 Log4J 线程的 CPU 占用

  3. 加上断连事件的耗时统计(即 Server 端的 Message-Event-Worker 线程池在处理 channelInactive 时的任务执行时间)

  4. 添加对 Netty 线程组的队列任务数统计

image-20200430192216836

20 万连接,大量断连带来的问题

然后重试 20 万连接,发现在 20 万连接的水平上,一旦有 Client 批量断连,触发 CPU 到达高水位,那么其它 Client 也可能由于超时未收到响应而断连,断连后 Client 会尝试重连,不断的重试会最终打满 CPU 造成雪崩,Redis、Zookeeper、HTTP 等 client 大量超时,之后出现 Full GC,系统根本自愈不了:

image-20200417162344297

且由于不断新建连接,会耗费 OpenSSL 等库的堆外内存使用,如果内存分配不合理,为堆外内存预留的资源过少,可能出现进程被 OOM-Killer 干掉的情况:

image-20200416173959392

我们的经验是大概留接近 -Xmx 大小的堆外内存,比如 8G 的限制,则一个合适的堆不要超过 5G。

基于上述,我们需要对大量断连和建连的情况分别作限流考虑,实施过载保护,这里我们停下来认真思考了服务端的代码优化。

代码优化

优化 Channel 注册表数据结构

服务端会保存设备 ID 与 Channel 的映射关系,之前的做法是将设备 ID 作多层散列,后来发现这里其实最关键的是减少竞争,故将原有的单个 Map 分解为多个对象,通过预先 hash 散列拿到数组下标值从而拿到 Map,再通过 JDK 的算法拿到 Channel 对象,避免多个线程对同一个 ConcurrentHashMap 的并发访问,减小粒度。

原结构:

image-20200430193452498

新结构:

image-20200430193504430

之前的代码逻辑是同一个设备 ID 可以保持多个连接,这里去掉了这部分逻辑,改成新连接会迫使旧连接断连:

image-20200430194026311

优化断连时对路由表的操作

删除路由表操作接近 100ms 耗时,在大量断连时效果不佳,Redisson 底层的 Netty 线程池不够。我们使用 Pipeline 优化 Redis 的网络访问,优化大量断连时的性能,减少 CPU 占用。

原有逻辑,每次断连事件都进行一次网络 I/O:

image-20200414181031111

现有逻辑,使用队列进行削峰:

image-20200430194333061

image-20200430194348215

利用 Redis Pipeline 优化性能:

image-20200430194543189

建连过载保护

我们的逻辑是,触发了过载事件,则 spread 到关心它的 Listener,从而触发相应的逻辑,比如关闭端口监听以防止新的建连等。

image-20200430195832313

image-20200430195942266

40 万连接,加配置

将服务器配置改成 16C 64 G,40 万连接情况下,6G 堆,堆外大概占用 5G,出现 Full GC 2 次(图中 CPU 过高的时间段),之后稳定:

image-20200421170941587

image-20200421171017381

image-20200421171047518

image-20200421171231537

20G 大堆,开启消息上行

之前 Client 没有开启 PUBLISH 报文,仅 MQTT 的 PING 来保活,现改为 20G 大堆,开启上行 PUBLISH 报文。

建连后发现 CPU 占比高的线程为 Kafka-Producer 的 I/O 线程,查看源码发现 KafkaProducer 线程安全,默认使用一个 I/O 线程进行发送,先将消息放入本地 buffer,再批量发送至 broker,并且检查状态回调 Callback:

image-20200426102719885

优化 KafkaProducer

修改 Kafka 配置,开启批量发送(linger),关闭 retry,改 acks 为 1 保证 Leader 持久化成功即可,加快回调速度:

image-20200426151723944

效果不错:

image-20200426151806633

70 万连接,优化对象创建

我们加到 70 万连接,客户端未发现断连,服务端无报错:

image-20200426151229403

CPU 维持在 68% 上下:

image-20200426151157830

内存使用如下:

image-20200426151858766

网卡入站速率:

image-20200426151917032

Kafka 生产速率:

image-20200426160258804

我们等待一段时间,发现很平稳,无断连。

考虑到下行消息并未开启,若开启可能使 CPU 占用升高很多(甚至翻倍),所以不能乐观。

现在主要的方向为“如何尽可能减少 CPU 占用,在不撑满 CPU 的情况下支撑更多连接”。

70 万连接平稳后,查看 jstack:

image-20200426160517708

发现 G1 GC 线程和 Kafka Producer 是占用偏高的几个线程。

尝试优化如下:

  1. 减少对象创建,比如固定对象使用同一个引用、取消使用构造器模式,改为直接 new:

image-20200426161256024

image-20200426161703853

  1. 增大 G1 停顿时间期望值:

image-20200426174945455

  1. Kafka 的 linger.ms 调为 10ms

先不增加下行消息,仅作以上优化,效果如下:

image-20200427171630659

没啥变化,还是 GC 忙:

image-20200427172147433

image-20200427172439239

Kafka I/O 线程繁忙程度:

image-20200427172813464

查看 GC 日志,发现 GC 频率大概间隔 5 秒一次 GC,耗时都在 1 秒内:

image-20200428141736009

GC 调优

我们明确方向为以应用程序吞吐量的降低换取高在线数,尝试:

  1. 将 G1 的年轻代调整为 20% 占比(-XX:G1NewSizePercent=20)
  2. 将 STW 期间的并行线程数从 13 调大至 20(-XX:ParallelGCThreads=20)

其他条件不变,同样加到 70 万连接,发现 CPU 下降了近 10%:

image-20200430202616159

查看 GC 日志:

image-20200428145802148

可以看到,频率下降了,STW 时间变化不大,达到了我们的目的。

我们继续用大 young 换 CPU 使用率,增大停顿时的线程数,并参考 官方调优文档 减少并发的 work 数量,增加 RSet 更新暂停时间占比,增加停顿时间期望:

  • -XX:ParallelGCThreads=32
  • -XX:G1NewSizePercent=30
  • -XX:MaxGCPauseMillis=500
  • -XX:G1RSetUpdatingPauseTimePercent=30
  • -XX:+AlwaysPreTouch

或者更激进地完全将 RSet 的更新放进停顿中去:-XX:-G1UseAdaptiveConcRefinement -XX:G1ConcRefinementGreenZone=2G -XX:G1ConcRefinementThreads=0,需要谨慎开启,因为这可能会导致长时间的停顿。

检查代码还发现,上行的 PUBLISH 报文和 CONNECT 报文的处理是交给业务线程池处理的,但 CONNECT 报文需要异步请求鉴权服务,其余均为内存操作,PUBLISH 需要请求 Kafka,但也是异步的,所以将这里的异步改为同步使用 Netty I/O 线程池去处理,减少一次线程切换。

image-20200430202954698

image-20200430203008279

image-20200430203021059

下行时也去掉业务线程池:

image-20200430203123589

再次尝试:

image-20200428190431697

又低了 10%,但是毛刺严重,不过由于我们不注重低延迟,STW 只要持续时长够短,CPU 高一点无所谓。

image-20200428191147594

image-20200428193347295

增加消息下行

检查代码,发现可以将下行 QoS 任务的设置异步化,并且打散任务注册,减少并发度:

image-20200429154456555

用同样的思路减小 QoS 任务注册表的粒度:

image-20200429154522413

43 万连接时出现 QoS1 重试:

image-20200429181749941

由于 ack 确认后,“取消重试任务”的线程是异步的,所以存在取消重试不及时的问题。

60 万连接时出现 Mixed GC:

image-20200429181657103

70 万连接后,CPU 负载如下:

image-20200429184400164

内存使用:

image-20200429183113356

GC 频率:

image-20200429185047909

停顿时长可以接受,时间与 CPU 毛刺基本对应。

Kafka 的生产速率大约为 6 万/秒:

image-20200429182923988

客户端无断连:

image-20200429183256811

QoS 重试比较频繁,重试任务的逻辑是在写成功 outbound buffer 后 3 秒内还未收到 PUBACK(以 Netty 的 channelRead 成功,切换到 BizThreadPool 后取消 QoS 任务为准),则进行重试。

image-20200429183405955

Full GC 导致大量断连

过了一晚上,查看曲线图发现了一次大量断连重连:

image-20200430110431946

查看日志发现有 FullGC

image-20200430105201471

之前伴随一次 MixedGC(to-space exhausted):

image-20200430105156931

可以看到,Full GC 之前有一次 Mixed GC,这代表 G1 收集器完成标记开启 Mixed GC 并准备清理老年代之前,内存就已经被耗尽了。我们需要让 Mixed 更迅速的收集。

Evacuation Failure 消耗了 860ms,可见转移失败的代价比多执行一些并发标记周期高很多。

一般来说,to-space exhausted 之后 FullGC 就不远了。

查阅相关资料,总结了以下调优方式:

  1. 通过 -XX:InitiatingHeapOccupancyPercent 这个参数让 G1 GC 更早得启动 Mixed GC,默认占用整个堆的 45% 时才会开启 Mixed GC。不过,这个参数也不能调得太小,否则会导致过多的并发收集及 Mixed 收集,增加停顿时长。
  2. 通过调整 -XX:G1MixedGCCountTarget 增加每次 Mixed GC 中老年代的处理数量。
  3. 调整 -XX:ConcGCThreads 增加用于并发收集的 GC 线程个数(增加了 CPU 消耗),需要权衡。
  4. 有时候转移失败是由于 Survivors 中没有足够的空间容纳新晋升的对象,考虑增加 -XX:G1ReservePercent

我们的调整如下:

  • -XX:ParallelGCThreads=16
  • -XX:InitiatingHeapOccupancyPercent=30
  • -XX:G1MixedGCCountTarget=16
  • -XX:ConcGCThreads=6
  • -XX:G1ReservePercent=15

由于之前没加 -XX:+HeapDumpAfterFullGC 导致没有 dump 可分析,我们这次加上。

其它

增加 Kafka 消费速率

由于 PUBLISH 消息最终都会到 Kafka 中去,而如果消费者消费不过来,会出现业务交互上的超时,我们可以考虑增加并发度(consumer 线程数)和设置批量消费(还可以设置批量消费大小)。

image-20200426181120430

实测增加并发度并不会大幅改变消费速率,但设置批量消费会大大加快。在消费逻辑为空的情况下,并发度为 10,关闭自动提交 offset,不设置批量消费时消费速率为 2k/s 左右:

image-20200427171950177

开启批量消费,设置一次 pull 100 条消息,默认最大一次拉 50M 消息(如果单个消息体积过大可以尝试调整)消费速率可以达到和生产速率相同的 6w/s:

image-20200427171859171

防止 Kafka 堆积造成磁盘不足

记得开启定期清理的配置(线上默认保留 1 天),否则在生产过快的情况下可能堆满磁盘:

image-20200430205441684

一旦 Kafka 堆满,会导致 Client 出现以下问题(压测过程中出现过):

image-20200427101124142

image-20200427101715945

增加 RocketMQ 生产速率

业务服务处理完消息,会下行生产 RocketMQ 消息,可以利用上 Kafka 的批量消费,进行批量生产:

image-20200430205638151

上图最后对 result 的判断是没必要的,RocketMQ 客户端只要不报错,就可以认为发送成功,不需要报错,特此说明一下。

增加 RocketMQ 的消费速率

RocketMQ 也开启批量消费即可。

总结

70 万连接,各指标如下:

image-20200430205834477

image-20200430205908301

image-20200430205926404

image-20200430205944131

Kafka:

image-20200430210032154

image-20200430210039843

RocketMQ:

image-20200430210110125

集群消费速率暂未优化,这里就不给出截图了,实际下行消息应当比当前的水平更高,可以后续优化。基本思路还是充分利用 CPU 进行 STW 期间的回收,减少 STW 时间,以一定的吞吐量降低的代价(只要不掉线)换取高在线数。