參考:https://cloud.tencent.com/developer/article/1636527
Tencent ES 是內核級深度優化的 ES 分支,持續地進行高可用、高性能、低成本等全方位優化,已支撐的單集群規模達到千級節點、萬億級吞吐。
Tencent ES 已在公司內部開源,同時也積極貢獻開源社區,截止目前已向社區提交 PR 25+。
騰訊聯合 Elastic 官方在騰訊雲上提供了內核增強版 ES 雲服務,支撐公司內部雲、外部雲、專有雲達 60PB+ 的數據存儲,服務 蘑菇街、知乎、B 站、鳳凰網等業內頭部客戶。
本文主要介紹 Tencent ES 的主要優化點之一:零拷貝 內存 Off Heap,提升內存使用效率,降低存儲成本。
最終達到,在讀寫性能與源生邏輯一致的前提下,堆內存使用率降低 80%,單節點存儲量從 5TB 提升至 50TB 的效果。
問題:日志分析場景數據量大,ES 內存瓶頸導致存儲成本較高
上節提到,日志分析是 ES 的主要應用場景(占比60%),而日志數據的特點顯著:
- 數據量大,成本是主要訴求:我們大批線上大客戶,數據量在幾百 TB 甚至 PB 級,單集群占用幾百台機器。如此規模的數據量,帶來了較高的成本,甚至有些客戶吐槽,日志的存儲成本已超越產品自身的成本。
- 數據訪問冷熱特性明顯:如下圖所示,日志訪問近多遠少,歷史極少訪問卻占用大量成本。
分析:成本瓶頸在哪里:堆內存使用率過高

我們對線上售賣的集群做硬件成本分析后,發現成本主要在磁盤和內存。
為了降低磁盤成本,我們采取冷熱分離、Rollup、備份歸檔、數據裁剪等多種方式降成本。在冷熱分離的集群,我們通過大容量的冷存儲機型,來存儲歷史數據,使得磁盤成本下降 60% 左右。

問題也隨之而來:如上圖所示,大容量的冷機型,存在磁盤使用率過低的問題( 40 % 以下),原因是堆內存使用率過高了( 70 % 左右),制約磁盤使用率無法提升。(其中單節點磁盤使用率 40%,約 13TB 左右,這已經是 Tencent ES 優化后的效果,源生只能支持到5 TB 左右)
所以,為了提升低成本的冷機型磁盤使用率,同時也為了降低內存成本,我們需要降低 ES 的堆內存使用率。
堆內存使用率為什么會高?
ES 是通過 JAVA 語言編寫的,在介紹如何降低堆內存使用率之前,先了解下 JAVA 的堆內存:

- 堆內存就是由 JVM (JAVA 虛擬機)管理的內存。建立在堆內存中的對象有生命周期管理機制,由垃圾回收機時自動回收過期對象占用的內存。
- 堆外內存是由用戶程序管理的內存,堆外內存中的對象過期時,需要由用戶代碼顯示釋放。1. 運營側調整裝箱策略能否解決問題?了解了 JAVA 堆內存后,我們看,能否通過調整運營策略來提升堆內存容量呢?
- 堆內存分配大一點行不行? 超過32GB,指針壓縮失效,內存浪費, 50GB堆內存性能與31GB接近 且垃圾回收壓力大,也影響性能
- 多節點部署提高堆內存總量是否可行? 多節點部署,占用機器量更大,用戶成本上升 大客戶節點數過多( 幾百個 ),集群元數據管理瓶頸,可用性下降 反向推動雲上用戶拆分集群,阻力很大
所以,簡單的運營側策略調整無法解決堆內存使用率過高的問題。那么我們就需要確認 ES 的堆內存是被什么數據占用了,能否優化。
2. 堆內存被什么數據占用了?
我們對線上集群的堆內存分布情況做統計分析后,發現絕大部分堆內存主要被 FST( Finite State Transducer )占用了:
- FST 內存占用量占分堆內存總量的 50% ~ 70%
- FST 與 磁盤數據量成正比: 10TB 磁盤數據量,其對應的 FST 內存占用量在 10GB ~ 15GB 左右。
因此,我們的目標就是就是通過內核層的優化,降低 FST 的堆內存占用量。
方案:降低 FST 堆內存占用量
什么是 FST ?
在介紹具體的方案前,先來了解下 FST 到底是什么。

