一 背景

一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。

算子依赖了一些库:

Groovy

aviatorscript

该应用有如下特征:

1、处理数据量大

a. 每分钟处理几百万行日志,日志流速几十 MB/S;

b. 每行日志可能需要执行多个计算任务,计算任务个数不好估计,几个到几千都有;

c. 每个计算任务需要对一行日志进行切分/过滤,一般条件

2、有一定实时性要求,某些数据必须在特定时间内算完;

3、4C8G 规格(后来扩展为 8C16G ),内存比较紧张,随着业务扩展,需要缓存较多数据;

简言之,对性能要求很高。

有两种方案:

Go call Java

使用 Java 重写这个应用

出于时间紧张和代码复用的考虑选择了 "Go call Java"。

下文介绍了这个方案和一些优化经验。

二 Go call Java

根据 Java 进程与 Go 进程的关系可以再分为两种:

方案1:JVM inside: 使用 JNI 在当前进程创建出一个 JVM,Go 和 JVM 运行在同一个进程里,使用 CGO + JNI 通信。

方案2:JVM sidecar: 额外启动一个进程,使用进程间通信机制进行通信。

方案1,简单测试下性能,调用 noop 方法 180万 OPS, 其实也不是很快,不过相比方案2好很多。

这是目前CGO固有的调用代价。

由于是noop方法, 因此几乎不考虑传递参数的代价。

方案2,比较简单进程间通信方式是 UDS(Unix Domain Socket) based gRPC但实际测了一下性能不好, 调用 noop 方法极限5万的OPS,并且随着传输数据变复杂伴随大量临时对象加剧 GC 压力。

不选择方案2还有一些考虑:

高性能的性能通信方式可以选择共享内存,但共享内存也不能频繁申请和释放,而是要长期复用;

一旦要长期使用就意味着要在一块内存空间上实现一个多进程的 malloc&free 算法;

使用共享内存也无法避免需要将对象复制进出共享内存的开销;

上述性能是在我的Mac机器上测出的,但放到其他机器结果应该也差不多。

出于性能考虑选择了JVM inside方案。

1 JVM inside 原理

JVM inside = CGO + JNI. C 起到一个 Bridge 的作用。

2 CGO 简介

是 Go 内置的调用 C 的一种手段。详情见官方文档。

GO 调用 C 的另一个手段是通过 SWIG,它为多种高级语言调用C/C++提供了较为统一的接口,但就其在Go语言上的实现也是通过CGO,因此就 Go call C 而言使用 SWIG 不会获得更好的性能。详情见官网。

以下是一个简单的例子,Go 调用 C 的printf("hello %s\n", "world")。

运行结果输出:

在出入参不复杂的情况下,CGO 是很简单的,但要注意内存释放。

3 JNI 简介

JNI 可以用于 Java 与 C 之间的互相调用,在大量涉及硬件和高性能的场景经常被用到。JNI 包含的Java Invocation API可以在当前进程创建一个 JVM。

以下只是简介JNI在本文中的使用,JNI本身的介绍略过。

下面是一个 C 启动并调用 Java 的String.format("hello %s %s %d", "world", "haha", 2)并获取结果的例子。

依赖的头文件和动态链接库可以在JDK目录找到,比如在我的Mac上是

/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/include/jni.h

/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/libjvm.dylib

运行结果

所有 env 关联的 ref,会在 Detach 之后自动工释放,但我们的最终方案里没有频繁 Attach&Detach,所以上述的代码保留手动 DeleteLocalRef 的调用。否则会引起内存泄漏(上面的代码相当于是持有强引用然后置为 null)。

实际中,为了性能考虑,还需要将各种 class/methodId 缓存住(转成 globalRef),避免每次都 Find。

可以看到,仅仅是一个简单的传参+方法调用就如此繁杂,更别说遇到复杂的嵌套结构了。这意味着我们使用 C 来做 Bridge,这一层不宜太复杂。

实际实现的时候,我们在 Java 侧处理了所有异常,将异常信息包装成正常的 Response,C 里不用检查 Java 异常,简化了 C 的代码。

