0%

SocketIO深度改造之旅-性能基础篇-2

2、性能篇#

本篇主要关注通过压测和参数配置、技术选型,基于理论和原理提升Netty-SocketIO性能,尽量让SocketIO服务器性能符合理论上的曲线趋势,内容中会涉及较多基础原理(节选出处见参考)。

2.1 环境设定#

系统环境:CentOS7.0,已经对fd、网络等关键内核参数进行了优化

硬件环境:2C4G

因整体环境区别,以下数据仅供趋势上的参考分析对照。

Netty-socketIO部分配置参数

1
2
3
4
5
6
7
8
9
10
bossThreads=2
workerThreads=4
pingInterval=10000
pingTimeout=20000
acceptbacklog=65535
tcpNoDelay = true
tcpKeepAlive = true
preferDirectBuffer=false
httpCompression = true
websocketCompression = true

压测工具:JMeter

G1默认配置

1
-XX:SoftRefLRUPolicyMSPerMB=0 -Xmx2048m -XX:-OmitStackTraceInFastThrow -Xss256k -Xms2048m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:MaxGCPauseMillis=200

初步SocketIO网关压测结果

测试场景 websocket连接数 发送频率 qps 网关cpu 网关内存/堆内存 网络流量(up/down) 备注
订阅推送服务 8000 1/5s 4000 11% 2.71G/1850m 4.45mb/4.21mb 服务器每个jmeter只能承受4000
广播 5000 1/5s 2500 4% 2.88G/1992m 2.5mb/1.34mb 服务器每个jmeter只能承受2500
广播 5000 1/2s 5200 7% 2.67G/1874m 5.81b/2.61mb 服务器每个jmeter只能承受2500

该数据由@梁兵玉 大佬提供,号称压测一时爽,一直压测一直爽。

2.2 目标与分析#

程序优化目标:功能扩展、QPS提升、GC友好,避免过早优化

优化阶段分析:

GC在G1下的GC阶段分别为YGC、MGC、FGC。

压测下的内存曲线表象:

表象:老年代处于慢速增长,年轻代内存空间不断压缩,GC频率越来越多,CPU也在提高,到达某个临界点进行一次FGC,如果无法回收成功一定空间,在低于理论极限QPS下,则判断可能存在内存溢出,因无法再申请到新的内存,系统警告出现内存溢出。需要Dump文件进行分析。

理论:JVM在G1出现FGC的可能情况

1.并发收集有余,应用程序和GC线程交替工作,不能完全避免繁忙的场合会出现回收过程中出现内存不足的情况,这时G1会转入一个FullGC进行回收。

2.如果混合GC出现空间不足,或者新生代GC时,survivor区和老年代无法容纳幸存对象,会进行一次FullGC

堆转储

1
2
3
4
jcmd process_id GC.heap_dump/path/to/heap_dump.hprof

#在jmap中包含live选项,这会在堆被转储之前强制执行一次FullGC;jcmd默认就会这么做
jmap dump:live,file=/path/to/heap_dump.hprof process_id

mat分析(暂略)

目标分析:降低和减少YGC频率和过长问题

从根节点开始扫描年轻代对象,直到扫描到下个引用为非年轻代对象。(可以避免YGC扫描整个堆。)

扫描老年代dirty区域,即可扫描到被老年代对象引用的年轻代对象。(老年代被分为不同的块,Card Table字节数组中每个字节表示老年代中的一块。新分配对象时,触发写屏障,存在有老年代对象引用年轻代对象时,将对应的卡表设置为dirty。)

将Eden和From区中的对象复制到To区。如果To区已满,则直接复制到老年代。

img

(图2-1)GC过程图

YGC耗时过长问题的排查应该从两个点出发:

  • YGC时存活的年轻代对象太多。
  • 老年代对象引用年轻代对象的情况太多。

img

(图2-2)GC事件耗时折线图

GC机制:

当老年代存活对象多时,每次minor gc查询老年代所有对象影响gc效率(因为gc stop-the-world),所以在老年代有一个write barrier(写屏障)来管理的card table(卡表),card table存放了所有老年代对象对新生代对象的引用。

策略:

  • 加速朝生夕灭对象快速回收
  • 减少过多老年代引用的对象

压测场景:

  • 私聊消息
  • 广播消息

压测观察:

CPU使用类别:

  • System:内核态CPU占用,覆盖io大部分场景,GC场景
  • Process:用户态CPU占用,覆盖JVM内部计算,GC场景

img

img

