概述
異地多活,往往意味著誇機房讀寫延遲的增加,也就增加了讀寫失敗的可能性,最終導致資料的延遲更長,同時,這種場景下也會影響線上系統的效能和時延。本文從資料低延遲、開發複雜度上考慮,總結了兩種處理方式,分別是雙寫和雙讀,從而保證資料的最終一致性。對於異地多活的業務場景,往往也不需要保證強一致性,允許短時間的不一致性。例如對於外賣軟體,在南方點了外賣,然後到北方出差,常規上也不可能短時間內(分鐘級別)從南方飛到北方。
再舉個極端的例子,我們所看到星空中的行星的光,也很多是很多年前從很遠的宇宙發射過來的,你不可能在同一時間看到光。
再者,實現真正的異地多活(強一致,多節點寫入)是個極其複雜的工程,需要底層資料庫、業務上的支援,對於一致性要求沒那麼高的業務場景,我們可以選擇稍微簡單的方案實現。
雙寫
寫入本機房後,還需要寫入異地機房,同步方式可以有:
-
資料庫本身支援了同步:這種情況往往需要增加第三方元件,例如阿里的otter元件支援了mysql的同步。業務程式碼只需要寫一次,底層資料同步交給資料庫,會出現短時間的兩個機房資料不一致的情況,業務上往往能夠接受。但極端情況也會出現異地對同一份資料進行寫,導致寫寫衝突,這時候需要業務介入做抉擇(常見的方式如訂單系統後期的對賬補償)。如果對於資料庫的操作是資料庫級別的原子性操作,例如redis的incr命令,就可以避免寫寫衝突。
-
資料庫本身不支援同步:這種情況需要業務程式碼雙寫,跨區寫的失敗率會變高,採取重試,但會加劇資料的延遲(如果延遲不高,也能接收)。同時,如果是線上系統,往往併發量比較大,所以還是得在業務層面加MQ,如加入第三方的MQ(如kafka),實現上就得實現producer和consumer邏輯,而且還需要額外對kafka進行維護,這也帶來了系統的複雜性。簡單做法是採用記憶體佇列,直接寫入記憶體佇列,通過定時器定期消費記憶體佇列資料。如果資料支援批量介面,採用批量寫資料庫,讀的時候,只讀本機房資料。這種方式,也會有問題:因為是記憶體佇列,如果服務重啟,還沒來得及消費的資料會丟失;或者是多次寫失敗重試後依然失敗,也會導致資料丟失(其實這種情況需要發出告警,人工介入了)。如果業務允許有一定的資料丟失的情況,但對時效性要求較高的,採用這種方式比較合理。
雙讀
跟雙寫的讀本機房相反,改成只寫本機房,讀雙機房。這種方式,首先對於高併發的讀,非常不友好,跨區讀的時延太高,同步讀往往會導致超時或者影響線上時延。所以一般採用非同步的方式,由一個非同步執行緒把資料從另一個機房撈出來再寫入本地機房資料庫,讀的時候只讀本地機房資料庫庫。這種方式加大了延遲,好處是提高了併發度,儘量的減少對讀的影響,而且如果本地支援冪等性,還能保證資料的最終一致。資料從異地同步到本地的機制可以兩種:
-
全量同步:實現簡單,但只適合於資料量少,但如果資料太多,同步也會很慢,加大了延遲,有可能打滿網路卡導致影響整體服務環境。
-
增量同步:實現複雜,需要設定個遊標,類似kafka的offset,記錄本次同步到的點,如何標準遊標是準確的呢?需要保證不多也不少,例如如果遊標粒度設定的太大,同一個遊標可能對應多個資料,這樣可能導致撈過來的資料比原有的多。所以這種情況對遊標的選擇就比較重要了。
高併發下的優化方案
批量:無論是對於雙讀還是雙寫,都採用資料庫的批量介面,減少網路io。
非同步+雙佇列快取
-
- 非同步:對於雙寫方案,採用非同步寫;對於雙讀方案,採用非同步讀更新(這種情況除非是增量更新,否則如果全量更新,也會導致效能和延遲的增加;但全量更新就要求資料不能太多,而且如果資料庫是redis或者其他kv,需要提前知道對應的key)。
- 雙佇列快取:雙buffer是為了提高併發度,對於雙寫,可以只需要對記憶體中的寫進行互斥,但對於資料的更新不會互斥,因為兩者個用不同佇列;對於雙讀,資料結構可以參考我之前發的doublybufferdata資料結構。對於佇列,其實是傳統MQ的替代,只是如果引入MQ,則需要帶來額外的維護成本,所以可以簡單的實現,用set或者map都可以。
總結
雙讀和雙寫的本質區別其實是資料在哪一邊同步的問題,類似kafka的producer和consumer,不可能放在同一個機房,要麼producer端是誇機房,要麼是consumer端是誇機房。無論是哪種方案,都會面臨延遲和不一致問題,以及還有效能問題,需要根據業務需要選擇一種折中的方案。