Categories
程式開發

陸金所CAT優化實踐


背景

CAT介紹

CAT(Central Application Tracking)是一個實時監控系統,由美團點評開發並開源,定位於後端應用監控。應用集成客戶端的方式上報中間件和業務數據,支持Transaction、Event和Heartbeat等數據類型Metrics報表,也支持調用鏈路Trace,對於發現和定位應用問題有很大幫助。

CAT服務端也可以認為是一個Lamda架構的報表系統,通過匯聚客戶端上報的原始消息MessageTree,實時計算出Transaction、Event、Problem、heartbeat等報表,保存在內存中; 歷史報表序列化後保存到本地並上傳到DB存儲,原始上報數據壓縮和建立索引後上傳至hdfs。

陸金所的後端應用監控也主要基於CAT, 是在前幾年的一個版本上做二次開發,增加了新的報表。各類架構中間件大量使用CAT埋點,並一直在豐富各類場景,在各類問題發現和問題定位發揮了很大作用。

下圖是應用的Transaction 報表,集成了多個中間件打點:

陸金所CAT優化實踐 1

下圖是某一個MessageTree,用戶通過報表中Sample或者通過額外的ES索引等搜索到某個應用的MessageTree,trace到具體調用事件

陸金所CAT優化實踐 2

遭遇性能問題

由於業務擴大,應用數量劇增,生產環境的機器數從半年前的6000+ 增加到10000+;另外新版本中間件增加了埋點量,隨著應用升級,單個應用實例上傳的CAT數據也在增加。

在19年12月份, 發現會出現偶爾某些CAT實例無響應的問題。由於當時手上有更緊急的問題處理,這些偶爾的崩潰往往通過重啟來解決。直到20年1月份,開發同學開始抱怨CAT界面響應慢,“重啟大法” 不再管用了,往往上個小時剛剛重啟,下個小時就又掛了。

具體表現

生產上的CAT用的都是物理機, 配置是志強物理核心E5雙路CPU(CPU 8*2,超線程32核), 128G內存, OS redhat 6.5。高峰時的CAT的context switch 相當高,達到120萬/秒, 系統負載偶爾到20以上,一次較大的Full GC往往耗時3-5秒; 一次長時間的GC就可能造成CAT的端口無響應,只有重啟才能解決。

臨時治理

  1. GC方式從CMS 改到了G1,並調大了heap到80G
  2. 宕機的實例總是那麼幾台,這是負載不均衡造成的,因此我們修改客戶端上報數據的路由規則讓負載更加均衡
  3. 申請緊急擴容,無奈年末硬件資源和人員都非常緊張,遠水解決不了近渴

生產上的應用集群還在擴大,明年還有更多項目需要上線,硬件擴容不僅增加硬件成本,也會增加運維成本;直接 提升性能應該是最好的方案。

測試環境CAT也存在類似的容量問題, 我們有3台物理機來跑CAT,但我們的測試環境一共有15000個應用實例。之前嘗試過應用開啟CAT,但導致CAT崩潰,當前的策略是部分環境和應用開啟CAT打點的方式,是能提供了部分的監控能力,但也對開發測試人員造成了不小的困擾。

對CAT做性能優化,一方面能解決生產容量不足的問題,另一方面也能協助規劃測試環境的集群容量,大幅提升開發測試效率。

優化準備

優化方向

