本文是野狗科技聯合創始人&架構師謝喬在ArchSummit 北京2015全球架構師峯會上進行的《基於數據同步雲服務架構實踐》的演講實錄,主要分為三個方面:野狗的數據同步理念,數據同步的架構演進,數據同步的細節問題。
野狗官博:https://blog.wilddog.com/
野狗官網:https://www.wilddog.com/
公眾訂閲號:wilddogbaas
以下為演講實錄:
可能大家在實際的應用場景中不使用數據同步的業務模式,但是我是想跟大家分享我們在演進過程中一些問題的解決思路,希望能對大家有所幫助。
今天的演講內容主要分三個議題:
-
野狗的數據同步理念
-
數據同步的架構演進
-
數據同步的細節問題
野狗的數據同步理念
首先從雲端這塊兒開始講起,我們的數據存儲是個Schema-free的形式,樹形的數據庫像一顆Json樹,更像前端工程師們用的數據結構,它能把原來的關係型數據通過一些關聯查詢形成聚合型的數據,比如blog,裏面有標題、回覆等內容,就相當於把數據重新聚合,這樣數據之間的關係就更直觀了,方便大家快速的設計比較好的數據結構,完美的與url結合,每條數據都通過url來唯一定位,每個path作為一個key,就成為了key-value的數據結構。
經典的雲服務是這樣的:現提供一個API,然後有其他的auth接入,雲端有存儲,有用户管理,有hosting功能,還有周邊的一些工具,客户端通過rest api這種方式與雲端進行交互來開發你的業務模型。
而野狗除了這一部分以外,還有一個富客户端的SDK,本地也做了存儲,當本地數據發生變化的時候會通過一個事件來通知用户,然後用户進行修改。
具體來講,是客户端與服務端建立一個長連接,來完成數據同步,當同步完成之後產生數據變化,就可以完成業務邏輯的實現。如果我們把模型再抽象一點,就像一個主從的同步,客户端作為從,和雲端進行副本級的同步過程。
也可以有另外一種同步方式,大家的服務可以與野狗雲進行實時同步。比如説,你的服務端進行了一次數據修改,同步到雲端,雲端把這個修改同步給關注這個數據的客户端。
數據實現同步的基本模型是這樣的:
開始有一個初始化的慢同步,可以做全量的同步或者條件同步,比如這個例子,客户端A進行了條件同步,同步到本地產生了一個本地副本,客户端B通過全同步拉取到本地形成一個本地副本。當客户端A修改後,產生了新的數據,我們把它叫增量同步,數據會push到雲端。然後本地使用best-effort模式,客户端先成功觸發事件,然後再同步到雲端,雲端再同步到其他的客户端,實現最終一致性。
這個過程很像op log的過程,也是基於長連接的,如果每次連接發生了異常,這裏會重新連接進行一次初始化慢同步過程。這也是我們所做的數據同步和消息推送的根本區別,原因是,消息推送要保證每個消息順序到達,而且不丟失,數據同步則是在性能上的提升,只關心最終的數據狀態。一旦發生異常,客户端重新連入到雲端以後,不會把之前過程中的op log都傳過去,只需要重新進行一次初始化操作,讓兩端進行同步恢復就可以了。
數據同步的架構演進
剛才講的業務方面的內容可能比較枯燥,接下來就是我們技術架構的演進過程。
首先看一下我們技術架構的特點,跟其他傳統業務不太一樣,屬於寫多讀少。因為讀只需要讀一次到客户端以後,讀客户端的副本就可以了,而且一些修改操作直接修改客户端本地,再由終端同步到雲端,剩下的操作大部分都是寫操作。寫同步當然是越實時越好,但問題就是讀的性能肯定會有一些延遲,後面會詳細講解。
我們實現的是最終一致性,因為這不是強一致性的架構,很多客户端可以關注同一個數據節點的變化。因為我們採用最終一致性,所以會導致多個客户端可以同時進行寫操作,就必然會產生寫衝突的問題,所以並行寫衝突的問題也要解決。
實時性是我們的特點,這裏暫時不詳細説。
最後一個是冪等操作。
這是0.1版本的架構框圖,這個主要面向我們的初期用户,用來驗證我們產品是否被用户認可。這個架構由一個接入層組成,用來維護和客户端的長連接,如果有一個請求過來,會產生數據操作到數據處理,數據處理直接寫Mysql。
Mysql這塊兒直接用了主從同步的模式來保留一定的可用性,然後再進行數據推送。數據推送的時候,先從Redis集羣中進行lookup操作,這個操作的目的是尋找要修改的數據節點被哪些終端所關注,然後再進行push操作。
這裏的數據採用了物化路徑存儲,也就是説,如果存的是/a/b/c的數據,實際上是存/a一條/a/b一條,/a/b/c一條。
業務得到認可之後,需要對早期用户有一個性能的保證,所以就有了這個0.2版本的架構框圖,把之前的Mysql改成了mongodb。使用mongodb的原因是可以動態創建數據庫,把用户的數據在APP級別進行隔離,這樣不會互相影響。同時,mongodb也帶來了讀寫性能的提升。
同時我們採用了副本集多活,利用mongodb自己的副本集主掛了之後自動切從的方案。
機槍換導彈的意思是之前是一次一次對數據庫進行操作,現在我們做了批量的操作和合並的push。之前的操作一個push會影響多個數據節點發生變化,會一條一條的推給關注的終端,現在可以做一個合併的push。
當我們的產品進入bate版測試之後就需要面向廣大的公測用户了,我們逐漸要面對的就是寫壓力了。因為mongodb的寫操作對於同一個數據表是鎖表的,所以寫是一個串行的性能問題,所以我們這裏加了一個寫緩衝隊列,這是大家都會想到的解決方案。
我們這裏使用了kafka。一條數據來了之後,由生產者進入kafka,然後由消費者把kafka的數據拿出來進行批量消費,最後內存生成一個操作樹的緩存,再批量寫入mongodb。這塊兒更類似Nagle算法,達到一定的操作量或者達到一定的超時時間後,就同步到Mysql數據庫。
可能大家有過加寫緩衝的經驗,這時候肯定會面臨讀性能下降的問題。因為這時候我們在讀到mongodb的時候是一個已經過時的數據快照,有一些操作還暫存在kafka,寫緩存隊列中,所以必須要解決這個讀不一致的問題。當讀操作來的時候,先從mongodb中讀取到快照,然後再記錄你當前執行到哪,一共有哪些操作還未執行。讀取完之後,在內存進行一個回放操作,拿到的就是比較新的快照版本了。
但是這裏還有一個問題,在操作的過程中,還會有新的寫操作過的內容,就算回放完,也是過期的版本。這裏有點像redis的主從同步一樣,拿到內存的最後版本後還有新過來的寫操作進入push和wait隊列,先把歷史版本推給客户端,再把之後的寫操作一次推給客户端。最後在客户端進行計算達到的就是最終一致性,用户拿到的就是最新的數據版本。
在beta版發佈一段時間之後,服務器的負載是很平穩的上升,延遲是10、11、12ms,每週是這樣一種遞增。但是突然有一天我們發現延遲暴增到上百ms,甚至到700ms,我們開始各種排查。但是查過之後,kafka、mongodb等等,都一切正常,最後才查到原來是因為push這裏需要查一次redis造成的。也就是説,我們在redis中存的是路徑Key,路徑下面是有哪些客户端節點關注了這個key,所以這裏要進行一次模糊匹配查詢,當一個實例的redis數據量到達20w、30w條的時候,如果用模糊查詢性能會非常低,延遲會達到幾百ms。所以我們這裏採用了臨時方案,用mongodb來代替redis,用mongodb加它的索引來提升模糊查詢的性能。
這裏也為我們敲了個警鐘,我們需要做性能監控,才能真正的面對用户。後來我們就基於flume做了一套自己的性能監控。Flume可以統計日誌,還有對每一個系統延遲的調用,以及異常報警,都寫入flume,再做一個flume的後台處理。
我們在設計架構的時候,總是把我們的關注點放在最容易發生問題的位置,而往往有時候雖然你解決了這塊兒的問題,但是由於總量上來了,還會影響一些原來不關注的地方出現問題,完全出乎意料。
數據同步的細節問題
剛才是簡單架構框圖的介紹,現在是我們數據同步面臨的一些細節的介紹。
兩個客户端同時修改本地的副本,需要考慮到數據的靜態一致性,同時還要考慮到寫隔離的問題。對於這個問題其實有兩個解決方案:一是中心化鎖機制;另外一個是進程間協商機制。但是鎖機制會有單點故障問題。所以我們做了一個分佈式樹形鎖機制。不過這裏有一些需要注意的問題:1、tryLock和release 需要2次的交互;2、需要注意註冊Lock的有效期;3、要等待Lock超時;4、最好使用動態hash;5、連接異常時退化。
還有一些性能問題,因為每個App都有一個樹形鎖,所以是單進程就算你進行了這種操作,在理論上是會有一個吞吐量的上限的。任何操作都要先去嘗試先獲得鎖,這個操作其實是一個浪費的操作。主要性能的點有兩個:一個是單次push sync量比較大,可以導致阻塞。另外一個就是異步push sync。
因為以上這些原因,一個噁心的架構就誕生了。主要因為縮減了write操作的過程,還有要保證雲端與客户端的一致性。整個系統就會太過於複雜,不確定因素太多。
但是我們做技術不能意淫。在真實的應用場景中,有同一客户端場景和不同客户端場景。但是兩者所佔的比例是不一樣的。不同客户端的寫衝突有0.3%,同一客户端寫衝突有4.1%。所以説,其實衝突的概率是非常小的。用上面那種方式就會有種“殺雞焉用宰牛刀”的感覺。
所以,我們提出了一個理念:讓上帝的歸上帝,野狗的歸野狗。具體到實施上就是讓用户進行可配置化,主要有四種方式:1、默認不啓用;2、減少不必要的開銷;3、降低鎖粒度;4、由appld hash改進為path hash。在這裏技術的同學就要注意了,有些問題其實不需要多麼厲害的架構,如果能在業務層面進行解決,就儘量將問題在業務層面解決,不要做特別複雜的架構去解決一些虛無縹緲的問題。
要解決這些問題,主要還是依賴寫時的樹形鎖,達到順序push的效果。如果沒有這個操作,就會出現客户端數據不一致的問題,所以push順序很重要,一定要一致。
主要是需要保證同一客户端的順序性。以“太空站”這個遊戲為例。飛機走着走着回發生回退的現象,造成這個現象的原因,是因為客户端在進行寫處理的時候是進行並行處理的。這個問題很好解決,可以按照客户端ID散列到每一個數據處理的進程上,在數據處理進程內部達到一個數據寫一致的效果。進程內的鎖也要實現順序性,所以目標又變成了解決write的性能。
第四個問題就是最終一致性的問題,剛才我們説的都是雲端和被同步客户端之間的問題。
但是這塊兒還會產生的問題模型是客户端A在本地先做修改,由1修改成2,將2同步到雲端以後,雲端也修改成2,雲端再push到其他的客户端,對這個數據有關注的,也會修改成2,這樣就解決了最終一致性的問題。
看似很完美,但還是有漏洞。
剛才所做的這一切,只能保證雲端和被同步的客户端的數據是一致的,但是這種情況由於客户端可以都先對本地進行修改,客户端A修改成2,客户端B修改成3,在推送到雲端的過程中,A進行的修改會寫入,B進行的修改也會寫入。最後執行的時候如果在雲端執行的時候是以某種順序推送過來的,假設雲端最後生成的是2那就是説,雲端和左側是一致的,就會與另一側的節點產生不一致。
也就是説,由於並行寫,最後會有一個客户端產生不一致的問題。
這裏我們也沒有用到一些複雜的算法,用了一個push給自己的模型來化解這個問題,達到最終的一致性。在並行寫和推送的時候仍然推送給自己,由於推送的過程是串行的,只有推送完前面的一次,才會推送對這個節點的下一次改變操作。這個推送完畢以後,因為是TCP的,所以會按順序推送過去,那就可以認為,在這個推送過程中,所有終端都達到了一致性。
會產生的問題大家也可以看到就是可能會出現,數據由2修改成3,再修改成2。在這裏我們需要對一致性問題和性能做一個取捨,當然還是選擇為了達到實時,所以採用這種比較弱的最終一致性方案。
最後一個問題,是一個原子性問題,因為我們是冪等操作,所以不會支持if then,i ++的操作。我們在這裏用了一個自旋鎖的CAS機制,在本地拉到數據之後做一個hash,這個hash和要修改的值做一個複合操作一起發到雲端,而云端也對這個數據進行一個hash,如果兩個hash是一致的,那才能認為可以操作,才能覆蓋。如果不一致的話,重新從雲端再次同步一些數據到本地產生一些副本,進行上一步的操作,直到成功為止。不過我們也有一個重試次數,現在的設置是20次。
今天的演講就到這裏了,謝謝大家。