关于Java描述符

使用 JNI 时,各种类名/方法签名,字段签名等用的都是描述符名称,在 Java 字节码文件中,类/方法/字段的签名也都是使用这种格式。

除了通过 JDK 自带的 javap 命令可以获取完整签名外,推荐一个Jetbrain Intelli IDEA 的插件jclasslib Bytecode Viewer ,可以方便的在IDE里查看类对应的字节码信息。

4 实现

我们目前只需要单向的 Go call Java,并不需要 Java call Go。

代码比较繁杂,这里就不放了,就是上述2个简介的示例代码的结合体。

考虑 Go 发起的一次 Java 调用,要经历4步骤。

Go 通过 CGO 进入 C 环境

C 通过 JNI 调用 Java

Java 处理并返回数据给 C

C 返回数据给 Go

三 性能优化

上述介绍了 Go call Java 的原理实现,至此可以实现一个性能很差的版本。针对我们的使用场景分析性能差有几个原因:

单次调用有固定的性能损失,调用次数越多损耗越大;

除了基本数据模型外的数据(主要是日志和计算规则)需要经历多次深复制才能抵达 Java,数据量越大/调用次数越多损耗越大;

缺少合理的线程模型,导致每次 Java 调用都需要 Attach&Detach,具有一定开销;

以下是我们做的一些优化,一些优化是针对我们场景的,并不一定通用。

由于间隔时间有点久了, 一些优化的量化指标已经丢失。

1 预处理

将计算规则提前注册到 Java 并返回一个 id, 后续使用该 id 引用该计算规则, 减少传输的数据量。

Java 可以对规则进行预处理, 可以提高性能:

Groovy 等脚本语言的静态化和预编译;

正则表达式预编译;

使用字符串池减少重复的字符串实例;

提前解析数据为特定数据结构;

Groovy优化

为了进一步提高 Groovy 脚本的执行效率有以下优化:

预编译 Groovy 脚本为 Java class,然后使用反射调用,而不是使用 eval ;

尝试静态化 Groovy 脚本: 对 Groovy 不是很精通的人往往把它当 Java 来写,因此很有可能写出的脚本可以被静态化,利用 Groovy 自带的org.codehaus.groovy.transform.sc.StaticCompileTransformation可以将其静态化(不包含Groovy的动态特性),可以提升效率。

自定义 Transformer 删除无用代码: 实际发现脚本里包含 打印日志/打印堆栈/打印到标准输出 等无用代码,使用自定义 Transformer 移除相关字节码。

设计的时候考虑过 Groovy 沙箱,用于防止恶意系统调用( System.exit(0) )和执行时间太长。出于性能和难度考虑现在没有启动沙箱功能。

动态沙箱是通过拦截所有方法调用(以及一些其他行为)实现的,性能损失太大。

静态沙箱是通过静态分析,在编译阶段发现恶意调用,通过植入检测代码,避免方法长时间不返回,但由于 Groovy 的动态特性,静态分析很难分析出 Groovy 的真正行为( 比如方法的返回类型总是 Object,调用的方法本身是一个表达式,只有运行时才知道 ),因此有非常多的办法可以绕过静态分析调用恶意代码。

2 批量化

减少 20%~30% CPU使用率。

初期,我们想通过接口加多实现的方式将代码里的 Splitter/Filter 等新增一个 Java 实现,然后保持整体流程不变。

比如我们有一个 Filter

除了 Go 的实现外,我们额外提供一个 Java 的实现,它实现了调用 Java 的逻辑。

但是这个粒度太细了,流量高的应用每秒要处理80MB数据,日志切分/字段过滤等需要调用非常多次类似 Filter 接口的方法。及时我们使用了 JVM inside 方案,也无法减少单次调用 CGO 带来的开销。

另外,在我们的场景下,Go call Java 时要进行大量参数转换也会带来非常大的性能损失。

就该场景而言, 如果使用 safe 编程,每次调用必须对 content 字符串做若干次深拷贝才能传递到 Java。

优化点:

将调用粒度做粗, 避免多次调用 Java: 将整个清洗动作在 Java 里重新实现一遍, 并且实现批量能力,这样只需要调用一次 Java 就可以完成一组日志的多次清洗任务。

3 线程模型

考虑几个背景:

CGO 调用涉及 goroutine 栈扩容,如果传递了一个栈上对象的指针(在我们的场景没有)可能会改变,导致野指针;

当 Go 陷入 CGO 调用超过一段时间没有返回时,Go 就会创建一个新线程,应该是为了防止饿死其他 gouroutine 吧。

这个可以很简单的通过 C 里调用 sleep 来验证;

C 调用 Java 之前,当前线程必须已经调用过 AttachCurrentThread,并且在适当的时候DetachCurrentThread。然后才能安全访问 JVM。频繁调用 Attach&Detach 会有性能开销;

在 Java 里做的主要是一些 CPU 密集型的操作。

结合上述背景,对 Go 调用 Java 做出了如下封装:实现一个 worker pool,有n个worker(n=CPU核数*2)。里面每个 worker 单独跑一个 goroutine,使用runtime.LockOSThread() 独占一个线程,每个 worker 初始化后, 立即调用 JNI 的 AttachCurrentThread 绑定当前线程到一个 Java 线程上,这样后续就不用再调用了。至此,我们将一个 goroutine 关联到了一个 Java 线程上。此后,Go 需要调用 Java 时将请求扔到 worker pool 去竞争执行,通过 chan 接收结果。

由于线程只有固定的几个,Java 端可以使用大量 ThreadLocal 技巧来优化性能。

注意到有一个特殊的Control Worker,是用于发送一些控制命令的,实践中发现当 Worker Queue 和 n 个 workers 都繁忙的时候,控制命令无法尽快得到调用, 导致"根本停不下来"。

控制命令主要是提前将计算规则注册(和注销)到 Java 环境,从而避免每次调用 Java 时都传递一些额外参数。

关于 worker 数量

按理我们是一个 CPU 密集型动作,应该 worker 数量与 CPU 相当即可,但实际运行过程中会因为排队,导致某些配置的等待时间比较长。我们更希望平均情况下每个配置的处理耗时增高,但别出现某些配置耗时超高(毛刺)。于是故意将 worker 数量增加。

4 Java 使用 ThreadLocal 优化

复用 Decoder/CharBuffer 用于字符串解码;

复用计算过程中一些可复用的结构体,避免 ArrayList 频繁扩容;

每个 Worker 预先在 C 里申请一块堆外内存用于存放每次调用的结果,避免多次malloc&free。

当ThreadLocal.get() + obj.reset()

obj.reset() 是重置对象的代价

expand 是类似ArrayList等数据结构扩容的代价

GC 是由于对象分配而引入的GC代价

大家可以使用JMH做一些测试,在我的Mac机器上:

ThreadLocal.get()5.847 ± 0.439 ns/op

new java.lang.Object()4.136 ± 0.084 ns/op

一般情况下,我们的 Obj 是一些复杂对象,创建的代价肯定远超过new java.lang.Object() ,像 ArrayList 如果从零开始构建那么容易发生扩容不利于性能,另外热点路径上创建大量对象也会增加 GC 压力。最终将这些代价均摊一下会发现合理使用 ThreadLocal 来复用对象性能会超过每次都创建新对象。

Log4j2的"0 GC"就用到了这些技巧。

由于这些Java线程是由JNI在Attach时创建的,不受我们控制,因此无法定制Thread的实现类,否则可以使用类似Netty的FastThreadLocal再优化一把。

5 unsafe编程

减少 10%+ CPU使用率。

如果严格按照 safe 编程方式,每一步骤都会遇到一些揪心的性能问题:

Go 调用 C: 请求体主要由字符串数组组成,要拷贝大量字符串,性能损失很大

大量 Go 风格的字符串要转成 C 风格的字符串,此处有 malloc,调用完之后记得 free 掉。

Go 风格字符串如果包含 '\0',会导致 C 风格字符串提前结束。