性能優化不是沒有方向的,其實我們在2018年就觀察到CAT在業務高峰時刻的上下文切換特別高(>1mil/s。 在19年的Qcon會議上,攜程的梁錦華介紹了他們對CAT的性能優化工作, 服務端的優化集中在改進線程模型來降低上下文切換,改進內存模型來降低GC。所以我們的優化也要覆蓋這兩個方向,另外,我們也要看下在JVM、OS配置層面能做哪些改進。

  • 線程模型優化:目的是降低上下文切換帶來的開銷;

    我們來看下什麼是上下文切換,我們都知道現代OS基本是多任務的,CPU資源在OS在不同的任務(線程)需求之間切換分配。為了確保正確性,每一次切換OS都需要保存上一次線程的運行狀態,並加載下一個線程的狀態,這些狀態往往涉及CPU上的多種寄存器;另外,在切換到下一個的線程之後,還會造成內存訪問效率的損失,這主要是不同線程運行時需要訪問的數據不同,由此帶來的多級緩存命中率下降而降低運行效率。

    一次上下文切換的直接開銷在1-5ns級別,而帶來的間接開銷則可能到1us到數個ms之間,有興趣的同學可以參考這兩篇文章: Quantifying The Cost of Context SwitchMeasuring context switching and memory overheads for Linux threads

  • 內存優化

    CAT作為APM應用,每秒攝入的數據在幾十到一百多MB級別,數據經過反序列化之後,還需要對內存報表做大量的更新操作,這個過程會創建特別多的臨時對象,會造成頻繁的Young GC。 CAT內存中維護了當前小時的報表,每一個小時中中,常駐內存隨著時間推移逐漸增大,造成可用內存減少,頻繁觸發Full GC。

  • JVM/OS/網絡設置優化

    JVM已經發佈到版本11,新版本帶來了一部分免費的性能提升, 另外GC的方式和參數也可以調整。開啟OS內存大頁和調整網絡參數等在理論上也能帶來性能提升。

核心指標

作為一個實時數據攝入的報表系統,我們很快就確定了幾個核心的性能指標:

  1. 服務端穩定性: 功能核心功能正常工作,服務端是否有OOM、無響應甚至進程崩潰現象
  2. 服務端負載: 操作系統系統負載
  3. 攝入數據速率: 主要考察單位時間(1小時)消費消息數量和數據大小
  4. 服務端消息丟失量:因為來不及處理而丟棄的消息數量
  5. 客戶端失敗消息數量:客戶端由於發送速率低於生產速率造成的消息丟棄量

第一輪優化

下圖描述了CAT的消息處理和線程模型,Netty Worker線程生成MessageTree後,offer到每個Analyzer專有隊列(Blocking Queue)中,由Analyzer線程從隊列中拉去後處理並生成對應的內存報表。

不難理解這裡的設計初衷是讓每個Analyzer獨立使用其隊列,實現了Analyzer處理的隔離,慢的Analyer不會影響那些快的Analzyer。

陸金所CAT優化實踐 3

對於某些重要且計算量較大的Analyzer(例如圖中Transaction Analyzer),使用了多個隊列,並根據客戶端應用名的hash來均衡多個隊列任務;CAT內部自建報表合併機制來合併多份報表。

如果某一個Analyzer的隊列滿了導致無法推送,Netty線程則會直接丟棄該消息,並統計丟棄次數。

下面的代碼描述了插入消息隊列的過程:

public void distribute(MessageTree tree) {
    String domain = tree.getDomain(); // domain就是上传消息的应用名
    for(Entry entry: m_tasks.entrySet())
    {
       List tasks = entry.getValue(); // PeriodTask封装了消息队列
       int index = 0;
       int length = tasks.size(); // 多个Analyzer队列
       if (length > 1) {
          index = Math.abs(domain.hashCode()) % length;
       }
       PeriodTask task = tasks.get(index);  
       if(!task.enqueue(tree)) { 
           // 记录的消息丢失
       }
    }    
}

Analyzer拉取並消費消息的代碼如下:

while(true) 
    // 无限循环拉取数据,最大5ms超时
    MessageTree message = m_queue.poll(5, TimeUnit.MILLISECONDS); 
    if(message != null) {
process(message)    
    }
}

現在一共有22個Analyzer,略微有點多,我們也不能刪除現有的Analyzer,因為不少系統已經依賴CAT的各類報表來協助監控。

通過線下profiling並結合研究代碼,我們發現:

  1. 隊列的offer和poll佔用了超過7%的CPU處理時間
  2. 從線程dump來看,Analyzer線程經常處於LockSupport.parkNanos調用上
  3. 由於部分Analyzer有多個線程,Analyzer線程總數量約30個,其線程CPU佔用又不太高 (<30%)
  4. 不同類型的Analyzer只會處理滿足特定條件的MessageTree,但是Netty Worker線程在做queue.offer動作時沒有判斷MessageTree能否被該Analyzer處理,Analyzer獲取到部分MessageTree之後又丟棄

回到系統設計模式上來, 一組線程生成MessageTree,並採用BlockingQueue 發送到另一組線程來處理,這是典型的消息傳遞場景。提到跨線程的消息傳遞,我們不能不提到大名鼎鼎的Disruptor的RingBuffer模型。

