10G mysql binlog重放並傳輸到另一臺服務器執行,阿里中間件大賽

轉載自:https://tianchi.aliyun.com/forum/new_articleDetail.html?spm=5176.11165310.0.0.90a57f61Sy5xTQ&raceId=231600&postsId=2035

這個冠軍的方案確實贊,10G的mysql binlog重放並傳輸只用了2秒!

總決賽冠軍隊伍 作死小分隊 比賽攻略

 

決賽答辯PPT已上傳!點這裏查看。

賽題分析

給定一批固定的增量數據變更信息(存放在Server端),程序需要單線程順序讀取文件,進行數據重放計算,然後將最終結果輸出到給定的目標文件(在Client端)中。

增量數據的變更主要包含數據庫的Insert/Update/Delete三種類型的數據。主鍵可能發生變更。

爲了降低數據在網絡中的傳輸開銷,我們的設計是在Server端完成數據的重放計算,再將結果發送到Client端,寫入結果文件中。

比賽採用16核機器運行程序。顯然,我們需要設計一個並行算法,充分利用多核CPU。

解題思路

由於數據的重放計算在Server端,Client主要負責接收結果和寫入文件,因此核心的算法都在Server端。本節將首先給出Server端算法的系統架構,之後具體介紹多線程算法的細節與實現。

系統架構

爲了最大限度利用多核CPU的計算能力,我們將整個重放過程按照流水線的方式分成三個部分:

Infrastructure.png

Fig.1 系統架構圖

1) Reader 按照賽題要求,負責單線程讀取文件。事實上,由於單線程的內存拷貝速度已經跟不上流水線速度,我們用 MappedByteBuffer 將文件按照16M的大小切分成多個的分段(Segment)映射到內存,交由Parser處理。

爲防止最後的一個 Task 被從中間切成兩段,Segment 除了 16MB 數據以外還多保留了 128KB 的 Margin。

2) Parser 負責從 Segment 中初步 Parse 出 Task的操作類型 (Insert/Update/Delete) 以及主鍵值。根據主鍵值,將 Task 分成 N 個 Bucket 分別交給 Worker 處理。 Parser支持多線程執行,可伸縮。

如果是 UpdatePK, 則產生 UpdatePKSrc 和 UpdatePKDst 兩個 task 以及對應的 Promise 對象,按照各自的 PK , 分別發給對應的 Bucket (Promise對象及Bucket之間如何協作處理主鍵更新問題下文會詳細說明)。

Parser 產生的 task 不是一個一個發給 Worker/Bucket 的。這樣吞吐量上不去。更好的方案是按批發送。如圖所示,每個 Segment 對於每個 Bucket 產生一批 tasks,這些 tasks 被附加到對應的 Segment 上,供 Worker 讀取。

容易誤解的一點:Parser 並不是 Parse 全部的 key-value,這不夠高效。Parser 只負責第一列也就是主鍵,剩下的部分,通過把當前的 offset 傳給 Worker,從而交給 Worker 來處理。

3) Worker 和 Bucket 是一一對應關係,Worker 根據 Parser 產生的結果,在自己的 Bucket 上依次重放 task,多線程執行,可伸縮。

當所有 Worker 處理完最後的 task 時,意味着回放完成,可以準備輸出了。輸出其實是 merge K 個有序 stream 的經典問題,可以用堆來高效的解決。

上述的流水線非常適合用 RingBuffer 實現,原因如下:

* RingBuffer 相比 BlockingQueue 速度更快,且本身不產生新對象,減少 GC

* RingBuffer 能夠方便地爲 slot 靜態分配內存空間

這裏我們用了 Disruptor 框架,它是一個高性能的線程間消息通訊的庫,底層用 RingBuffer 實現。它不僅能取代 ArrayBlockingQueue,功能上還要豐富的多。對於本題的架構,只需要一個 RingBuffer 就能完成。

以下的示意圖是和 Fig.1 是 完全等價 的:

RingBufferPipeline.png

Fig.2 RingBuffer流水線效果圖

圖中白色部分爲當前RingBuffer內的數據。圖中 Worker 2 和 Worker 0 都在處理 Segment 103 中對應自己 Bucket 的 task。

並行算法