C 调用 Java: C 风格的字符串无法直接传递给 Java,需要经历一次解码,或者作为 byte[](需要一次拷贝)传递给 Java 去解码(这样控制力高一些,我们需要考虑 UTF8 GBK 场景)。

Java 处理并返回数据给 C: 结构体比较复杂,C 很难表达,比如二维数组/多层嵌套结构体/Map 结构,转换代码繁杂易错。

C 返回数据给 Go: 此处相当于是上述步骤的逆操作,太浪费了。

多次实践时候,针对上述4个步骤分别做了优化:

1. Go调用C: Go 通过 unsafe 拿到字符串底层指针地址和长度传递给 C,全程只传递指针(转成 int64),避免大量数据拷贝。

a. 我们需要保证字符串在堆上分配而非栈上分配才行,Go 里一个简单的技巧是保证数据直接或间接跨goroutine引用就能保证分配到堆上。还可以参考reflect.ValueOf() 里调用的escape方法。

b.Go的GC是非移动式GC,因此即使GC了对象地址也不会变化

2. C调用Java: 这块没有优化,因为结构体已经很简单了,老老实实写;

3. Java处理并返回数据给C:

a. Java 解码字符串:Java 收到指针之后将指针转成DirectByteBuffer,然后利用CharsetDecoder解码出 String。

b. Java返回数据给C:

1)考虑到返回的结构体比较复杂,将其Protobuf序列化成byte[]然后传递回去, 这样 C 只需要负责搬运几个数值。

2)此处我们注意到有很多临时的malloc,结合我们的线程模型,每个线程使用了一块ThreadLocal 的堆外内存存放 Protobuf 序列化结果,使用writeTo(CodedOutputStream.newInstance(ByteBuffer))可以直接将序列化结果写入堆外, 而不用再将byte[]拷贝一次。

3)经过统计一般这块 Response 不会太大,现在大小是 10MB,超过这个大小就老老实实用malloc&free了。

4. C返回数据给Go:Go 收到 C 返回的指针之后,通过unsafe构造出 []byte,然后调用 Protobuf 代码反序列化。之后,如果该 []byte 不是基于ThreadLocal内存,那么需要主动 free 掉它。

Golang中[]byte和string

代码中的 []byte(xxxStr)和string(xxxBytes) 其实都是深复制。

Go 中的 []byte和 string 其实是上述结构体的值,利用这个事实可以做在2个类型之间以极低的代价做类型转换而不用做深复制。这个技巧在 Go 内部也经常被用到,比如string.Builder#String()。

这个技巧最好只在方法的局部使用,需要对用到的 []byte和 string的生命周期有明确的了解。需要确保不会意外修改 []byte 的内容而导致对应的字符串发生变化。

另外,将字面值字符串通过这种方式转成 []byte,然后修改 []byte 会触发一个 panic。

在 Go 向 Java 传递参数的时候,我们利用了这个技巧,将 Data(也就是底层的 void*指针地址)转成 int64 传递到Java。

Java解码字符串

Go 传递过来指针和长度,本质对应了一个 []byte,Java 需要将其解码成字符串。

通过如下 utils 可以将(address, length)转成DirectByteBuffer,然后利用CharsetDecoder可以解码到CharBuffer最后在转成 String 。

通过这个方法,完全避免了 Go string 到 Java String 的多次深拷贝。

这里的 decode 动作肯定是省不了的,因为 Go string 本质是 utf8 编码的 []byte,而 Java String 本质是 char[].

6 左起右至优化

先介绍 "左起右至切分": 使用3个参数(String leftDelim, int leftIndex, String rightDelim)定位一个子字符,表示从给定的字符串左侧数找到第 leftIndex 个 leftDelim 后,位置记录为start,继续往右寻找rightDelim,位置记录为end.则子字符串 [start+leftDelim.length(), end) 即为所求。

其中leftIndex从0开始计数。

例子:

字符串="a,b,c,d"

规则=("," , 1, ",")

结果="c"

第1个","右至","之间的内容,计数值是从0开始的。