Disruptor框架是LMAX Exchange 開發的高性能隊列模型,該框架充分利用了Java語言中的volatile 語義,創新性地使用了RingBuffer數據結構,實現了在線程之間快速消息傳遞,支持批量消費。吞吐量和延時性能都高於Java標準庫中的BlockingQueue,其性能關係是:

Disruptor > ArrayBlockingQueue > LinkedBlockingQueue

由於篇幅關係,我們就不在這裡詳細介紹Disruptor內部原理了,有興趣的小伙伴請參考 Disruptor介紹

線程模型嘗試和調整

MessageTree做預過濾是必須要做的,這部分很快做完了,但在線程模型的改動上我們經過了幾次嘗試:

嘗試一

考慮到Disruptor做線程間的消息傳遞效率,我們將BlockingQueue簡單替換成了Disruptor實現。效果不是很明顯,總體的CPU使用並沒有下降多少。

由於Disruptor需要Event對象放入RingBuffer,封裝MessageTree的類定義如下:

class MessageTreeEvent {
MessageTree message;
}

嘗試二

為降低Analyzer線程數, 我們想到將多個Analyzer線程合併,在Disruptor框架下需使用同一個RingBuffer。於是我們將一個MessageTree映射到多個MessageTreeEvent,並通過1個全局的的RingBuffer,分發給一個線程池來處理。考慮到Ringbuffer中MessageTreeEvent數量增加,我們將RingBuffer大小調整到 262144 (1<<18)

新的MessageTreeEvent定位如下:

class MessageTreeEvent {
    MessageTree message;
    String analyzerId;
}

如果22個Analyzer都採用這個方法,並假設MessageTree 速率為每秒5萬,那麼最大就有 22 * 5w/s = 110w/s速率的消息需要通過Ringbuffer。這個數字乍一看非常大,但如果對照性能Disruptor測試結果, 這個速率對於Disruptor框架來說壓力不大。

陸金所CAT優化實踐 4

我們挑選了大概10個Analyzer加入這個大的RingBuffer來處理,但無論如何如何增大buffer消息丟棄情況還是有點多,特別是較為重要的Transaction/Problem等Analyzer的消息。

嘗試三

考慮到不同的Analyzer重要程度不同,我們的盡量保證核心Analyzer能正常工作,那些不太重要的Analyzer丟一點消息是可以接受的。於是我們給Analyzer 引入了優先級概念,

enum AnalyzerLevel {
  HIGH(1),
  MID(GLOBAL_REPORT_QUEUE_SIZE/16),
  LOW(GLOBAL_REPORT_QUEUE_SIZE/4);
  
  public final int requiredCapacity;
  AnalyzerLevel(int requiredCapacity) {
  this.requiredCapacity=requirecapacity;
  }
}

下面是往RingBuffer插入數據的代碼, 也體現了disruptor的優點,hasAvailableCapacity這個方法與BlockingQueue的size相比,其內部實現是無鎖的。

RingBuffer ringBuffer = disruptor.getRingBuffer();
if(ringBuffer.hasAvailableCapacity(m_analyzer.getLevel().requiredCapacity)) { 
   long seq=ringBuffer.next();
   try {
      //准备MessageTreeEvent对象
   MessageTreeEvent event = ringBuffer.get(sequence);
   event.message = messageTree;
   event.analyzerName = m_analyzerName;
   } finally {
         ringBuffer.publish(seq)  ;
   }
} else  {
    // 丢弃并记录
}

我們又引入了分組的概念,將Analyzer分為2組,每一組使用一個RingBuffer,每一個RingBuffer使用2個線程來消費。 CAT一共22個Analyzer,我們將15個Analyzer改造到了新的線程模型 。

Disruptor消費和啟動代碼如下:

// int threadsPerRingBuffer = 2 
WorkHandler [] handlers = new WorkHanlder[threadsPerRingBuffer];
for(int index = 0; index < threadsPerRingBuffer; index ++) {
  handlers[index] = createHanlder(index); // 创建多个消费线程对等
}
disruptor.handleEventWithWokerPool(handlers); // 设置disruptor的消费者
disruptor.start(); // 启动

private WorkHanlder createHandler(int threadIdx) {
  return WorkerHanlder () {
    public void onEvent(MessageTreeEvent event) {
      String analyzerName = event.analyzerName;
      getAnalyzer(threadIdx).process(event.message);
    }
  };
}