爲了發揮並行性能,在每個Parser中我們將數據表按 hash(PK) 切分成 N 個 Bucket, 每個Bucket都由一個獨立的Worker線程完成重放計算。

HashBucket.png

Fig.3 Hash 分桶示意圖

對於Insert,Delete 和一般的 Update 事件,只要分配到對應的 Bucket 去做就可以了;唯獨 UpdatePK(更新主鍵)事件例外,必須要 Bucket 間協作才能保證正確地把數據移動過去。

何謂“Bucket 間協作”?

一行數據被“拿走”的時候,如果還存在對該行的操作沒完成,那這些修改就丟失了!所以,必須要保證一行數據被“拿走”前,所有的修改都已經 apply 到上面。反之同理,必須要“拿到”數據以後,才能把後續的操作 apply 上去。

也就是說,我們要讓這兩個線程在這一點(UpdatePK 這條操作)上同步纔行。

BucketUpdatePK.png

Fig.4 UpdatePK 對 Bucket 的影響,接收方要等待發送方也處理到這個task

很自然的想到,可以用 CountDownLatch 來阻塞 UpdatePK 的接收方(數據被移到此 Bucket),直到 UpdatePK 的發送方發出這行數據,它纔拿到數據、接着運行。然而,當 UpdatePK 操作較爲密集的時候,這個解決方案非常低效!

另一種思路是內存中維護一張主鍵變更表,記錄主鍵的變更歷史,將所有 UpdatePK 後新主鍵的所有操作都分配到舊主鍵所在的Bucket中。然而每個task分配時都需要從主鍵變更表中查找對應的Bucket,並且Parser也無法並行執行,同樣十分低效。

有什麼更高效的解決方法呢?

針對這一問題, 最終設計出下文的並行算法, 本算法的核心在於通過 Promise 對象,解決了 Update 主鍵這一難題,從而使得數據表的各個 Bucket 的線程能夠無鎖、高效地協作。

所謂 Promise,是借鑑 Future/Promise 異步編程範式設計的一個數據結構。實現很簡單,只要封裝一個 volatile 的變量,如下所示(實際代碼實現更復雜,僅爲示例):

final class Promise {
    volatile T data;

    public boolean ready() { return data != null; }
    public T get() { return data; }
    public void set(T data) { this.data = data; }
}

Promise 在 Parser parse 到 UpdatePK 事件時產生,發送方和接收方都持有它的引用:發送方獲得 UpdatePKSrc 任務,只能寫入Promise(set);接收方獲得 UpdatePKDst 任務,只能讀取Promise(get)、以及檢查數據是否 ready。通過 Promise 作爲中間媒介,被操作的數據記錄 data 就能從源 Bucket:hash(srcKey) 移動到目標 Bucket:hash(dstKey)。

Promise.png

Fig.5 Promise與發送方和接收方的關係

相比上一小節提到的 Latch 解決方案,Promise 不是阻塞接收方,而是告訴他:你要的數據還沒準備好。明智的接收方將會先“擱置”這個消息,並把後來遇到的所有對這個 Key 的操作都暫存起來(放在blockedTable中,如圖所示)。一旦某一時刻 Promise.ready() 爲真,就可以把這個 data 放到對應的 Key 上了!暫存的操作也可以那時候再做。

如何從 blockedTable 中高效地找到 promise.ready() 的 task?事實上對於每個發送者(也就是其他bucket),我們只要檢查最早阻塞的那個 task 是否 ready 就可以了。

BlockedTask.png

Fig.6 Task 處理效果圖,暫存被阻塞的Task

對於被 block 的 PK 的操作,會以一個鏈表保存下來。如果不巧操作很多,這個鏈表就會變的很長。一個簡單的改進:如果來的是一個普通 Update 操作,其實可以直接 apply 到上一個操作上的 data 上,例如 A=5 B=6 可以疊加到 PK=3 A=4上, 就變成 PK=3 A=5 B=6,從而避免把 Update 操作追加到鏈表上。

附上關鍵邏輯的僞代碼:

// 當 Parse 到 UpdatePK (SET PK=dstKey WHERE PK=srcKey)
Promise promise = new Promise();
bucket[hash(srcKey)].send(new UpdatePKSrcTask(srcKey, promise, ...));
bucket[hash(dstKey)].send(new UpdatePKDstTask(dstKey, promise, ...));
// 當接收方 Bucket 收到 UpdatePKDstTask task
if (!task.promise.ready()) {
    LinkedList<Task> blockedTasksOnThisKey = new LinkedList<>(); // 存放阻塞的Task
    blockedTasksOnThisKey.add(task);
    blockedTable.put(task.key, blockedTasksOnThisKey); // 暫存,以後再處理
} else {
    table.put(task.key, task.getData()); // 直接處理
}
// 當發送方 Bucket 收到 UpdatePKSrcTask task
task.promise.set(table.remove(key));
// 當接受方發現 blockedTable 中的 task.promise 已經 ready,則取出來處理掉
for (Task task : blockedTasks) {
    applyUnblockedTask(task);
}

如果阻塞的 Tasks 中包含一個 Delete,後面又來了一個 UpdatePKDst,要注意,可能會再次阻塞。

您可能擔心 blockedTable 的查詢增加了單個 Bucket 的計算負擔。實驗表明,由於各個 Bucket 的工作進度差異相差不會很大,blockedTable 的最大 size 也在 25000 以內,遠小於數據表大小,所以這個代價是完全可接受的。

健壯性分析

算法的正確性是最重要的。對於任意的輸入數據集,算法都能保證輸出正確的結果。

可以從理論上證明:本算法可以處理以任何順序出現的 UpdatePK / Update / Delete / Insert 操作,保證重放結束後一定查詢到正確的結果。

其實很簡單,算法保證了所有的操作都在它們可以執行的時候被執行,換句話說,對於一切有互相依賴關係的操作,算法不會破壞它們的先後關係。算法的並行性,是在保證了該前提的情況下做到的。

以下的例子可能幫助您獲得一個感性的認識。

從一個簡單的例子開始。如果遇到如下的序列

Insert PK=1 A=1 B=2
Update A=2 Where PK=1
Update PK=2 Where PK=1
Update A=3 Where PK=2

假設 PK = 1 和 2 分別被 hash 到 Bucket 1 和 Bucket 2,那麼會有如下情況:

Insert PK=1 A=1 B=2    // Bucket 1 新增記錄
Update A=2 Where PK=1  // Bucket 1 更新記錄 
Update PK=2 Where PK=1 // Bucket 1 接到 UpdatePKSrc,移除並把該數據 set 到 promise
                       // Bucket 2 如果拿到了 data,那就成功了;假設它沒拿到,PK=2加入阻塞表
Update A=3 Where PK=2  // Bucket 2 把這條 Update 追加到 PK=2 的操作列表上
                          ...
                       // Bucket 2 等到了 data,Update A=3 也被重放了

舉一個更極端的例子來說明。如果遇到這樣的情況:

Insert PK=1 A=1 B=2
Update A=2 Where PK=1
Update PK=2 Where PK=1
Update PK=3 Where PK=2 // 連續的更新
Update A=3 Where PK=3

算法會做如下處理:

Insert PK=1 A=1 B=2    // Bucket 1 新增記錄
Update A=2 Where PK=1  // Bucket 1 更新記錄 
Update PK=2 Where PK=1 // Bucket 1 接到 UpdatePKSrc,移除並把該數據 set 到 promise
                       // Bucket 2 如果拿到了 data,那就成功了;假設它沒拿到,PK=2加入阻塞表
Update PK=3 Where PK=2 // Bucket 2 發現 2 這個主鍵在阻塞表中,所以本操作也放入阻塞表
                       // Bucket 3 無法拿到 data,所以把 PK=3 加入阻塞表
Update A=3 Where PK=3  // Bucket 3 把這條 Update 追加到 PK=3 的操作列表上
                          ...
                       // Bucket 2 等到了 data,UpdatePK 也被重放了
                          ...
                       // Bucket 3 等到了 data,Update A=3 也被重放了

不妨自己嘗試更多的情況。

 

關於表結構的健壯性, 程序會根據第一次遇到的 Insert log 來確定表結構,包括各個列的名字、類型、主鍵信息等。

 

程序嚴格按照比賽要求。對於數字,支持 long 型正數範圍;對於文本,最長支持 65536 個字符。具體實現參考下文&ldquo;數據存儲&rdquo;一小節。

