简介: 一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。
作者 | 响风
来源 | 阿里技术公众号
一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。
算子依赖了一些库:
Groovy
aviatorscript
该应用有如下特征:
1、处理数据量大
2、有一定实时性要求,某些数据必须在特定时间内算完;
3、4C8G 规格(后来扩展为 8C16G ),内存比较紧张,随着业务扩展,需要缓存较多数据;
简言之,对性能要求很高。
有两种方案:
出于时间紧张和代码复用的考虑选择了 "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")。
运行结果输出:
hello 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)并获取结果的例子。
#include < stdio.h>
#include < stdlib.h>
#include "jni.h"
JavaVM *bootJvm() {
JavaVM *jvm;
JNIEnv *env;
JavaVMInitArgs jvm_args;
JavaVMOption options[4];
// 此处可以定制一些JVM属性
// 通过这种方式启动的JVM只能通过 -Djava.class.path= 来指定classpath
// 并且此处不支持*
options[0].optionString = "-Djava.class.path= -Dfoo=bar";
options[1].optionString = "-Xmx1g";
options[2].optionString = "-Xms1g";
options[3].optionString = "-Xmn256m";
jvm_args.options = options;
jvm_args.nOptions = sizeof(options) / sizeof(JavaVMOption);
jvm_args.version = JNI_VERSION_1_8; // Same as Java version
jvm_args.ignoreUnrecognized = JNI_FALSE; // For more error messages.
JavaVMAttachArgs aargs;
aargs.version = JNI_VERSION_1_8;
aargs.name = "TODO";
aargs.group = NULL;
JNI_CreateJavaVM(&jvm, (void **) &env, &jvm_args);
// 此处env对我们已经没用了, 所以detach掉.
// 否则默认情况下刚create完JVM, 会自动将当前线程Attach上去
(*jvm)->DetachCurrentThread(jvm);
return jvm;
}
int main() {
JavaVM *jvm = bootJvm();
JNIEnv *env;
if ((*jvm)->AttachCurrentThread(jvm, (void **) &env, NULL) != JNI_OK) {
printf("AttachCurrentThread error\n");
exit(1);
}
// 以下是 C 调用Java 执行 String.format("hello %s %s %d", "world", "haha", 2) 的例子
jclass String_class = (*env)->FindClass(env, "java/lang/String");
jclass Object_class = (*env)->FindClass(env, "java/lang/Object");
jclass Integer_class = (*env)->FindClass(env, "java/lang/Integer");
jmethodID format_method = (*env)->GetStaticMethodID(env, String_class, "format",
"(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;");
jmethodID Integer_constructor = (*env)->GetMethodID(env, Integer_class, "< init>", "(I)V");
// string里不能包含中文 否则还需要额外的代码
jstring j_arg0 = (*env)->NewStringUTF(env, "world");
jstring j_arg1 = (*env)->NewStringUTF(env, "haha");
jobject j_arg2 = (*env)->NewObject(env, Integer_class, Integer_constructor, 2);
// args = new Object[3]
jobjectArray j_args = (*env)->NewObjectArray(env, 3, Object_class, NULL);
// args[0] = j_arg0
// args[1] = j_arg1
// args[2] = new Integer(2)
(*env)->SetObjectArrayElement(env, j_args, 0, j_arg0);
(*env)->SetObjectArrayElement(env, j_args, 1, j_arg1);
(*env)->SetObjectArrayElement(env, j_args, 2, j_arg2);
(*env)->DeleteLocalRef(env, j_arg0);
(*env)->DeleteLocalRef(env, j_arg1);
(*env)->DeleteLocalRef(env, j_arg2);
jstring j_format = (*env)->NewStringUTF(env, "hello %s %s %d");
// j_result = String.format("hello %s %s %d", jargs);
jobject j_result = (*env)->CallStaticObjectMethod(env, String_class, format_method, j_format, j_args);
(*env)->DeleteLocalRef(env, j_format);
// 异常处理
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
printf("ExceptionCheck\n");
exit(1);
}
jint result_length = (*env)->GetStringUTFLength(env, j_result);
char *c_result = malloc(result_length + 1);
c_result[result_length] = 0;
(*env)->GetStringUTFRegion(env, j_result, 0, result_length, c_result);
(*env)->DeleteLocalRef(env, j_result);
printf("java result=%s\n", c_result);
free(c_result);
(*env)->DeleteLocalRef(env, j_args);
if ((*jvm)->DetachCurrentThread(jvm) != JNI_OK) {
printf("AttachCurrentThread error\n");
exit(1);
}
printf("done\n");
return 0;
}
依赖的头文件和动态链接库可以在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
运行结果
java result=hello world haha 2
done
所有 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 call Java 的原理实现,至此可以实现一个性能很差的版本。针对我们的使用场景分析性能差有几个原因:
以下是我们做的一些优化,一些优化是针对我们场景的,并不一定通用。
由于间隔时间有点久了, 一些优化的量化指标已经丢失。
1 预处理
Groovy优化
为了进一步提高 Groovy 脚本的执行效率有以下优化:
设计的时候考虑过 Groovy 沙箱,用于防止恶意系统调用( System.exit(0) )和执行时间太长。出于性能和难度考虑现在没有启动沙箱功能。
动态沙箱是通过拦截所有方法调用(以及一些其他行为)实现的,性能损失太大。
静态沙箱是通过静态分析,在编译阶段发现恶意调用,通过植入检测代码,避免方法长时间不返回,但由于 Groovy 的动态特性,静态分析很难分析出 Groovy 的真正行为( 比如方法的返回类型总是 Object,调用的方法本身是一个表达式,只有运行时才知道 ),因此有非常多的办法可以绕过静态分析调用恶意代码。
2 批量化
减少 20%~30% CPU使用率。
初期,我们想通过接口加多实现的方式将代码里的 Splitter/Filter 等新增一个 Java 实现,然后保持整体流程不变。
比如我们有一个 Filter
type Filter interface {
Filter(string) bool
}
除了 Go 的实现外,我们额外提供一个 Java 的实现,它实现了调用 Java 的逻辑。
type JavaFilter struct {
}
func (f *JavaFilter) Filter(content string) bool {
// call java
}
但是这个粒度太细了,流量高的应用每秒要处理80MB数据,日志切分/字段过滤等需要调用非常多次类似 Filter 接口的方法。及时我们使用了 JVM inside 方案,也无法减少单次调用 CGO 带来的开销。
另外,在我们的场景下,Go call Java 时要进行大量参数转换也会带来非常大的性能损失。
就该场景而言, 如果使用 safe 编程,每次调用必须对 content 字符串做若干次深拷贝才能传递到 Java。
优化点:
将调用粒度做粗, 避免多次调用 Java: 将整个清洗动作在 Java 里重新实现一遍, 并且实现批量能力,这样只需要调用一次 Java 就可以完成一组日志的多次清洗任务。
3 线程模型
考虑几个背景:
这个可以很简单的通过 C 里调用 sleep 来验证;
结合上述背景,对 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 优化
当 ThreadLocal.get() + obj.reset() < new Obj() + expand + GC 时,就能利用 ThreadLocal来加速。
大家可以使用JMH做一些测试,在我的Mac机器上:
一般情况下,我们的 Obj 是一些复杂对象,创建的代价肯定远超过 new java.lang.Object() ,像 ArrayList 如果从零开始构建那么容易发生扩容不利于性能,另外热点路径上创建大量对象也会增加 GC 压力。最终将这些代价均摊一下会发现合理使用 ThreadLocal 来复用对象性能会超过每次都创建新对象。
Log4j2的"0 GC"就用到了这些技巧。
由于这些Java线程是由JNI在Attach时创建的,不受我们控制,因此无法定制Thread的实现类,否则可以使用类似Netty的FastThreadLocal再优化一把。
5 unsafe编程
减少 10%+ CPU使用率。
如果严格按照 safe 编程方式,每一步骤都会遇到一些揪心的性能问题:
多次实践时候,针对上述4个步骤分别做了优化:
Java返回数据给C:
Golang中[]byte和string
代码中的 []byte(xxxStr) 和 string(xxxBytes) 其实都是深复制。
type SliceHeader struct {
// 底层字节数组的地址
Data uintptr
// 长度
Len int
// 容量
Cap int
}
type StringHeader struct {
// 底层字节数组的地址
Data uintptr
// 长度
Len int
}
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[].
public class DirectMemoryUtils {
private static final Unsafe unsafe;
private static final Class< ?> DIRECT_BYTE_BUFFER_CLASS;
private static final long DIRECT_BYTE_BUFFER_ADDRESS_OFFSET;
private static final long DIRECT_BYTE_BUFFER_CAPACITY_OFFSET;
private static final long DIRECT_BYTE_BUFFER_LIMIT_OFFSET;
static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
} catch (Exception e) {
throw new AssertionError(e);
}
try {
ByteBuffer directBuffer = ByteBuffer.allocateDirect(0);
Class> clazz = directBuffer.getClass();
DIRECT_BYTE_BUFFER_ADDRESS_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("address"));
DIRECT_BYTE_BUFFER_CAPACITY_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("capacity"));
DIRECT_BYTE_BUFFER_LIMIT_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("limit"));
DIRECT_BYTE_BUFFER_CLASS = clazz;
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
public static long allocateMemory(long size) {
// 经过测试 JNA 的 Native.malloc 吞吐量是 unsafe.allocateMemory 的接近2倍
// return Native.malloc(size);
return unsafe.allocateMemory(size);
}
public static void freeMemory(long address) {
// Native.free(address);
unsafe.freeMemory(address);
}
/**
* @param address 用long表示一个来自C的指针, 指向一块内存区域
* @param len 内存区域长度
* @return
*/
public static ByteBuffer directBufferFor(long address, long len) {
if (len > Integer.MAX_VALUE || len < 0L) {
throw new IllegalArgumentException("invalid len " + len);
}
// 以下技巧来自OHC, 通过unsafe绕过构造器直接创建对象, 然后对几个内部字段进行赋值
try {
ByteBuffer bb = (ByteBuffer) unsafe.allocateInstance(DIRECT_BYTE_BUFFER_CLASS);
unsafe.putLong(bb, DIRECT_BYTE_BUFFER_ADDRESS_OFFSET, address);
unsafe.putInt(bb, DIRECT_BYTE_BUFFER_CAPACITY_OFFSET, (int) len);
unsafe.putInt(bb, DIRECT_BYTE_BUFFER_LIMIT_OFFSET, (int) len);
return bb;
} catch (Error e) {
throw e;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
public static byte[] readAll(ByteBuffer bb) {
byte[] bs = new byte[bb.remaining()];
bb.get(bs);
return bs;
}
}
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 的值是相同的,可以复用。
优化算法:
7 动态GC优化
基于 Go 版本 1.11.9
上线之后发现容易 OOM.进行了一些排查,有如下结论。
Go GC 的3个时机:
Go 有个参数叫 GOGC,默认是100。当每次GO GC完之后,会设置 NextGC = liveSize * (1 + GOGC/100)
liveSize 是 GC 完之后的堆使用大小,一般由需要常驻内存的对象组成。
一般常驻内存是区域稳定的,默认值 GOGC 会使得已用内存达到 2 倍常驻内存时才发生 GC。
但是 Go 的 GC 有如下问题:
于是,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 其实可用的 < 6G。
实现
定义:
低水位 = 0.6 * 总内存
高水位 = 0.8 * 总内存
抖动区间 = [低水位, 高水位] 尽量让 常驻活跃内存 * GOGC / 100 的值维持在这个区间内, 该区间大小要根据经验调整,才能尽量使得 GOGC 大但不至于 OOM。
活跃内存=刚 GC 完后的 heapInUse
最小GOGC = 50,无论任何调整 GOGC 不能低于这个值
最大GOGC = 500 无论任何调整 GOGC 不能高于这个值
这样,如果常驻活跃内存很小,那么 GOGC 会慢慢变大直到收敛某个值附近。如果常驻活跃内存较大,那么 GOGC 会变小,尽快 GC,此时 GC 代价会提升,但总比 OOM 好吧!
这样实现之后,机器占用的物理内存水位会变高,这是符合预期的,只要不会 OOM, 我们就没必要过早释放内存给OS(就像Java一样)。
这台机器在 09:44:39 附近发现 NextGC 过高,于是赶紧进行一次 GC,并且调低 GOGC,否则如果该进程短期内消耗大量内存,很可能就会 OOM。
8 使用紧凑的数据结构
由于业务变化,我们需要在内存里缓存大量对象,约有1千万个对象。
内部结构可以简单理解为使用 map 结构来存储1千万个 row 对象的指针。
type Row struct {
Timestamp int64
StringArray []string
DataArray []Data
// 此处省略一些其他无用字段, 均已经设为nil
}
type Data interface {
// 省略一些方法
}
type Float64Data struct {
Value float64
}
先不考虑map结构的开销,有如下估计:
估算占用内存 = Row 数量(int64 大小 + 字符串数组内存 + Data 数组内存) = 1千万 (8+1012+48) = 1525MB。
再算上一些临时对象,期望常驻内存应该比这个值多一些些,但实际上发现刚 GC 完常驻内存还有4~6G,很容易OOM。
OOM的原因见上文的 "动态GC优化"
进行了一些猜测和排查,最终验证了原因是我们的算法没有考虑语言本身的内存代价以及大量无效字段浪费了较多内存。
算一笔账:
这里忽略字段对齐等带来的浪费。
浪费的点在:
最后,我们做了如下优化:
9 字符串复用
根据业务特性,很可能产生大量值相同的字符串,但却是不同实例。对此在局部利用字段 map[string]string 进行字符串复用,读写 map 会带来性能损失,但可以有效减少内存里重复的字符串实例,降低内存/GC压力。
为什么是局部? 因为如果是一个全局的 sync.Map 内部有锁, 损耗的代价会很大。
通过一个局部的map,已经能显著降低一个量级的string重复了,再继续提升效果不明显。
这个 JVM inside 方案也被用于tair的数据采集方案,中心化 Agent 也是 Golang 写的,但 tair 只提供了 Java SDK,因此也需要 Go call Java 方案。
本文介绍了 Go 调用 Java 的一种实现方案,以及结合具体业务场景做的一系列性能优化。
在实践过程中,根据Go的特性设计合理的线程模型,根据线程模型使用ThreadLocal进行对象复用,还避免了各种锁冲突。除了各种常规优化之外,还用了一些unsafe编程进行优化,unsafe其实本身并不可怕,只要充分了解其背后的原理,将unsafe在局部发挥最大功效就能带来极大的性能优化。
原文链接
本文为阿里云原创内容,未经允许不得转载。