另外, 有了之前合併線程成功的經驗, 在仔細檢查代碼時和檢查線程棧時,發現Netty的worker線程數為24, 確實有點多。我們逐步降低,測試表明Netty work線程數為2時仍然一切正常,從top -H的輸出來看,在100MB/秒的網絡攝入流量下,Netty Worker線程的CPU也就在70%左右,未見客戶端發送失敗的情況。

最終的線程模型如下:

陸金所CAT優化實踐 5

JVM設置改動

在JVM和GC方式的選擇上,我們選用了open Jdk 11和G1的方式,在測試環境,這個組合的運行穩定,GC的延時較低, CAT的頁面響應也比較快。

優化工作做了2週, 快到了過年的時間,我們先找了2台機器驗證,驗證通過後更新到了所有實例。

改造效果

我們將測試環境4500台機器左右的流量導入到一台機器, 在修改前,這台CAT機器剛起來1分鐘後就會陷入無響應狀態。

改造後測試環境的這台服務器順利跑了起來, 在小時消息量0.94億,消息大小210G情況下“top -H” 輸出如下, 可以看到Netty work線程(圖中epollEventLoopG)的佔用不高,4個全局的Analyzer線程(圖中Cat-Global開頭線程)的佔用也不太高,無消息丟失。

陸金所CAT優化實踐 6

陸金所CAT優化實踐 7

在生產環境中也找出一台機器,通過配置路由規則,讓其承載較大流量,這台機器在不同負載情況下表現如下:

消息量 消息大小 應用數 CPU(平均/範圍) 上下文切換 核心消息丟失 非核心消息丟失
1.22億 320GB 1015 5.0 (1.8-8.9) 35萬 0 0
0.99億 324GB 1031 6.9 (1.4-14.9) 38萬 0 0
1.43億 312GB 1049 7.4 (2.3-16.9) 47萬 7330 76680

注: 我們區分了核心消息(優先級為High的Analyzer)與非核心消息丟失。

G1GC在生產環境表現穩定,一次young G1GC平均耗時約200ms,未見Old GC。

上下文切換下降了一半以上,CPU負載也下來了很多 ,沒有出現超過負載20+的情況,應該可以安穩過年了!

未解決的問題

春節前的一輪優化主要覆蓋線程模型優化與JVM設定, 內存優化還沒做。

生產環境中CAT在日常的高峰流量中CPU負載依然超過10,並隨著小時報表在內存中積累,10分鐘後的CPU負載明顯攀升 (如下圖)

陸金所CAT優化實踐 8

結合測試環境中CAT進程的堆dump,"jmap -histo $pid"的輸出的分析中,我們發現還存在如下幾個問題:

  1. CPU使用率還是有點高,承載較大流量是出現核心消息丟失
  2. 上下文切換較高,平時負載在40萬/秒, 高峰時間到50多萬/秒
  3. 臨時對象較多,例如SimpleDateFormat/DecimalFormat 等對象
  4. LinkedHashMap中的內存使用效率較低
  5. 駐留內存中簡單對像數量太多

詳細優化過程先從內存優化部分說起

內存優化

有效內存使用率概念

關於內存使用效率,和大家分享下Java中對象的大小概念

  • Shallow Size: 包含當前對象Header和對象直接擁有的內部數據,以下面的對象s為例,除了對象Header之外,包含1個數組引用、1個Map引用、1個double和1個int, 其內部數據大小是8*3+4 = 28 byte

    在64位JVM未開啟指針壓縮情況下加上對象Header 16 byte 並保持8 byte的對齊,最終Shallow Size 大小 28 + 16 + 4 = 48 byte

 class Sample {
    int[] intArray; // reference size 8
    Map map; // reference size 8
    double doubleValue; // double size 8
    int intValue;    // int size 4
 }
 Sample s = new Sample();

希望了解更多java對象內存佈局的朋友可以使用 open jdk jol工具 ,下面是利用jol打印上述對象layout的代碼

  import org.openjdk.jol.info.ClassLayout;
  import org.openjdk.jol.vm.VM;
  
  public class ObjectLayoutMain {
      public static void main(String[] args) throws Exception {
          System.out.println(VM.current().details());
          System.out.println(ClassLayout.parseClass(Sample.class).toPrintable());
      }
  }