優勢與創新點

在健壯性的基礎上,本算法還有以下幾點優勢:

完全無鎖(Lock-free),無阻塞(Non-blocking)。在16核CPU的測試場景下,鎖競爭將會導致不小的開銷;而阻塞更不用說,極端情況下可能多線程會退化成協程。(例如 Latch 的解決方案,連續 UpdatePK 就會導致這樣的情況)。本算法完全擯棄了wait()或lock(),而是用 代價極低 的 volatile 實現同步,這是最大的創新點。

可伸縮(Scalability)。除了 Reader 根據題意必須單線程,算法中沒有任何不可伸縮的數據結構,理論上爲線性加速比。若 CPU 核數增加,只要提升 Parser 和 Worker/Bucket 的線程數即可。(一些解決方案用到全局的 KeyMap,導致無法伸縮)

流處理(Streaming)。本算法是一個真正的流處理系統,在真實場景中可以不斷灌入新數據並提供查詢(保證最終一致性)。這也與比賽的初衷一致。

細節實現和優化

上述算法和架構給出了大致的代碼編寫思路。細節上,爲了追求極致的性能,我們還做了各種優化。

原生類型數據結構

Java 的範型對於 primitive type 的數據是嚴重的浪費。比如 Map 是非常低效的,不僅浪費了大量內存,還產生了大量冗餘的 boxing/unboxing。

對此,我們利用 fastutil 和 koloboke 這兩個庫代替 Java 標準庫中範型實現的 HashMap、ArrayList 等數據結構,極大提升了性能。

數據存儲

我們使用 long 數組來存儲每一行數據。

由於列值的類型爲 long 或者 String。對於 long 類型值, 將其解析成 long 數據存儲即可。而對於String 類型的數據, 如果通過將其轉換成 String 存儲, 至少有兩個問題:

  • Encode/decode 造成無謂的性能損耗;
  • 內存開銷很大,對象數量非常多,對 GC 造成巨大壓力。

StringStore 類能夠將 String 類型的列也“變成” long,從而放到 long 數組中。利用中間結果文件,創建一個 MappedByteBuffer, 將字符串 bytes 寫入 MappedByteBuffer 中,並將 position 和 length 用位運算壓縮到一個 long 值中返回。根據這個值即可從 MappedByteBuffer中讀取出字符串數據。

一個優化是:如果字符串的 bytes 的長度小於等於 7: 那麼直接利用 long 裏面的 7 個字節存儲,剩下一個字節存長度,避免了磁盤寫入。附上StringStore類的核心代碼:

// 寫入byte[]類型的數據,範圍long值作爲讀取的索引
public long put(byte[] data, int len) {
    if (len &lt;= 7) {
        long value = unsafe.getLong(data, BYTE_ARRAY_BASE_OFFSET);
        return uint64(len) &lt;&lt; 56 | (value &amp; 0xffffffffffffffL);
    } else {
        long pos = doPut(data, len);
        return 0xffL &lt;&lt; 56 | pos &lt;&lt; 32 | len;
    }
}

// 根據寫入時獲得的long值,讀取相應的數據
// 爲了減少內存中的對象拷貝,直接將結果寫入ByteBuffer中
public static void get(long value, ByteBuffer buf) {
    int h = (int) (value &gt;&gt;&gt; 56);
    if (h != 0xff) {
        buf.putLong(value);
        buf.position(buf.position() - 8 + h);
    } else {
        int pos = (int) ((value &amp; 0xffffff00000000L) &gt;&gt;&gt; 32);
        int len = (int) (value &amp; 0xffffffffL);
        doGet(pos, len, buf);
    }
}

數組池

如果爲每行數據都創建一個 long[],需要頻繁地 new 出大量對象。爲此,我們實現了一個 LongArrayPool 來管理所有的行數據,用 offset 來查找所需的數據。

對象池 + 數組化

以 Segment 中附加的 tasks 爲例,如果每次 new Task() 將產生總計近億個 Task 對象,造成嚴重的 GC 壓力,顯然不可取。

使用對象池可以解決一半的問題。通過複用 Task,減輕了 new Task() 的壓力。但這還不夠好!讓我們看看 Task 的結構:

final class Task {
    byte opcode;
    long key;
    int promise;
    int data;
}

可見,Task 本身結構很簡單,相比之下對象頭的代價顯得很浪費。如果不用對象,其實可以用數組來代替:

// In Segment.ensureAllocated()
opcodes = new byte[Constants.SEGMENT_MAX_EVENTS_NUM_PER_BUCKET];
offsets = new int[Constants.SEGMENT_MAX_EVENTS_NUM_PER_BUCKET];
keys = new long[Constants.SEGMENT_MAX_EVENTS_NUM_PER_BUCKET];
promises = new int[Constants.SEGMENT_MAX_EVENTS_NUM_PER_BUCKET];

這樣做有以下幾個優點:

  • 創建(分配內存)速度大大提升
  • 內存佔用大幅下降,省掉了對象頭的開銷
  • 確保連續的內存分配,順序訪問更快

對於 Promise 可以做類似的優化,參考 PromisePool,這裏不再贅述。

Pool 的懶初始化

做了上述數組池和對象池優化後,程序啓動時間大大增加,這是因爲創建 Pool 需要大量分配內存,如果發生在類加載期間,就會阻塞 main 函數的運行。

解決方案是適當延遲部分 Pool 的分配,對它們採用 lazy 的初始化策略,即第一次使用時才分配所需的內存空間。

GC 調優

JVM 會在新生代不夠分配時觸發 GC。考慮到我們有 1G 的新生代內存,而事實上要動態 new 的對象很少,通過調節 Pool 的初始化時機,可以做到只發生一次 ParNew GC。

對於老年代的 CMS GC 代價很大,我們在比賽中儘可能避免觸發 CMS GC。而這就要求儘可能節約內存,上文提到的對象池和數組池發揮了重要作用。

線程數調優

根據比賽數據集選取最合適的 Parser 和 Worker 線程數,對榨乾最後一點 CPU 性能至關重要。

爲了調優, 我們啓動一個 monitor 的 daemon 線程, 定時打印 Reader, Parser 和 Worker的進度,從而推測性能瓶頸在哪一方。經嘗試,我們將 Parser 線程定爲 6 個,Worker(即 Bucket 數)定爲 10 個。

Parser 讀取預測

由於題目規定爲單庫單表,"變更類型"(U/I/D)之前的字符(下稱 header)沒有必要解析,可直接跳過。不過這部分的字符長度並不確定, 因此我們嘗試預測這個跳過的長度。對於每一行,如果推測正確,則可以直接跳過這部分字符;否則,從行首開始解析直到到達"變更類型", 同時更新預估的 header 長度。

由於大部分的 log 的 header 長度是一樣的, 這個技巧有效地避免大量不必要字符的解析。

同理,對於 Parser 來說除了主鍵以外剩下 key-value 並沒有用。用類似的思路也可以預測長度並直接跳過。附上 skipHeader的代碼:

if (buffer[pos + opcodeOffset - 1] == (byte) &#39;|&#39; 
     &amp;&amp; buffer[pos + opcodeOffset + 1] == (byte) &#39;|&#39;) {
    // Fast pass
    pos += opcodeOffset;
} else {
    int lineBegin = pos;
    pos++;  // Skip &#39;|&#39;
    pos = skipNext(buffer, pos, &#39;|&#39;);  // Skip binlog ID
    pos = skipNext(buffer, pos, &#39;|&#39;);  // Skip timestamp
    pos = skipNext(buffer, pos, &#39;|&#39;);  // Skip database name
    pos = skipNext(buffer, pos, &#39;|&#39;);  // Skip table name
    opcodeOffset = pos - lineBegin;
}      

重寫網絡傳輸和 Logging

實驗發現 Netty 和 logback 都比較重量級,拖慢了啓動速度。因此自己實現了網絡傳輸和 Logger,減少啓動時間。

總結與感想

算法決定了性能的上限,工程實現的好壞決定了能多大程度接近這個上限。

大賽過程競爭非常激烈,可謂高手雲集。通過這場比賽,我們的技術得到了鍛鍊,收穫瞭解決問題的成就感。同時也真誠感謝大賽的主辦方,讓我們有機會在賽場上證明自己的能力。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章