如上圖所示,ES 底層存儲采用 Lucene(搜索引擎),寫入時會根據原始數據的內容,分詞,然后生成倒排索引。
查詢時,先通過查詢倒排索引找到數據地址(DocID)),再讀取原始數據(行存數據、列存數據)。
但由於 Lucene 會為原始數據中的每個詞都生成倒排索引,數據量較大。所以倒排索引對應的倒排表被存放在磁盤上。這樣如果每次查詢都直接讀取磁盤上的倒排表,再查詢目標關鍵詞,會有很多次磁盤 IO,嚴重影響查詢性能。
為了解磁盤 IO 問題,Lucene 引入排索引的二級索引 FST Finite State Transducer 。原理上可以理解為前綴樹,加速查詢。
其原理如下:
- 將原本的分詞表,拆分成多個 Block ,每個 Block 會包含 25 ~ 48 個詞(Term)。圖中做了簡單示意,Allen 和 After組成一個 Block 。
- 將每個 Block 中所有詞的公共前綴抽取出來,如 Allen 和 After 的公共前綴是 A 。
- 將各個 Block 的公共前綴,按照類似前綴樹的邏輯組合成 FST,其葉子節點攜帶對應 Block 的首地址 。(實際 FST 結構更為復雜,前綴后綴都有壓縮,來降低內存占用量)
- 為了加速查詢,FST 永駐堆內內存,無法被 GC 回收。
- 用戶查詢時,先通過關鍵詞(Term)查詢內存中的 FST ,找到該 Term 對應的 Block 首地址。再讀磁盤上的分詞表,將該 Block 加載到內存,遍歷該 Block ,查找到目標 Term 對應的DocID。再按照一定的排序規則,生成DocID的優先級隊列,再按該隊列的順序讀取磁盤中的原始數據(行存或列存)。
由此可知,FST常駐堆內內存,無法被 GC 回收 , 長期占用 50% ~ 70% 的堆內存 !
解決方案
既然 FST 是常駐堆內內存,導致堆內存使用率過高,那么解決問題的思路有兩種:
- 降低 FST 在堆內的內存使用量
- 將 FST 從堆內存(OnHeap,有32GB容量限制)移到堆外內存(OffHeap)。因為堆外內存無容量上限,可通過擴充機器內存來提升容量。 (堆外內存容量限制近似為 物理內存 - JAVA堆內存) 自然也就有了相應方案:
解決方案一:降低 FST 在堆內的內存使用量
在 Tencent ES 成立前期,我們采用過這種方案。具體的做法是,將 FST 對應的 Block 大小,從 25 ~ 48,放大一倍至 49 ~ 96 。這樣,在 關鍵詞 Term 數相同的情況下,Block 數量降低了一倍,對應的 FST 內存理論上也會下降一倍。
- 優點:我們實測發現,這種方案下,FST 的堆內存占用量下降了 40% 左右。
- 缺點: - 由於 Block內的 Term 數變多了,那么每次遍歷 Block 查找目標 Term 時,需要從磁盤讀取的數據量更大了,因此也帶來了明顯的查詢性能損耗,約 20% 。 - 該方案只是讓 FST 占用的內存下降了一半,仍無法控制 FST 占用的內存總量。不同場景下,FST 數據量大小差異也很大,在全文檢索的字段較多時,仍然存在 FST 內存過高的問題。
由此我們可以看出,簡單的降低 FST 的堆內存使用量,並不是一個普適性的方案,需要更為通用、徹底限制住 FST 總大小的方案。
解決方案二: 將 FST 從堆內存(OnHeap)移到堆外內存(OffHeap)

將 FST 從堆內存(OnHeap)移到堆外內存(OffHeap),幾乎可以完全釋放 FST 在堆內存占據的使用空間,這也是 JAVA 實踐方向上一個普遍使用的方案。對於 JAVA 的堆內存不足,將部分內存移到堆外內存(OffHeap)的問題,ES 社區 和 其他 JAVA 系產品都有相應的解決方案。
1.ES 社區方案:
該方案是將 FST 從堆內存中剔除, 直接交由 MMAP 管理。FST 在磁盤上也是有對應的持久化文件的,Lucene 的 .tip 文件,該方案每次查詢時直接通過 MMap 讀取 .tip 文件,通過文件系統緩存 FST 數據。
- 優點:這種方實現簡單,代碼改動量小
- 缺點:
- 我們早期也試用過這種方式實現,但是由於 MMAP 屬於 page cache 可能被系統回收掉。而且 ES 的大查詢也會使用大量的系統緩存導致 FST 占用的內存被沖掉,瞬間產生較多的讀盤操作,從而帶來性能的 N 倍損耗,容易產生查詢毛刺。特別是在 SATA 盤上,嚴重時查詢時延有 10 倍的毛刺。
- Lucene 8.x 、ES 7.x 后才支持該功能,存量的 6.x 用戶無法使用。
2.HBase 方案
HBase的方案是,在堆外搭建一個Cache,將其一部分堆內存(Bucket Cache,Data Block 緩存)移到堆外,釋放堆內內存。
- 優點:數據緩存放在堆外,釋放大量堆內內存
- 缺點:
- 淘汰策略完全依賴 LRU 策略
- 只是把數據緩存放置在堆外,索引的緩存還在堆內
3.Tencent ES 方案
我們的方案總體上接近HBase的方案,相比之下:
- 優點:
- 相比於 ES 社區方案,我們堆外的 Cache 保證 FST 的內存空間不受系統影響。
- 相比於 HBase 方案,我們實現了更精准的數據淘汰策略,提高了內存使用率。也通過多級 Cache 解決性能問題,所以我們敢於把索引放置在堆外。
實現:全鏈路 0 拷貝 FST OffHeap Cache
下面通過將由淺入深地向大家介紹我們實現 FST OffHeap 的過程,及其中碰見的問題和解決方案。
總體架構

