twitter storm 原始碼走讀之5 -- worker程式內部訊息傳遞處理和資料結構分析

徽滬一郎發表於2013-12-17

歡迎轉載,轉載請註明出處,徽滬一郎。

本文從外部訊息在worker程式內部的轉化,傳遞及處理過程入手,一步步分析在worker-data中的資料項存在的原因和意義。試圖從程式碼實現的角度來回答,如果是從頭開始實現worker的話,該如何來定義訊息介面,如何實現各自介面上的訊息處理。

Topology到Worker的對映關係

Topology由Spout,Bolt組成,其邏輯關係大體如下圖所示。

無論是Spout或Bolt的處理邏輯都需要在程式或執行緒內執行,那麼它們與程式及執行緒間的對映關係又是如何呢。有關這個問題,Understanding the Parallelism of a Storm Topology 一文作了很好的總結,現重複一下其要點。

  1. worker是程式,executor對應於執行緒,spout或bolt是一個個的task
  2. 同一個worker只會執行同一個topology相關的task
  3. 在同一個executor中可以執行多個同型別的task, 即在同一個executor中,要麼全部是bolt類的task,要麼全部是spout類的task
  4. 執行的時候,spout和bolt需要被包裝成一個又一個task

worker,executor, task三者之間的關係可以用下圖表示

小結一下,Worker=Process, Executor=Thread, Task=Spout or Bolt.

每一個executor使用的是actor pattern,high level的處理邏輯如下圖所示

外部訊息的接收和處理

在原始碼走讀之四一文中總結了worker程式內的各種型別的thread,也即executor,這個等同於定義了程式內部和程式間的介面型別。那麼這些介面上的訊息在具體流傳和處理過程中需要定義哪些資料結構,針對這些資料結構,又要做哪些必要的處理呢?

換句話說,就是為什麼在worker.clj中有哪些資料和函式存在,不這樣做,可以不?

先圖示一下,外部訊息處理的大概流程。

注:圈起來的數字表示訊息轉換和處理的序列。

步驟一

監聽埠準備就緒,接收執行緒在收到外部的訊息後,面臨的問題就是如何確定由哪個task來處理該訊息。接收到的tuple中含有task-id,根據task-id可以知道執行該task的executor,executor中有receive-message-queue即(incoming queue)來存放外部的tuple. 定義的資料結構需要反映這個轉換過程task-id->executor->receive-queue-map.

那麼在worker-data中哪些資料項與這個過程相關呢

  1. :port
  2. :executor-receive-queue-map
  3. :short-executor-receive-queue-map
  4. :task->short-executor
  5. :transfer-local-fn

transfer-local-fn將資料從接收執行緒傳送到spout或bolt所在的executor執行緒。

步驟二

接下來資料會被傳遞到executor,於是又牽涉到executor的資料結構問題。executor-data由函式mk-executor-data建立,其內容與worker-data比較起來相對較少。

executor收到tuple之後,第一步需要進行反序列化,storm中使用kyro來進行序列化和反序列化,這也是為什麼在executor中有該資料項的原因。

executor中與步驟2相關的資料項

  1. :type executor-type
  2. :receive-queue
  3. :deserializer (executor-data中的資料項)

步驟三:

步驟2處理結束,會產生相應的tuple傳送到外部。這個過程需要多解釋一下,首先tuple不是直接傳送給worker的transfer-thread(負責向其它程式傳送訊息),而是傳送給send-handler執行緒,每一個executor在建立的時候最起碼會有兩個執行緒被建立,一個用於執行bolt或spout的處理邏輯,另一個用以負責快取bolt或spout產生的對外傳送的tuple。

一旦snd-hander中的tuple數量達到閥值,這些被快取的tuple會一次性傳送給worker級別的transfer-thread.

executor中與步驟3相關的資料項

  1. :transfer-fn (mk-executor-transfer-fn batch-transfer->worker)
  2. :batch-transfer-queue

在步驟3中生成outgoing的tuple,tuple生成的時候需要回答兩個基本問題

  1. tuple中含有哪些欄位 --   該問題的解答由spout或bolt中的declareOutFields來解決
  2. 由哪個node+port來接收該tuple -- 由grouping來解決,這個時候就可以看出為什麼需要task這一層的邏輯抽象了,有關grouping的詳細解釋,請參考fxjwind撰寫的Storm-原始碼分析-Streaming Grouping (backtype.storm.daemon.executor)

步驟四:

處理邏輯很簡單,先將資料快取,然後在達到閥值之後,一起傳送給transfer-thread.

start-batch-transfer->worker-handler