字符串="a=1 b=2 c=3"

规则=("b=", 0, " ")

结果="2"

第0个"b="右至" "之间的内容,计数值是从0开始的。

在一个计算规则里会有很多 (leftDelim, leftIndex, rightDelim),但很多情况下leftDelim 的值是相同的,可以复用。

优化算法:

按 (leftDelim, leftIndex, rightDelim) 排序,假设排序结果存在 rules 数组里;

按该顺序获取子字符串;

处理 rules[i] 时,如果rules[i].leftDelim == rules[i-1].leftDelim,那么 rules[i] 可以复用rules[i-1]缓存的start,根据排序规则知rules[i].leftIndex>=rules[i-1].leftIndex,因此 rules[i] 可以少掉若干次indexOf。

7 动态GC优化

基于 Go 版本 1.11.9

上线之后发现容易 OOM.进行了一些排查,有如下结论。

Go GC 的3个时机:

已用的堆内存达到 NextGC 时;

连续 2min 没有发生任何 GC;

用户手动调用 runtime.GC() 或 debug.FreeOSMemory();

Go 有个参数叫 GOGC,默认是100。当每次GO GC完之后,会设置NextGC = liveSize * (1 + GOGC/100)。

liveSize 是 GC 完之后的堆使用大小,一般由需要常驻内存的对象组成。

一般常驻内存是区域稳定的,默认值 GOGC 会使得已用内存达到 2 倍常驻内存时才发生 GC。

但是 Go 的 GC 有如下问题:

根据公式,NextGC 可能会超过物理内存;

Go 并没有在内存不足时进行 GC 的机制(而 Java 就可以);

于是,Go 在堆内存不足(假设此时还没达到 NextGC,因此不触发GC)时唯一能做的就是向操作系统申请内存,于是很有可能触发 OOM。

可以很容易构造出一个程序,维持默认 GOGC = 100,我们保证常驻内存>50%的物理内存 (此时 NextGC 已经超过物理机内存了),然后以极快的速度不停堆上分配(比如一个for的无限循环),则这个 Go 程序必定触发 OOM (而 Java 则不会)。哪怕任何一刻时刻,其实我们强引用的对象占据的内存始终没有超过物理内存。

另外,我们现在的内存由 Go runtime 和 Java runtime (其实还有一些临时的C空间的内存)瓜分,而 Go runtime 显然是无法感知 Java runtime 占用的内存,每个 runtime 都认为自己能独占整个物理内存。实际在一台 8G 的容器里,分1.5G给Java,Go 其实可用的

实现

定义:

低水位 = 0.6 * 总内存

高水位 = 0.8 * 总内存

抖动区间 = [低水位, 高水位] 尽量让 常驻活跃内存 * GOGC / 100 的值维持在这个区间内, 该区间大小要根据经验调整,才能尽量使得 GOGC 大但不至于 OOM。

活跃内存=刚 GC 完后的 heapInUse

最小GOGC = 50,无论任何调整 GOGC 不能低于这个值

最大GOGC = 500 无论任何调整 GOGC 不能高于这个值

当 NextGC

当 NextGC > 高水位时,立即触发一次 GC(由于是手动触发的,根据文档会有一些STW),然后公式返回计算出一个合理的 GOGC;

其他情况,维持 GOGC 不变;

这样,如果常驻活跃内存很小,那么 GOGC 会慢慢变大直到收敛某个值附近。如果常驻活跃内存较大,那么 GOGC 会变小,尽快 GC,此时 GC 代价会提升,但总比 OOM 好吧!

这样实现之后,机器占用的物理内存水位会变高,这是符合预期的,只要不会 OOM, 我们就没必要过早释放内存给OS(就像Java一样)。

这台机器在 09:44:39 附近发现 NextGC 过高,于是赶紧进行一次 GC,并且调低 GOGC,否则如果该进程短期内消耗大量内存,很可能就会 OOM。

8 使用紧凑的数据结构

由于业务变化,我们需要在内存里缓存大量对象,约有1千万个对象。