每分钟2.4次YGC,耗时均处于比较健康状态,在GC下,System和ProcessCPU会呈现几乎完全相同趋势。

老年代理论:

当分配的对象大于等于Region大小的一半的时候就会被认为是巨型对象。H对象默认分配在老年代,可以防止GC的时候大对象的内存拷贝,2G堆大小,单个Region=4M,默认2M大对象直接进入老年代。

对象引用:4Byte+8Byte

程序中完成了一个Java对象的声明,但是它所占的空间为:4byte+8byte。4byte是上面部分所说的Java栈中保存引用的所需要的空间。而那8byte则是Java堆中对象的信息。

大对象关注点:过大的字符串和数组

管理好SocketIO中的老年代对象:

1
2
3
4
5
6
7
8
连接管理
ClientsBox
uuid2client<>
channel2client<>
房间管理
namespaces<>
clientrooms<>
roomclients<>

UUID问题分析

socketio默认使用UUID作为sessionId,UUID默认构造方法默认使用2个int保存,且在会话层使用UUID作为key使用,不建议强制转化成36Byte长度 String作为程序使用。

大页支持:

1
#grep Hugepagesize /proc/meminfo Hugepagesize:2048kB

计算需要多少大页。如果JVM会分配4GB大小的堆,系统支持2MB的大页,则这个堆就需要2048个大页。系统可以使用的大页的数目是在Linux内核中全局定义的,因此要对将在该系统中运行的所有JVM(以及其他任何会使用大页的程序)重复这个过程。考虑到非堆部分也有可能会使用大页,所以应多估算10%(这样,这个例子要使用2200个大页)。

会话设置:

1
echo 2200 > /proc/sys/vm/nr_hugepages

配置绑定:

1
/etc/sysctl.conf -> sys.nr_hugepages = 2200

不要遗漏崩溃时现场保护

\1. JVM Segment Fault -> coredump

当然,更好的做法是生成coredump,从CoreDump能够转出Heap Dump 和 Thread Dump 还有crash的地方,非常实用。

在启动脚本里加上 ulimit -c unlimited或其他的设置方式,如果有root权限,设一下输出目录更好

1
echo "/{MYLOGDIR}/coredump.%p" > /proc/sys/kernel/core_pattern

\2. -XX:+HeapDumpOnOutOfMemoryError(可选)

在Out Of Memory,JVM快死掉的时候,输出Heap Dump到指定文件。不然开发很多时候还真不知道怎么重现错误。

路径只指向目录,JVM会保持文件名的唯一性,叫java_pid${pid}.hprof。因为如果指向文件,而文件已存在,反而不能写入。

1
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOGDIR}/

但在容器环境下,输出4G的HeapDump,在普通硬盘上会造成20秒以上的硬盘IO跑满,也是个十足的恶邻,影响了同一宿主机上所有其他的容器。

Jackson、Protobuf序列化监控

img

该功能由@郭浩 大佬提供

protobuf集成,带宽、内存影响

使用同样一个信令:{“”, {} }

对该信令执行1w次,生成的数据包大小对比

序列化设置:不忽略empty和null

携带相同信息量的对象,转化成Byte数组大小1w次:

json:740000

protobuf:200000

平均单个对象大小:

1
2
JavaBean ProtoMessage:64Byte
JavaBean Message:48Byte

img

序列化耗时:jackson VS protobuf

1
2
3
Benchmark                                Mode  Cnt  Score   Error  Units
SerializableBenchmark.jsonSerializable avgt 10 2.851 ± 0.031 ms/op
SerializableBenchmark.protoSerializable avgt 10 0.266 ± 0.008 ms/op

JSON

img

PROTO

img

反序列化耗时:

1
2
3
Benchmark                                  Mode  Cnt  Score   Error  Units
DeSerializableBenchmark.jsonSerializable avgt 10 4.656 ± 0.058 ms/op
DeSerializableBenchmark.protoSerializable avgt 10 0.402 ± 0.012 ms/op

JSON

img

proto

img

二进制协议消息选型

socketio对于二进制协议的发送和接收通常包含2部分,这是一个差异点,对于使用JMeter测试时需要对应到2个信令的发送。

二进制协议发送码为451,接收码为461

img

img

2.3 压测网络设置#

端口数和端口范围修改

窗口大小和吞吐量:按照RTT0.1s计算,1个连接的最大吞吐量为5.2Mbps,在一般网络压测中,使用多个TCP连接提高网络吞吐量,web浏览器中通常使用4个TCP连接。

基于SocketIO协议的应用层自定义二进制协议