在實現 OffHeap 方案的初期,我們的架構如上圖所示。
先來看下源生邏輯是怎樣訪問 FST 的:
- 數據寫入:ES 的一次 Refresh / Merge 動作,會生成一個新的 Lucene Segment,相應的在磁盤上生成該 Segment 對應的各種數據文件。其中 .tip 文件里面存儲的就是該 Segment 各個字段的 FST 信息。在生成 .tip 文件后,Lucene 也會將每個字段( Field )的 FST 數據解析后,拷貝至該 Field 在 OnHeap 內存中的對象里,作為一個成員變量永駐內存,直到該 Segment 被刪除 ( Index被刪除、Segment Merge 時 )。
- 數據查詢:查詢時,直接訪問 OnHeap 的 FST 。
再來看下優化后的 Tencent ES 是怎樣訪問 FST 的:
- 數據寫入: 在 OffHeap 內存放置一個 LRU Cache,在生成新的 Segment 時,不再將 .tip 中的 FST 數據拷貝至 OffHeap LRU Cache。將其對應的 Key 放置在 OnHeap 的 Field 中,不再將 FST 拷貝至 OnHeap 的 Field 中。這樣就把 FST 從 OnHeap 移到了 OffHeap。
- 數據查詢:查詢時,通過 OnHeap Field 中保存的 Key,去 OffHeap LRU Cache 中查詢 FST 數據,將其拷貝至 OnHeap后,做查詢操作。若 Cache Miss ,則從磁盤的 .tip 文件中的相應位置讀取該 Field 對應的 FST 做查詢,同時將其放置到 OffHeap LRU Cache 中。
將兩種方案做個對比,如下表所示:

那么可以總結出 Tencent ES 優化后的 FST 訪問邏輯的優勢和劣勢:
- 優勢:在 OnHeap 我們用 100B 左右的 Key 置換 MB 級別的 FST,大大降低的內存占用量,使得單節點最大支持的磁盤數據量有了 5 倍以上的提升。
- 劣勢:FST 在每次查詢時都要從 OffHeap LRU Cache 拷貝至 OnHeap,相比於源生邏輯直接訪問 OnHeap 的 FST ,讀寫都多了拷貝的動作,造成高並發讀寫時有 20%+ 的性能損耗。
所以,我們要對 OffHeap LRU Cache 的讀寫路徑做優化,減少 Copy 次數,提升讀寫性能。
具體的實現方案是全鏈路零拷貝 OffHeap FST 訪問邏輯。
全鏈路零拷貝 OffHeap FST 訪問邏輯
ES 源生邏輯訪問 FST 只支持堆內的操作,怎樣做到讓它能直接訪問堆外的數據呢?
為此,我們做了兩方面優化:

- OffHeap LRU Cache: 改造讀數據邏輯,Cache 只返回數據地址,封裝為一個 Buffer,堆內只存數據地址,這樣就把 FST 的訪問從先拷貝至 OnHeap 再訪問優化為直讀 OffHeap 內存。
- ES:重構 FST 讀寫邏輯,實現 FST 訪問直讀 OffHeap 內存:
- FST 抽象為一個 FST Buffer,對外提供 FST 形式的各種訪問接口。內部實現按 FST 的數據結構讀取 OffHeap Buffer的邏輯,作為訪問 OffHeap FST 的代理。
- 將 ES 訪問 FST 的所有鏈路全部改造為 FST Buffer 接口的形式,優化 FST 的讀寫路徑如下所示:

經過上述優化,把 FST 的數據訪問由 1 次 Copy 優化為 0 Copy,實現了全鏈路零拷貝 OffHeap FST 訪問邏輯。同時也將 FST 的數據寫入從 2 次 Copy 優化為 1 次 Copy。讀寫性能損耗從 20%+ 下降至 7%。
雖然這樣性能影響已經比較小了,但我們還是想挑戰下自己,能否將性能優化到極致呢?
多級 Cache 將性能優化到極致
要進一步優化性能,需要搞清楚一個問題:7% 的性能損耗在哪里?
Perf分析后發現,Hot 堆棧是 OffHeap Cache 計算Hash、校驗 Key 等邏輯。為什么會有頻繁讀 Cache 的操作呢?我們分析 Lucene 的源碼發現,在高並發讀寫時,一次讀寫入上千條數據,則會有讀 Cache 上千次。例如,一個 bulk update 寫入 3000條數據,3 分片,每個分片大概有 1000 條數據 update 操作,那么就有 1000 次讀 Cache 的邏輯。而這 1000 次讀 Cache,基本上是讀的同一個 Key (_id 對應的 Key),能否做到讓這 1000 次查詢的 Key,稍微緩存一會,防止那么多次讀 Cache 的操作呢?
我們的優化方案是:OnHeap + OffHeap 的兩級 Cache 架構,降低 OffHeap Cache 訪問頻率。而堆內的 Cache 一定要輕量,最少的占用 OnHeap 內存,否則就違背了我們要將 FST 從 OnHeap 移出去的初衷。所以,我們最終選用堆內的弱引用機制( WeakRefrence )來緩存 OffHeap FST 的指針,作為 OnHeap 的輕量級 Cache,利用 JVM 的 GC 自動釋放無效的弱引用,同時堆外內存。

相比於直接設置一個 OnHeap Cache,弱引用有占用內存小,避免拷貝等優勢。

這樣我們訪問 FST 的邏輯,會先查詢堆內的各個查詢共享的 WeakRefrence,當其已經被釋放時,才會訪問 OffHeap Cache。這樣就大大降低了 OffHeap Cache 的訪問頻率。

這里的挑戰是,兩級Cache,key的關聯釋放問題。JVM GC時會銷毀 WeakRefrence 對應的 OnHeap 對象,但 Java 無析構函數,無法自動釋放堆外指針。而我們期望,在堆內的 WeakRefrence 釋放時,同時釋放堆外指針,從而對 OffHeap Cache 的 Key 的引用計數減一,使其可以根據 LRU 規則自動回收無效的 FST 數據。
深入分析 JAVA 垃圾回收、弱引用機制后,發現可以通過注冊一個 WeakRefrence Queue,在 WeakRefrence 釋放前,可以捕獲到它。進而改造 WeakRefrence的數據結構,使其在被捕獲后,對 OffHeap Cahe 的 Key 引用計數減一,然后才被回收。

經過上述 OnHeap WeakRefrence + OffHeap LRU Cache 兩級 Cache 架構的優化后,高並發讀寫性能基本與源生邏輯持平。
其他優化點
除了上述的性能優化外,Tencent ES 的 FST OffHeap 還做了一些其他優化:
- 精准控制 Cache 淘汰策略,內存高效使用:
- LSM Tree 底層文件合並過程,及時淘汰無效數據
- 字段粒度 Cache 控制: 有去重需求:主鍵(_id )寫入 Cache,提升寫入性能 無去重需求:主鍵(_id )不寫入 Cache,降低內存成本【20%-40%】
- CAS並發控制:解決Cache Miss后,並發讀文件的IO放大問題
- 不停服動態調整 Cache 大小:用戶可根據業務情況,在不停服的情況下隨時調整Cache大小
- 不停服動態開關 OffHeap Cache :
- 用戶按需開關 OffHeap Cache 功能
- 存量集群上線部署兼容性
優化效果
最后來看下我們層層優化后,最終的效果:
壓測效果
通過 ES 官方 Benchmark 工具 ES Rally 壓測,結果如下:

線上效果
根據線上集群的實際運行效果看,當開啟OffHeap功能后,集群整體平均JVM的內存使用率從 70%+ 下降 至 30% 左右。

Tencent ES 將持續地進行高可用、高性能、低成本等全方位優化:可用性方面,將提升 ES 的故障自愈能力、故障自動分析診斷,達到零接觸運維的目標;高性能方面,將進一步提升 ES 的海量數據實時分析能力;低成本方面,將提供存儲與計算分離的能力,基於騰訊自研的共享文件系統 CFS,進一步縮減成本。
最后,歡迎各位對 ES 內核技術有興趣的同學掃描下方的二維碼與我們展開交流,同時也歡迎大家在騰訊雲體驗 CES 雲服務。