内部结构可以简单理解为使用 map 结构来存储1千万个 row 对象的指针。

先不考虑map结构的开销,有如下估计:

Row数量 = 1千万

字符串数组平均长度 = 10

字符串平均大小 = 12

Data 数组平均长度 = 4

估算占用内存 = Row 数量*(int64 大小 + 字符串数组内存 + Data 数组内存) = 1千万 * (8+10*12+4*8) = 1525MB。

再算上一些临时对象,期望常驻内存应该比这个值多一些些,但实际上发现刚 GC 完常驻内存还有4~6G,很容易OOM。

OOM的原因见上文的 "动态GC优化"

进行了一些猜测和排查,最终验证了原因是我们的算法没有考虑语言本身的内存代价以及大量无效字段浪费了较多内存。

算一笔账:

指针大小 = 8;

字符串占内存 = sizeof(StringHeader) + 字符串长度;

数组占内存 = sizeof(SliceHeader) + 数组cap * 数组元素占的内存;

另外 Row 上有大量无用字段(均设置为 nil 或0)也要占内存;

我们有1千万的对象, 每个对象浪费8字节就浪费76MB。

这里忽略字段对齐等带来的浪费。

浪费的点在:

数组 ca p可能比数组 len 长;

Row 上有大量无用字段, 即使赋值为 nil 也会占内存(指针8字节);

较多指针占了不少内存;

最后,我们做了如下优化:

确保相关 slice 的 len 和 cap 都是刚刚好;

使用新的 Row 结构,去掉所有无用字段;

DataArray 数组的值使用结构体而非指针;

9 字符串复用

根据业务特性,很可能产生大量值相同的字符串,但却是不同实例。对此在局部利用字段map[string]string进行字符串复用,读写 map 会带来性能损失,但可以有效减少内存里重复的字符串实例,降低内存/GC压力。

为什么是局部? 因为如果是一个全局的sync.Map内部有锁, 损耗的代价会很大。

通过一个局部的map,已经能显著降低一个量级的string重复了,再继续提升效果不明显。

四 后续

这个 JVM inside 方案也被用于tair的数据采集方案,中心化 Agent 也是Golang写的,但 tair 只提供了Java SDK,因此也需要 Go call Java 方案。

SDK 里会发起阻塞型的 IO 请求,因此 worker 数量必须增加才能提高并发度。

此时 worker 不调用runtime.LockOSThread()独占一个线程, 会由于陷入 CGO 调用时间太长导致Go 产生新线程, 轻则会导致性能下降, 重则导致 OOM。

五 总结

本文介绍了 Go 调用 Java 的一种实现方案,以及结合具体业务场景做的一系列性能优化。

在实践过程中,根据Go的特性设计合理的线程模型,根据线程模型使用ThreadLocal进行对象复用,还避免了各种锁冲突。除了各种常规优化之外,还用了一些unsafe编程进行优化,unsafe其实本身并不可怕,只要充分了解其背后的原理,将unsafe在局部发挥最大功效就能带来极大的性能优化。

六 招聘

蚂蚁智能监控团队负责解决蚂蚁金服域內外的基础设施和业务应用的监控需求,正在努力建设一个支撑百万级机器集群、亿万规模服务调用场景下的,覆蓋指标、日志、性能和链路等监控数据,囊括采集、清洗、计算、存储乃至大盘展现、离线分析、告警覆盖和根因定位等功能,同时具备智能化 AIOps 能力的一站式、一体化的监控产品,并服务蚂蚁主站、国际站、网商技术风险以及金融科技输出等众多业务和场景。如果你对这方面有兴趣,欢迎加入我们。

《Flutter企业级应用开发实战》

本书重在为企业开发者和决策者提供Flutter的完整解决方案。面向企业级应用场景下的绝大多数问题和挑战,都能在本书中获得答案。注重单点问题的深耕与解决,如针对行业内挑战较大的、复杂场景下的性能问题。本书通过案例与实际代码传达实践过程中的主要思路和关键实现。本书采用全彩印刷,提供良好阅读体验。