以下是使用 “-Xms40g -Xmx40g” 的vm參數在64位jvm11下的輸出

  # Running 64-bit HotSpot VM.
  # Objects are 8 bytes aligned.
  # Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
  # Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
  
  org.jacky.playground.jol.Sample object internals:
   OFFSET  SIZE            TYPE DESCRIPTION                               VALUE
        0    16                 (object header)                           N/A
       16     8          double Sample.doubleValue                        N/A
       24     4             int Sample.intValue                           N/A
       28     4                 (alignment/padding gap)                  
       32     8           int[] Sample.intArray                           N/A
       40     8   java.util.Map Sample.map                                N/A
  Instance size: 48 bytes

注: 設置堆內存大於32G會關閉引用壓縮 ,興趣的同學可以自己跑一下看應用壓縮或者是32位JVM下的輸出。

  • Retain Size: 內存中的對象存在引用關係,消除循環後可以認為是一個個對象樹。對象的Retain Size是該對像對應的對象樹的大小。與對象的Shallow Size相比,Retain Size是一個相對動態的值,隨著其下層對象具體值變化而變化。

內存有效使用率定義如下:

內存使用率 = 實際數據佔用大小 / Retain Size

更多內存效率理論請參考:Building Memory-efficient Java Applications: Practices and Challenges

優化實踐

現狀

從CAT的heap dump中我們看到最大的對象主要是當前小時的各種Report對象, 這些Report大量使用了多層級的Map結構,如下圖 (圖中的數字是經驗估計數量)。可以看到Map對象非常多, 特別是層次往下的那些對象。

陸金所CAT優化實踐 9

現有代碼採用java標準庫中的LinkedHashMap來表示這些層次結構,這也就產生了大量LinkedHashMap以及子對象LinkedhashMap$Entry,從下面堆dump的內存文件分析看到這幾個package的對像在內存佔用按照類型排行上非常靠前:

  • heartbeat.model.event.*
  • transaction.model.event.*
  • event.model.entity.*

陸金所CAT優化實踐 10

內存使用概覽

開放地址HashMap實現

我們發現對於那些靠近葉子節點的報表對象,採用LinkedHashMap在大多數時候有點多餘,因為不需要記錄插入順序,可以簡化成HashMap,下面是這兩者Entry/Node節點類的定義比較:

  //java.util.Hash
  static class Node implements Map.Entry {
        final int hash;
        final K key;
        V value;
        Node next;
    }
   //java.util.LinkedHashMap
   static class Entry extends HashMap.Node {
        Entry before, after; // 额外的before & after引用
        Entry(int hash, K key, V value, Node next) {
            super(hash, key, value, next);
        }
    }

是不是還能進一步優化呢?答案是可以,而且改動很小

很多Java技術棧的同學對標準庫中利用鍊錶法實現的HashMap比較熟悉, 但大學學過《數據結構》課程的同學可能還記得另一種Hash的實現 開放地址Hash。與鍊錶法開一個鍊錶來解決衝突的方式不同, 開放地址Map通過在線性表中重新計算一個新位置來解決。

實測大小對比

下述測試代碼生成一個包含大小為size的數組,其保存大小從0到size-1的HashMap

import org.agrona.collections.Int2ObjectHashMap;
import java.util.*

private static Map[] populate(int size, boolean useOpenMap) {
    List result = new ArrayList();
    for (int i = 1; i <= size; i++) {
        Map m = useOpenMap ? new Int2ObjectHashMap() : new LinkedHashMap();
        for (int j = 0; j < i; j++) {
            m.put(j, new Range());
        }
        result.add(m);
    }
    return result.toArray(new Map[]{});
}
public static class Range { // retain size=56
    int id = 0;
    int count = 0;
    int fails = 1;
    double sum = 1.0;
    double avg = 1.0;
    double max = 1;
}

運行結果整理如下, 可以看到切換到Int2ObjectHashMap的實現就能輕鬆節省30%以上內存,如果值類型的shallow更小,節省還會更多。

size設置 數組大小(LinkedhashMap) 數組大小(開放地址Map) 節省比例
10 9104 byte 6304 byte 30.7%
100 692KB 459KB 33.7%
1000 77,302KB 43,506KB 43.7%

