最近將公司的線上業務遷移到Storm叢集上,上線後遇到低峰期CPU耗費嚴重的情況。在解決問題的過程中深入瞭解了storm的內部實現原理,並且解決了一個storm0.9-0.10版本一直存在的嚴重bug,目前代碼已經合并到了storm新版本中,在這篇文章裡會介紹這個問題出現的情境、分析思路、解決的方式和一些個人的收穫。背景
Storm是Twitter開源的一個大資料處理架構,專註於流式資料的處理。Storm通過建立拓撲結構(Topology)來轉換資料流。和Hadoop的作業(Job)不同,Topology會持續轉換資料,除非被叢集關閉。
可以看出Topology是由不同組件(Component)串/並聯形成的有向圖。資料元組(Tuple)會在Component之間通過資料流的形式進行有向傳遞。Component有兩種
目前業界主要在離線或者對即時性要求不高業務中使用Storm。隨著Storm版本的更迭,可靠性和即時性在逐漸增強,已經有運行線上業務的能力。因此我們嘗試將一些即時性要求在百毫秒級的線上業務遷入Storm叢集。
- 用Top看了下CPU佔用,系統調用佔用了70%左右。再用wtool對Storm的背景工作處理序進行分析,找到了CPU佔用最高的線程
java.lang.Thread.State: TIMED_WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x0000000640a248f8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2163) at com.lmax.disruptor.BlockingWaitStrategy.waitFor(BlockingWaitStrategy.java:87) at com.lmax.disruptor.ProcessingSequenceBarrier.waitFor(ProcessingSequenceBarrier.java:54) at backtype.storm.utils.DisruptorQueue.consumeBatchWhenAvailable(DisruptorQueue.java:97) at backtype.storm.disruptor$consume_batch_when_available.invoke(disruptor.clj:80) at backtype.storm.daemon.executor$fn__3441$fn__3453$fn__3500.invoke(executor.clj:748) at backtype.storm.util$async_loop$fn__464.invoke(util.clj:463) at clojure.lang.AFn.run(AFn.java:24) at java.lang.Thread.run(Thread.java:745)
我們可以看到這些線程都在訊號量上等待。調用的來源是disruptor$consume_batch_when_available。
disruptor是Storm內部訊息隊列的封裝。所以先瞭解了一下Storm內部的訊息傳輸機制。
(圖片來源Understanding the Internal Message Buffers of Storm)
Storm的工作節點稱為Worker(其實就是一個JVM進程)。不同Worker之間通過Netty(舊版Storm使用ZeroMQ)進行通訊。
每個Worker內部包含一組Executor。Strom會為拓撲中的每個Component都分配一個Executor。在實際的資料處理流程中,資料以訊息的形式在Executor之間流轉。Executor會迴圈調用綁定的Component的處理方法來處理收到的訊息。
Executor之間的訊息傳輸使用隊列作為訊息管道。Storm會給每個Executor分配兩個隊列和兩個處理線程。
- 背景工作執行緒:讀取接收隊列,對訊息進行處理,如果產生新的訊息,會寫入發送隊列
- 發送線程:讀取發送隊列,將訊息發送其他Executor
當Executor的發送線程發送訊息時,會判斷目標Executor是否在同一Worker內,如果是,則直接將訊息寫入目標Executor的接收隊列,如果不是,則將訊息寫入Worker的傳輸隊列,通過網路發送。
Executor工作/發送線程讀取隊列的代碼如下,這裡會迴圈調用consume-batch-when-available讀取隊列中的訊息,並對訊息進行處理。
(async-loop (fn [] ... (disruptor/consume-batch-when-available receive-queue event-handler) ... ))
- 我們再來看一下consume_batch_when_available這個函數裡做了什麼。
(defn consume-batch-when-available [^DisruptorQueue queue handler] (.consumeBatchWhenAvailable queue handler))
前面提到Storm使用隊列作為訊息管道。Storm作為流式大資料處理架構,對訊息傳輸的效能很敏感,因此使用了高效記憶體隊列Disruptor Queue作為訊息佇列。
Disruptor Queue是LMAX開源的一個無鎖記憶體隊列。內部實現如下。
(圖片來源Disruptor queue Introduction)
Disruptor Queue通過Sequencer來管理隊列,Sequencer內部使用RingBuffer儲存訊息。RingBuffer中訊息的位置使用Sequence表示。隊列的生產消費過程如下
- Sequencer使用一個Cursor來儲存寫入位置。
- 每個Consumer都會維護一個消費位置,並註冊到Sequencer。
- Consumer通過SequenceBarrier和Sequencer進行互動。Consumer每次消費時,SequenceBarrier會比較消費位置和Cursor來判斷是否有可用訊息:如果沒有,會按照設定的策略等待訊息;如果有,則讀取訊息,修改消費位置。
- Producer在寫入前會查看所有消費者的消費位置,在有可用位置時會寫入訊息,更新Cursor。
查看DisruptorQueue.consumeBatchWhenAvailable實現如下
final long nextSequence = _consumer.get() + 1;final long availableSequence = _barrier.waitFor(nextSequence, 10, TimeUnit.MILLISECONDS);if (availableSequence >= nextSequence) { consumeBatchToCursor(availableSequence, handler);}
繼續查看_barrier.waitFor方法
public long waitFor(final long sequence, final long timeout, final TimeUnit units) throws AlertException, InterruptedException { checkAlert(); return waitStrategy.waitFor(sequence, cursorSequence, dependentSequences, this, timeout, units);}
Disruptor Queue為消費者提供了若干種訊息等待策略
- BlockingWaitStrategy:阻塞等待,CPU佔用小,但是會切換線程,延遲較高
- BusySpinWaitStrategy:自旋等待,CPU佔用高,但是無需切換線程,延遲低
- YieldingWaitStrategy:先自旋等待,然後使用Thread.yield()喚醒其他線程,CPU佔用和延遲比較均衡
- SleepingWaitStrategy:先自旋,然後Thread.yield(),最後調用LockSupport.parkNanos(1L),CPU佔用和延遲比較均衡
Storm的預設等待策略為BlockingWaitStrategy。BlockingWaitStrategy的waitFor函數實現如下
if ((availableSequence = cursor.get()) < sequence) { lock.lock(); try { ++numWaiters; while ((availableSequence = cursor.get()) < sequence) { barrier.checkAlert(); if (!processorNotifyCondition.await(timeout, sourceUnit)) { break; } } } finally { --numWaiters; lock.unlock(); }}
BlockingWaitStrategy內部使用訊號量來阻塞Consumer,當await逾時後,Consumer線程會被自動喚醒,繼續迴圈查詢可用訊息。這裡的實現有個BUG,在processorNotifyCondition.await逾時時應該迴圈查詢,但是代碼中實際上跳出了迴圈,直接返回的當前的cursor,
而DisruptorQueue.consumeBatchWhenAvailable方法中可以看到,Storm此處設定逾時為10ms。推測在沒有訊息或者訊息量較少時,Executor在消費隊列時會被阻塞,由於逾時時間很短,背景工作執行緒會頻繁逾時,再加上BlockingWaitStrategy的BUG,consumeBatchWhenAvailable會被頻繁調用,導致CPU佔用飆高。
嘗試將10ms修改成100ms,編譯Storm後重新部署叢集,使用Storm的demo拓撲,將bolt並發度調到1000,修改spout代碼為10s發一條訊息。經測試CPU佔用大幅減少。
再將100ms改成1s,測試CPU佔用基本降為零。
但是隨著調高逾時,測試時並沒有發現訊息處理有延時。繼續查看BlockingWaitStrategy代碼,發現Disruptor Queu的Producer在寫入訊息後會喚醒等待的Consumer。
if (0 != numWaiters){ lock.lock(); try { processorNotifyCondition.signalAll(); } finally { lock.unlock(); }}
這樣,Storm的10ms逾時就很奇怪了,沒有減少訊息延時,反而增加了系統負載。帶著這個疑問查看代碼的上下文,發現在構造DisruptorQueue對象時有這麼一句注釋
;; :block strategy requires using a timeout on waitFor (implemented in DisruptorQueue), as sometimes the consumer stays blocked even when there‘s an item on the queue.(defnk disruptor-queue [^String queue-name buffer-size :claim-strategy :multi-threaded :wait-strategy :block] (DisruptorQueue. queue-name ((CLAIM-STRATEGY claim-strategy) buffer-size) (mk-wait-strategy wait-strategy)))
Storm使用的Disruptor Queue版本為2.10.1。查看Disruptor Queue的change log,發現該版本的BlockingWaitStrategy有潛在的並發問題,可能導致某條訊息在寫入時沒有喚醒等待的消費者。
2.10.2 Released (21-Aug-2012)
- Bug fix, potential race condition in BlockingWaitStrategy.
- Bug fix set initial SequenceGroup value to -1 (Issue #27).
- Deprecate timeout methods that will be removed in version 3.
因此Storm使用了短逾時,這樣在出現並發問題時,沒有被喚醒的消費方也會很快因為逾時重新查詢可用訊息,防止出現訊息延時。
這樣如果直接修改逾時到1000ms,一旦出現並發問題,最壞情況下訊息會延遲1000ms。在權衡效能和延時之後,我們在Storm的設定檔中增加配置項來修改逾時參數。這樣使用者可以自己選擇保證低延時還是低CPU佔用率。
就BlockingWaitStrategy的潛在並發問題諮詢了Disruptor Queue的作者,得知2.10.4版本已經修複了這個並發問題(Race condition in 2.10.1 release
)。
將Storm依賴升級到此版本。但是對Disruptor Queue的2.10.1做了並發測試,無法複現這個並發問題,因此也無法確定2.10.4是否徹底修複。謹慎起見,在升級依賴的同時保留了之前的逾時配置項,並將預設逾時調整為1000ms。經測試,在叢集空閑時CPU佔用正常,並且壓測也沒有出現訊息延時。