這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
最終的實現代碼: https://github.com/esdb/drbuffer
本文是整個 kafka agent 實現過程中的第一步:https://segmentfault.com/a/1190000004567774
記憶體結構
每個寫入的packet格式如下
---packet_size(uint16)---packet_body([]byte)---
通過儲存packet的長度實現變長資料的儲存。目標是把這樣的記憶體結構儲存到一個ring buffer裡。整體的ring buffer的結構如下
--- <-- nextReadFrompacket 1---packet 2--- <-- nextWriteFrom ...
除了每個packet是一個變長的結構體,貌似並沒有什麼特殊的。儲存兩個offset,一個指向讀的位置,一個指向寫入的位置。
持久化的需求其實並不困難,只需要把記憶體地區從一個檔案mmap而來就可以。只要作業系統不掛(突然斷電)資料的一致性和持久化都可以保證。對於日誌,監控,離線分析等情境這樣的持久化保證是足夠的。利用這樣的一個持久化的ring buffer可以隔離快速的寫入方和不那麼靠譜的後端。讓業務進程對後端有一定的容錯性。但是對於和金錢相關的業務事件,單機和單磁碟的可用性是不夠的,那就需要額外添加網路複製來保證了。
golang使用mmap非常方便,直接就是[]byte,甚至可以用type alias轉成別的類型來用。而且mmap是堆外記憶體不歸golang的gc管理,不會增加gc的負擔。不得不說相比jvm,golang對於unsafe的操作花樣(作死的方式)更多,堆內和堆外記憶體的使用體驗也強式一致性。
痛點一:繞回
所謂ring buffer就是一個環,寫入到了尾部之後就要繞回到頭部。對於成員定長的隊列,這個並不困難。寫到最後一個slot之後就繞回到0就行了。但是對於成員變長的隊列來說,繞回就變得非常困難。考慮一下幾種情況
---1---0---A---
上面是3個byte,對應了一個完整的packet,內容是"A"。
---1---0---A---free slot 1---
如果只有一個空位,那麼下一個packet的頭部2個byte就會被分成兩部分。
---1---0---A---free slot 1---free slot 2---
如果有兩個空位,那麼下一個packet的頭部2個byte是可以寫下了,但是body又得繞回到頭部再寫了。所以可見因為成員是變長的,所以無法通過預先規劃使得尾部總是一個byte不多,一個byte不少。解決辦法就是讓這個繞回的點變成活動的。繞回的時候留下一個wrapAt的offset,讓讀的時候可以跟隨著wrapAt的指示跟著繞回。
---1---0---A--- <-- wrapAtfree slot 1---
痛點二:寫入速度過快,覆蓋了未讀的地區
--- <-- nextWriteFrompacket 1--- <-- nextReadFrompacket 2---packet 3---
如果像上面這樣的情況,nextWriteFrom接著寫入就可能會覆蓋nextReadFrom指向的地區。如果不移動nextReadFrom,那麼下次讀取的時候就會指向到一個錯誤的地區,比如位已經錯位了的包,從而讀取錯誤。問題的關鍵在於,怎麼樣判斷nextReadFrom會被覆蓋?咋一看這是一個很簡單的問題
[writeFrom, writeTo)
如果readFrom落在這個區間裡就可以認為會被影響到了。因為writeTo指向的byte其實不會被寫入,所以很自然認為readFrom不會受影響。但是一旦我們允許了這樣的行為就會出現非常複雜的情況,給定
nextReadFrom == nextWriteFom
這個情況下是有更多的位元組可讀,還是已經到隊尾了?如果是認為到隊尾了,那麼意味著上面的情況我們沒有移動readFrom指標導致一大片本來可以繼續讀的記憶體被跳過了。如果認為沒有到隊尾,那麼隊尾在哪裡?隊尾那就必須是前面的wrapAt的位置。那麼就必須維護這樣的一個範圍 [nextReadFrom, wrapAt)
是包含有效資料的。這是可行的,但是會非常麻煩。最簡單的實現方式是要求[writeFrom, writeTo]
整個閉區間的範圍內都不包含讀的指標,這樣通過簡單判斷讀寫指標是不是相等就可以知道是不是無資料可讀了。
痛點三:怎麼移讀指標?
一旦寫入速度過快要移讀指標了,但是移讀指標也是一個大難題。
--- <-- nextReadFrom1---0---A---1---0---B---
如果nextReadFrom如上所示,這個時候要求nextReadFrom往後移動4格,nextReadFrom不能是這樣
--- 1---0---A---1--- <-- nextReadFrom0---B---
因為這樣指向的是一個packet的中間是無效的。所以nextReadFrom往後退也要是整packet往後退。這裡我們可以使用一個最簡單的實現,一旦nextReadFrom被徵用了,直接把nextReadFrom置為0,因為0總是一個合法的指向位置。而且0一次性清空的記憶體是足夠的。缺點就是一次性置為0會丟掉大量未讀的資料。
痛點四:重讀的需求
傳統的ring buffer,讀取和移動讀取指標是一個操作。一旦讀了,這篇地區就不再被儲存了。但是經常我們需要讀資料出來,做一些處理,當處理確定成功了再去移位置。否則下次重讀(重啟之後恢複了記憶體狀態)的時候還是從上一次的地方重複讀取。如何廉價地支援這樣的可靠讀取的需求?
簡單的實現是儲存兩個讀指標,一個是lastReadTo(已經提交了的),一個是nextReadFrom(未提交確認的)
--- <-- lastReadTo1---0---A--- <-- nextReadFrom1---0---B---
每次讀取一次,就把上次讀取到的位置(nextReadFrom)存入lastReadTo。這樣做,有一個潛在的問題是寫入可能會覆蓋兩個讀指標。仔細思考一下,只需要考慮覆蓋lastReadTo的問題,因為總是先覆蓋到lastReadTo,再覆蓋到nextReadFrom的。所以做覆蓋的檢查的時候以lastReadTo為準,一旦發生覆蓋,則把lastReadTo和nextReadFrom都置為0,從頭開始,丟掉未讀的部分。