值得說明的是:

  1. 在CPU性能測試中,開放地址的Map的get/put都比HashMap性能略差,但絕對值差距很小
  2. 開放地址的Map在刪除時需要在將這個位置mark成已刪除,會造成空間浪費,但在CAT計算中無刪除操作

對象消除

在上一個圖中,可以注意到heartbeat.model.event.Detail對像數量非常之多,其占用內存就超過1G!

對應到業務邏輯,每一個Detail都是描述應用heartbeat的某一個屬性,例如"SystenLoad"、“PhysicalFreeMemory”、"GC Count"等,這些Details存在如下幾個特點:

  1. Key大量重複, Key去重後數量很少,同一個應用的CAT客戶端在不同時間、不同實例的heartbeat中的Key都一樣; 還有部分Key是CAT客戶端自帶的,這部分Key對所有應用都一樣
  2. Detail的定義非常簡單,m_label 總是為null,可以直接去掉
class Detail {
     String m_id;
     double m_value;
     String m_label; //总为null, 可以消除
}
  1. Detail對象保存在Extension對像中,其中的key與value中id值相同
class Extension {
Map m_detais = new LinkedHashMap();
}

從上面的幾個特點,我們可以將這裡的key對象映射成int,一個detail對象的有效數據就是一個int和一個double對象,總的有效大小為12byte。

我們來找兩個例子來計算下內存使用效率:

這是一個非典型場景, m_details中key數量為122,比較多

陸金所CAT優化實踐 11

我們來計算上面m_details hashmap的內存使用效率:

  1. Retained size 17632
  2. 有效大小 122 * 12
  3. 內存有效率 122 * 12 / 17632 = 8.3%

下面這個m_details,key數量較小,內存使用效率: (12 *2)/472=5.1%

陸金所CAT優化實踐 12

在使用eclipse collection 的LongDoubleMap替代後,上述兩例的使用效率分別提高到 46.1和15.8%。

其他內存優化

考慮到線程安全問題,SimpleDateFormat和DecimalFormat等對像在使用時創建新實例,使用線程安全的實現來代替即可。

繼續線程優化

為了可以更方便地調整全局線程/ringBuffer,並始終保持不同線程之間負載和優先級的均衡,我們引入了Analyzer動態分組。

Analyzer動態分組

我們對大約20個Analyzer按照重要正度和計算複雜度綜合考慮排序,用於Analyzer分組。

Analyzer index 優先級
HeartbeatAnalyzer 0 High
DumpAnalyzer 1 High
TransactionAnalyzer 2 High
EventAnalyzer 3 High
DependencyAnalyzer 12 Low
MqAnalyzer 15 Low

動態分組保證那些計算量大且優先級又高的Analyzer不集中競爭計算資源, 實現規則如下

effectiveRingBufferIndex = analyzer.getGlobalIndex() % ringbufferCount 

我們將剩下的幾個Analyzer合併到了全局線程組,對Netty Worker數、全局線程數和每個RingBuffer的消費線程數做了配置化。默認開啟2個Netty Worker線程,3個全局線程/ringBuffer,考慮到維護多份報表的內存開銷較大,每個RingBuffer的消費線程數默認設置為1。優化後的典型的線程配置如下:

陸金所CAT優化實踐 13

另外繼續增加了Ringbuffer大小到524288 (2^19) ,當然我們也清楚增加緩存大小有兩個壞處:

  1. 最大處理延時增加,考慮到CAT的處理能力,這個影響最大不超過5秒,業務上可以接受
  2. buffer增大導致內存使用增加,由於CAT進程都是動輒幾十G的堆,額外的百萬個buffer對象帶來的影響微乎其微

其他優化與嘗試

  1. 對ConcurrentHashMap做 null 檢查後使用synchronize改到使用ConcurrentMap.computeIfAbsent

    CAT啟動或者跨小時的時候會集中創建bucket,採用null檢查 + synchronize的方法會造成集中的線程堵塞

ConcurrentMap m_buckets = new ConcurrentHashMap();
//改造前
bucket=m_buckets.get(path);
if(bucket == null) { 
   synchronize(m_buckets) {
       bucket= createBucker(); // 慢操作
       m_buckets.put(path, bucket);
   }
}
//改造后
bucket=m_buckets.computeIfAbsent(path,path -> createBucket());
  1. 縮減CAT集群內部請求的線程數量,增加其buffer大小,並使用連接池來管理連接
  2. 增加磁盤寫入線程數量和buffer來緩解測試環境磁盤寫入較慢的問題
  3. 測試環境OS的電源管理從on-demand 改成performance模式,與生產對齊
  4. 測試環境嘗試開啟內存大頁,效果不太明顯,生產環境也需要運維協助配置,暫放棄