(defn start-batch-transfer->worker-handler! [worker executor-data]
  (let [worker-transfer-fn (:transfer-fn worker)
        cached-emit (MutableObject. (ArrayList.))
        storm-conf (:storm-conf executor-data)
        serializer (KryoTupleSerializer. storm-conf (:worker-context executor-data)) 
        ]
    (disruptor/consume-loop*
      (:batch-transfer-queue executor-data)
      (disruptor/handler [o seq-id batch-end?]
        (let [^ArrayList alist (.getObject cached-emit)]
          (.add alist o)
          (when batch-end?
            (worker-transfer-fn serializer alist)
            (.setObject cached-emit (ArrayList.))
            )))
      :kill-fn (:report-error-and-die executor-data))))

worker-transfer-fn是worker中的transfer-fn,由mk-transfer-fn生成。

(defn mk-transfer-fn [worker]
  (let [local-tasks (-> worker :task-ids set)
        local-transfer (:transfer-local-fn worker)
        ^DisruptorQueue transfer-queue (:transfer-queue worker)]
    (fn [^KryoTupleSerializer serializer tuple-batch]
      (let [local (ArrayList.)
            remote (ArrayList.)]
        (fast-list-iter [[task tuple :as pair] tuple-batch]
          (if (local-tasks task)
            (.add local pair)
            (.add remote pair)
            ))
        (local-transfer local)
        ;; not using map because the lazy seq shows up in perf profiles
        (let [serialized-pairs (fast-list-for [[task ^TupleImpl tuple] remote] [task (.serialize serializer tuple)])]
          (disruptor/publish transfer-queue serialized-pairs)
          )))))

步驟五:

處理函式mk-transfer-tuples-handler,主要進行序列化,將序列化後的資料傳送給目的地址。

(defn mk-transfer-tuples-handler [worker]
  (let [^DisruptorQueue transfer-queue (:transfer-queue worker)
        drainer (ArrayList.)
        node+port->socket (:cached-node+port->socket worker)
        task->node+port (:cached-task->node+port worker)
        endpoint-socket-lock (:endpoint-socket-lock worker)
        ]
    (disruptor/clojure-handler
      (fn [packets _ batch-end?]
        (.addAll drainer packets)
        (when batch-end?
          (read-locked endpoint-socket-lock
            (let [node+port->socket @node+port->socket
                  task->node+port @task->node+port]
              ;; consider doing some automatic batching here (would need to not be serialized at this point to remove per-tuple overhead)
              ;; try using multipart messages ... first sort the tuples by the target node (without changing the local ordering)
            
              (fast-list-iter [[task ser-tuple] drainer]
                ;; TODO: consider write a batch of tuples here to every target worker  
                ;; group by node+port, do multipart send              
                (let [node-port (get task->node+port task)]
                  (when node-port
                    (.send ^IConnection (get node+port->socket node-port) task ser-tuple))
                    ))))
          (.clear drainer))))))

tuple傳送的時候需要用到connection,但目前只知道task-id,所以在worker中需要儲存task-id到node+port的對映,node+port與outgoing connections之間的對映。

worker中與步驟5相關的資料項:

  1. :cached-node+port->socket
  2. :cached-task->node+port
  3. :component->stream->fields
  4. :component->sorted-tasks
  5. :endpoint-socket-lock
  6. :transfer-queue (執行緒內部的訊息佇列)
  7. :task->component

其它的資料項

上述五個步驟並沒有涵蓋worker-data所有的資料項,那麼其它的資料項歸一歸類,大體如下

timer相關,timer相關的資料項包括timer及其對應的處理控制程式碼

  1. :heartbeat-timer
  2. :refresh-connection-timer
  3. :refresh-active-timer
  4. :executor-heartbeat-timer
  5. :user-timer

zk相關

  1. :storm-cluster-state
  2. :storm-active-atom
  3. :cluster-state

配置相關

  1. :conf
  2. :mq-context 在transport layer是使用zmq還是netty

Assignment相關

  1. :storm-id
  2. :assigment-id
  3. :worker-id
  4. :executors
  5. :task-ids
  6. :storm-conf
  7. :topology
  8. :system-topology

程式關閉相關

  1. :suicide-fn

其它的其它

  1. :uptime 執行時間,統計用
  2. :default-shared-resources 執行緒池
  3. :user-shared-resources 未啟用

 小結

設計的時候,一定是先畫出一個大概的藍圖,然後逐步的細化並加以實現。具體來說,步驟如下

  1. manifest 定義主要的功能
  2. draw skeleton 畫出實現草圖,定義主要的介面
  3. discussion 與團隊討論
  4. data structures 資料結構
  5. function 函式實現
  6. testing 測試

相關文章