FlinkSQL功能解密系列–AysncI/O

巴蜀真人發表於2018-02-08

背景

Async I/O 是阿里巴巴貢獻給社群的一個呼聲非常高的特性,於1.2版本引入。主要目的是為了解決與外部系統互動時網路延遲成為了系統瓶頸的問題。

流計算系統中經常需要與外部系統進行互動,比如需要查詢外部資料庫以關聯上使用者的額外資訊。通常,我們的實現方式是向資料庫傳送使用者a的查詢請求,然後等待結果返回,在這之前,我們無法傳送使用者b的查詢請求。這是一種同步訪問的模式,如下圖左邊所示。

圖片來自官方文件

圖中棕色的長條表示等待時間,可以發現網路等待時間極大地阻礙了吞吐和延遲。為了解決同步訪問的問題,非同步模式可以併發地處理多個請求和回覆。也就是說,你可以連續地向資料庫傳送使用者abc等的請求,與此同時,哪個請求的回覆先返回了就處理哪個回覆,從而連續的請求之間不需要阻塞等待,如上圖右邊所示。這也正是 Async I/O 的實現原理。

Async I/O

使用 Async I/O 的前提是需要一個支援非同步請求的客戶端。當然,沒有非同步請求客戶端的話也可以將同步客戶端丟到執行緒池中執行作為非同步客戶端。Flink 提供了非常簡潔的API,讓使用者只需要關注業務邏輯,一些髒活累活比如訊息順序性和一致性保證都由框架處理了,多麼棒的事情!

使用方式如下方程式碼片段所示(來自官網文件):

/** `AsyncFunction` 的一個實現,向資料庫傳送非同步請求並設定回撥 */
class AsyncDatabaseRequest extends AsyncFunction[String, (String, String)] {

    /** 可以非同步請求的特定資料庫的客戶端 */
    lazy val client: DatabaseClient = new DatabaseClient(host, post, credentials)

    /** future 的回撥的執行上下文(當前執行緒) */
    implicit lazy val executor: ExecutionContext = ExecutionContext.fromExecutor(Executors.directExecutor())

    override def asyncInvoke(str: String, asyncCollector: AsyncCollector[(String, String)]): Unit = {

        // 發起一個非同步請求,返回結果的 future
        val resultFuture: Future[String] = client.query(str)

        // 設定請求完成時的回撥: 將結果傳遞給 collector
        resultFuture.onSuccess {
            case result: String => asyncCollector.collect(Iterable((str, result)));
        }
    }
}

// 建立一個原始的流
val stream: DataStream[String] = ...

// 新增一個 async I/O 的轉換
val resultStream: DataStream[(String, String)] =
    AsyncDataStream.(un)orderedWait(
        stream, new AsyncDatabaseRequest(),
        1000, TimeUnit.MILLISECONDS, // 超時時間
        100)    // 進行中的非同步請求的最大數量

AsyncDataStream 有兩個靜態方法,orderedWait 和 unorderedWait,對應了兩種輸出模式:有序和無序。

  • 有序:訊息的傳送順序與接受到的順序相同(包括 watermark ),也就是先進先出。
  • 無序:
    • 在 ProcessingTime 的情況下,完全無序,先返回的結果先傳送。
    • 在 EventTime 的情況下,watermark 不能超越訊息,訊息也不能超越 watermark,也就是說 watermark 定義的順序的邊界。在兩個 watermark 之間的訊息的傳送是無序的,但是在watermark之後的訊息不能先於該watermark之前的訊息傳送。

原理實現

AsyncDataStream.(un)orderedWait 的主要工作就是建立了一個 AsyncWaitOperatorAsyncWaitOperator 是支援非同步 IO 訪問的運算元實現,該運算元會執行 AsyncFunction 並處理非同步返回的結果,其內部原理如下圖所示。

如圖所示,AsyncWaitOperator 主要由兩部分組成:StreamElementQueue 和 Emitter。StreamElementQueue 是一個 Promise 佇列,所謂 Promise 是一種非同步抽象表示將來會有一個值(參考 Scala Promise 瞭解更多),這個佇列是未完成的 Promise 佇列,也就是進行中的請求佇列。Emitter 是一個單獨的執行緒,負責傳送訊息(收到的非同步回覆)給下游。