效果

單機性能

為了驗證優化效果,我們對某一台機器又加大了流量,比較了不同負載的表現

消息量 消息大小 CPU(平均/範圍) 上下文切換 核心消息丟失 非核心消息丟失
1.01億 180GB 2.1(0.9-5) 19.2萬 0 0
1.27億 283.9GB 4.11.9-7.6) 20.2萬 0 1,394,670
1.45億 296.8GB 4.6(2.9-9.7) 21.2萬 0 552,854
1.74億(*) 402GB(*) 8.1(2.2-13.8) 25萬 0 53,085,742

注:1.74億消息量是人為加大負載,每秒網絡流量114MB(402GB/3600) ,已打滿千兆線路。

下圖為小時消息量1.45億下的系統表現:

陸金所CAT優化實踐 14

可以看到上下文切換、CPU的使用率和GC都非常平穩,核心消息丟失為0;非核心消息丟失略高。可考慮增加全局處理線程數到4甚至5來緩解極端負載下的非核心消息丟失。

容量評估

基於最新的單機性能和總的生產數據量,現有生產環境集群還有約50%的冗餘容量,未來2年都無需擴容。

測試環境的CAT容量也評估了出來,現有3台CAT支撐15000個測試應用實例有點勉強,正在申請額外3台服務器,這樣就能支持所有的測試集群,並留有部分冗餘。

思考

超線程

超線程(Hyper Thread,HT)給OS提供了更多的可用核心,但這些核心是畢竟是硬件虛擬出來的,目的是更好地使用CPU多餘的計算和緩存資源,提供更高的吞吐量。

簡單地認為開啟HT可以免費獲得一倍的可用線程併計算能力能翻倍是不可取的,物理核心和虛擬核心會競爭使用計算和緩存資源,在某些情況下甚至會降低吞吐量。

在計算密集的場景下,HT的虛擬核心是不能計算在可用核心裡面的,因為虛擬CPU的計算能力有限。這可能也是我們生產環境CPU飆到20左右就會出現計算能力嚴重不足,帶來端口無響應等問題。

Java內存使用效率

Java有很好的面向對象的特性,在書寫程序時帶來了很多便利,但也帶來了運行時刻的內存負擔,每個對像都有個很大的Header,有時Header甚至超過了本身數據的大小。

這有兩個比較好的解決方案值得期待:

  1. Java語言支持struct類型

    Java語言struct類型需求很早就被提了出來,struct類型和原生類型一樣,不屬於對象範疇,沒有對象Header的內存成本。近年放在valhalla 項目中, 19年5月份發布了原型版,有興趣的同學可以看下。

  2. java與原生語言混合編程

    Oracle的graalvm項目,支持Java語言與其他原生語言混合編程,在Java應用的性能瓶頸的部分採用C或者Rust語言來實現。該項目已經開源,已經取得了一定的進展,可在官網下載社區版的graalvm的JDK。

總結

兩輪性能優化各耗時2週,回顧整個優化過程,我們制定了大體的方向,找到核心的性能指標,大量查找資料,從原理驗證做起,並結合線下環境的逐步驗證,直到目標達成為止。

在優化過程中,我們也學習了CAT本身設計巧妙的地方,例如異步化的實時數據處理、支持水平擴容、高效的序列化/反序列化和集群數據路由等。在此感謝美團點評的朋友把這個項目開源出來,讓大量的開發者收益。

性能優化是一個綜合的話題,並沒有什麼聖杯,只需在工作中勤摸索、常思考、積極與他人交流並敢於嘗試總能有收穫。我們把這次優化經歷寫出來,希望能拋磚引玉,也歡迎各位同行指正。

作者介紹

蔡健,陸金所應用架構師。 2008年復旦大學碩士畢業後加入大摩, 2016年加入陸金所,負責Java架構中間件和應用監控;職業理念是專注,並對新技術時刻充滿熱情。

方超,陸金所應用架構師。十年工作經驗。熱愛生活和技術。