
作者 | 響風
來源 | 阿裡技術公衆号
一 背景
一個基于 Golang 編寫的日志收集和清洗的應用需要支援一些基于 JVM 的算子。
算子依賴了一些庫:
Groovy
aviatorscript
該應用有如下特征:
1、處理資料量大
- 每分鐘處理幾百萬行日志,日志流速幾十 MB/S;
- 每行日志可能需要執行多個計算任務,計算任務個數不好估計,幾個到幾千都有;
- 每個計算任務需要對一行日志進行切分/過濾,一般條件<10個;
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")。
運作結果輸出:
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 通過 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
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 線程模型
考慮幾個背景:
- 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() < new Obj() + expand + GC 時,就能利用 ThreadLocal來加速。
- 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個步驟分别做了優化:
- Go調用C: Go 通過 unsafe 拿到字元串底層指針位址和長度傳遞給 C,全程隻傳遞指針(轉成 int64),避免大量資料拷貝。
- 我們需要保證字元串在堆上配置設定而非棧上配置設定才行,Go 裡一個簡單的技巧是保證資料直接或間接跨goroutine引用就能保證配置設定到堆上。還可以參考 reflect.ValueOf() 裡調用的 escape 方法。
- Go的GC是非移動式GC,是以即使GC了對象位址也不會變化
- C調用Java: 這塊沒有優化,因為結構體已經很簡單了,老老實實寫;
- Java處理并傳回資料給C:
- Java 解碼字元串:Java 收到指針之後将指針轉成 DirectByteBuffer ,然後利用 CharsetDecoder 解碼出 String。
- Java傳回資料給C:
- 考慮到傳回的結構體比較複雜,将其 Protobuf 序列化成 byte[] 然後傳遞回去, 這樣 C 隻需要負責搬運幾個數值。
- 此處我們注意到有很多臨時的 malloc,結合我們的線程模型,每個線程使用了一塊 ThreadLocal 的堆外記憶體存放 Protobuf 序列化結果,使用 writeTo(CodedOutputStream.newInstance(ByteBuffer))可以直接将序列化結果寫入堆外, 而不用再将 byte[] 拷貝一次。
- 經過統計一般這塊 Response 不會太大,現在大小是 10MB,超過這個大小就老老實實用 malloc&free了。
- C傳回資料給Go:Go 收到 C 傳回的指針之後,通過 unsafe 構造出 []byte,然後調用 Protobuf 代碼反序列化。之後,如果該 []byte 不是基于 ThreadLocal 記憶體,那麼需要主動 free 掉它。
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 的值是相同的,可以複用。
優化算法:
- 按 (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 其實可用的 < 6G。
實作
定義:
低水位 = 0.6 * 總記憶體
高水位 = 0.8 * 總記憶體
抖動區間 = [低水位, 高水位] 盡量讓 常駐活躍記憶體 * GOGC / 100 的值維持在這個區間内, 該區間大小要根據經驗調整,才能盡量使得 GOGC 大但不至于 OOM。
活躍記憶體=剛 GC 完後的 heapInUse
最小GOGC = 50,無論任何調整 GOGC 不能低于這個值
最大GOGC = 500 無論任何調整 GOGC 不能高于這個值
- 當 NextGC < 低水位時,調高 GOGC 幅度10;
- 當 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 對象的指針。
type Row struct {
Timestamp int64
StringArray []string
DataArray []Data
// 此處省略一些其他無用字段, 均已經設為nil
}
type Data interface {
// 省略一些方法
}
type Float64Data struct {
Value float64
}
先不考慮map結構的開銷,有如下估計:
- Row數量 = 1千萬
- 字元串數組平均長度 = 10
- 字元串平均大小 = 12
- Data 數組平均長度 = 4
估算占用記憶體 = Row 數量(int64 大小 + 字元串數組記憶體 + Data 數組記憶體) = 1千萬 (8+1012+48) = 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 能力的一站式、一體化的監控産品,并服務螞蟻主站、國際站、網商技術風險以及金融科技輸出等衆多業務和場景。如果你對這方面有興趣,歡迎加入我們。
聯系人:季真([email protected])
《Flutter企業級應用開發實戰》
本書重在為企業開發者和決策者提供Flutter的完整解決方案。面向企業級應用場景下的絕大多數問題和挑戰,都能在本書中獲得答案。注重單點問題的深耕與解決,如針對行業内挑戰較大的、複雜場景下的性能問題。本書通過案例與實際代碼傳達實踐過程中的主要思路和關鍵實作。本書采用全彩印刷,提供良好閱讀體驗。
點選這裡,檢視書籍~