SocketIO在自己的协议层已经封装了PingPong协议、Ack的头ID描述等功能,该二进制协议的概念仅作为应用层参考

Header

魔数(magic number):标识一个认可的信令或者版本

命令(command):标识一个消息的命令或者消息ID

dataLength:Body长度

Body

对象序列化后的二进制

如果想要设计一种可扩展性强的二进制协议,墙裂推荐参考TCP/IP中的IP数据包协议

img

2.4 微基准测试之SecretRandom#

RandomSecret类是Java程序员使用随机、分布式ID(socketIO-sessionId)场景中的常客,经常有人使用JVM参数 -Djava.security=file:/dev/./urandom

/dev/random 与 /dev/urandom

Linux的两个随机数源, 从IO中断,网卡传输包这些外部入侵者不可预测的随机源中获取熵,混合后使用CSPRNG生成熵池。

当熵池估值为0时,/dev/random 会block住请求,而/dev/urandom 则会继续输出随机数。

JDK8的SecureRandom

首先,Native算法多了两种子类型。NativeBlocking的generateSeed与nextBytes都从/dev/random中读,NativeNonBlocking则两者都从/dev/urandom中读。

不过Native里nextBytes并不需要调用generateSeed,所以对于主要用SecureRandom来生成随机数的应用来说,这个区别不大。

其次,SHA1PRNG用到的SeedGenerator,终于改好了,原来NativeSeedGenerator无论设什么都是读/dev/random,现在改为设什么就读什么,所以jre/lib/security/java.security 里也改了 securerandom.source=file:/dev/random

所以,JDK8里,如果你显式获得的SHA1PRNG以后启动不想有阻塞的可能性,还是要设成-Djava.security=file:/dev/urandom,只是不用猥琐的加个. 在中间了。不过依然加上也无不可。

结论:

SHA1PRNG 比 NativePRNG消耗小一半,synchronized的代码少一半,不与系统/dev/urandom交互所以偶发高延时也更少一些,所以没特殊安全要求的话建议用SHA1,比如生成sessionId,traceId的场景

如果想用SHA1, 设成-Djava.security=file:/dev/./urandom总是对的

如果想用Native,什么都不设置就好了。

如果自己能控制,在应用或框架启动时,先调用一下相应SecureRandom算法实例的nextInt()函数,总能减少一点首次服务调用所花的时间。

该结论摘自@肖桦(江南白衣)的博客

同样可以使用JMH微基准测试进行对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
App.randomWithNative 379862 QPS
App.randomWithNative:randomWithNative·p0.00 ≈ 10⁻⁴ ms/op
App.randomWithNative:randomWithNative·p0.50 0.001 ms/op
App.randomWithNative:randomWithNative·p0.90 0.007 ms/op
App.randomWithNative:randomWithNative·p0.95 0.007 ms/op
App.randomWithNative:randomWithNative·p0.99 0.008 ms/op
App.randomWithNative:randomWithNative·p0.999 sample 56.254 ms/op
App.randomWithNative:randomWithNative·p0.9999 sample 186.945 ms/op
App.randomWithNative:randomWithNative·p1.00 sample 1235.223 ms/op

App.randomWithSHA1 668574 QPS
App.randomWithSHA1:randomWithSHA1·p0.00 ≈ 10⁻⁴ ms/op
App.randomWithSHA1:randomWithSHA1·p0.50 0.002 ms/op
App.randomWithSHA1:randomWithSHA1·p0.90 0.006 ms/op
App.randomWithSHA1:randomWithSHA1·p0.95 0.321 ms/op
App.randomWithSHA1:randomWithSHA1·p0.99 1.701 ms/op
App.randomWithSHA1:randomWithSHA1·p0.999 2.245 ms/op
App.randomWithSHA1:randomWithSHA1·p0.9999 12.325 ms/op
App.randomWithSHA1:randomWithSHA1·p1.00 138.936 ms/op

WebSocket知识点

img

9~15Bit介绍:

payload length 负载长度,单位字节如果负载长度0125字节,则此处就是负载长度的字节数,如果负载长度在12665535之间,则此处的值为126,1632Bit表示负载的真实长度。如果负载长度在655362的64次方-1时,16~80Bit表示负载的真实长度。其中负载长度包括应用数据长度和扩展数据的长度

payload length 后面4个字节可能是掩码的key(如果掩码位是1则有这4个字节的key,否则没有),掩码计算方法将在后面给出。

接下来就是负载的数据了,他们可能需要根据掩码的key进行编码(仅浏览器需要掩码)