圖中E5表示進入該運算元的第五個元素(”Element-5″),在執行過程中首先會將其包裝成一個 “Promise” P5,然後將P5放入佇列。最後呼叫 AsyncFunction 的 ayncInvoke 方法,該方法會向外部服務發起一個非同步的請求,並註冊回撥。該回撥會在非同步請求成功返回時呼叫 AsyncCollector.collect 方法將返回的結果交給框架處理。實際上 AsyncCollector是一個 Promise ,也就是 P5,在呼叫 collect 的時候會標記 Promise 為完成狀態,並通知 Emitter 執行緒有完成的訊息可以傳送了。Emitter 就會從佇列中拉取完成的 Promise ,並從 Promise 中取出訊息傳送給下游。

訊息的順序性

上文提到 Async I/O 提供了兩種輸出模式。其實細分有三種模式: 有序,ProcessingTime 無序,EventTime 無序。Flink 使用佇列來實現不同的輸出模式,並抽象出一個佇列的介面(StreamElementQueue),這種分層設計使得AsyncWaitOperatorEmitter不用關心訊息的順序問題。StreamElementQueue有兩種具體實現,分別是 OrderedStreamElementQueue 和 UnorderedStreamElementQueueUnorderedStreamElementQueue 比較有意思,它使用了一套邏輯巧妙地實現完全無序和 EventTime 無序。

有序

有序比較簡單,使用一個佇列就能實現。所有新進入該運算元的元素(包括 watermark),都會包裝成 Promise 並按到達順序放入該佇列。如下圖所示,儘管P4的結果先返回,但並不會傳送,只有 P1 (隊首)的結果返回了才會觸發 Emitter 拉取隊首元素進行傳送。

ProcessingTime 無序

ProcessingTime 無序也比較簡單,因為沒有 watermark,不需要協調 watermark 與訊息的順序性,所以使用兩個佇列就能實現,一個 uncompletedQueue 一個 completedQueue。所有新進入該運算元的元素,同樣的包裝成 Promise 並放入 uncompletedQueue 佇列,當uncompletedQueue佇列中任意的Promise返回了資料,則將該 Promise 移到 completedQueue佇列中,並通知 Emitter 消費。如下圖所示:

EventTime 無序

EventTime 無序類似於有序與 ProcessingTime 無序的結合體。因為有 watermark,需要協調 watermark 與訊息之間的順序性,所以uncompletedQueue中存放的元素從原先的 Promise 變成了 Promise 集合。如果進入運算元的是訊息元素,則會包裝成 Promise 放入隊尾的集合中。如果進入運算元的是 watermark,也會包裝成 Promise 並放到一個獨立的集合中,再將該集合加入到 uncompletedQueue 隊尾,最後再建立一個空集合加到 uncompletedQueue 隊尾。這樣,watermark 就成了訊息順序的邊界。只有處在隊首的集合中的 Promise 返回了資料,才能將該 Promise 移到 completedQueue 佇列中,由 Emitter 消費發往下游。只有隊首集合空了,才能處理第二個集合。這樣就保證了當且僅當某個 watermark 之前所有的訊息都已經被髮送了,該 watermark 才能被髮送。過程如下圖所示:

快照與恢復

分散式快照機制是為了保證狀態的一致性。我們需要分析哪些狀態是需要快照的,哪些是不需要的。首先,已經完成回撥並且已經發往下游的元素是不需要快照的。否則,會導致重發,那就不是 exactly-once 了。而已經完成回撥且未發往下游的元素,加上未完成回撥的元素,就是上述佇列中的所有元素。

所以快照的邏輯也非常簡單,(1)清空原有的狀態儲存,(2)遍歷佇列中的所有 Promise,從中取出 StreamElement(訊息或 watermark)並放入狀態儲存中,(3)執行快照操作。

恢復的時候,從快照中讀取所有的元素全部再處理一次,當然包括之前已完成回撥的元素。所以在失敗恢復後,會有元素重複請求外部服務,但是每個回撥的結果只會被髮往下游一次。

本文的原理和實現分析基於 Flink 1.3 版本。

參考資料


相關文章