异地多活场景下的数据同步之道 | 珍藏版
在當今互聯網行業,大多數互聯網從業者對"單元化"、"異地多活"這些詞匯已經耳熟能詳。而數據同步是異地多活的基礎,所有具備數據存儲能力的組件如:數據庫、緩存、MQ等,數據都可以進行同步,形成一個龐大而復雜的數據同步拓撲。
本文將先從概念上介紹單元化、異地多活、就近訪問等基本概念。之后,將以數據庫為例,講解在數據同步的情況下,如何解決數據回環、數據沖突、數據重復等典型問題。
1 什么是單元化
如果僅僅從"單元化”這個詞匯的角度來說,我們可以理解為將數據劃分到多個單元進行存儲。"單元"是一個抽象的概念,通常與數據中心(IDC)概念相關,一個單元可以包含多個IDC,也可以只包含一個IDC。本文假設一個單元只對應一個IDC。
考慮一開始只有一個IDC的情況,所有用戶的數據都會寫入同一份底層存儲中,如下圖所示:
?? ???這種架構是大多數中小型互聯網公司采用的方案,存在以下幾個問題:
? ? ? 1 不同地區的用戶體驗不同。一個IDC必然只能部署在一個地區,例如部署在北京,那么北京的用戶訪問將會得到快速響應;但是對于上海的用戶,訪問延遲一般就會大一點,上海到北京的一個RTT可能有20ms左右。
?? ?? 2 容災問題。這里容災不是單臺機器故障,而是指機房斷電,自然災害,或者光纖被挖斷等重大災害。一旦出現這種問題,將無法正常為用戶提供訪問,甚至出現數據丟失的情況。這并不是不可能,例如:2015年,支付寶杭州某數據中心的光纜就被挖斷過;2018年9月,云棲大會上,螞蟻金服當場把杭州兩個數據中心的網線剪斷。? ? ??
? ? ?為了解決這些問題,我們可以將服務部署到多個不同的IDC中,不同IDC之間的數據互相進行同步。如下圖:
?
? ? ?通過這種方式,我們可以解決單機房遇到的問題:
?? ?? 1 用戶體驗。不同的用戶可以選擇離自己最近的機房進行訪問。
?? ?? 2 容災問題。當一個機房掛了之后,我們可以將這個機房用戶的流量調度到另外一個正常的機房,由于不同機房之間的數據是實時同步的,用戶流量調度過去后,也可以正常訪問數據 (故障發生那一刻的少部分數據可能會丟失)。
? ? ? ? ?需要注意的是,關于容災,存在一個容災級別的劃分,例如:單機故障,機架(rack)故障,機房故障,城市級故障等。我們這里只討論機房故障和城市故障。
-
機房容災 :?上面的案例中,我們使用了2個IDC,但是2個IDC并不能具備機房容災能力。至少需要3個IDC,例如,一些基于多數派協議的一致性組件,如zookeeper,redis、etcd、consul等,需要得到大部分節點的同意。例如我們部署了3個節點,在只有2個機房的情況下, 必然是一個機房部署2個節點,一個機房部署一個節點。當部署了2個節點的機房掛了之后,只剩下一個節點,無法形成多數派。在3機房的情況下,每個機房部署一個節點,任意一個機房掛了,還剩2個節點,還是可以形成多數派。這也就是我們常說的"兩地三中心”。
-
城市級容災:在發生重大自然災害的情況下,可能整個城市的機房都無法訪問。一些組件,例如螞蟻的ocean base,為了達到城市級容災的能力,使用的是"三地五中心"的方案。這種情況下,3個城市分別擁有2、2、1個機房。當整個城市發生災難時,其他兩個城市至少可以保證有3個機房依然是存活的,同樣可以形成多數派。
小結:如果僅僅是考慮不同地區的用戶數據就近寫入距離最近的IDC,這是純粹意義上的”單元化”。不同單元之間的數據實時進行同步,相互備份對方的數據,才能做到真正意義上"異地多活”。實現單元化,技術層面我們要解決的事情很多,例如:流量調度,即如何讓用戶就近訪問附近的IDC;數據互通,如何實現不同機房之間數據的相互同步。流量調度不在本文的討論范疇內,數據同步是本文講解的重點。
2 如何實現數據同步
需要同步的組件有很多,例如數據庫,緩存等,這里以多個Mysql集群之間的數據同步為例進行講解,實際上緩存的同步思路也是類似。
2.1 基礎知識
為了了解如何對不同mysql的數據相互進行同步,我們先了解一下mysql主從復制的基本架構,如下圖所示:
通常一個mysql集群有一主多從構成。用戶的數據都是寫入主庫Master,Master將數據寫入到本地二進制日志binary log中。從庫Slave啟動一個IO線程(I/O Thread)從主從同步binlog,寫入到本地的relay log中,同時slave還會啟動一個SQL Thread,讀取本地的relay log,寫入到本地,從而實現數據同步。
基于這個背景知識,我們就可以考慮自己編寫一個組件,其作用類似與mysql slave,也是去主庫上拉取binlog,只不過binlog不是保存到本地,而是將binlog轉換成sql插入到目標mysql集群中,實現數據的同步。
?? ?? ? 這并非是一件不可能完成的事,MySQL官網上已經提供好所有你自己編寫一個mysql slave 同步binlog所需的相關背景知識,訪問這個鏈接:https://dev.mysql.com/doc/internals/en/client-server-protocol.html,你將可以看到mysql 客戶端與服務端的通信協議。下圖紅色框中展示了Mysql主從復制的相關協議:
?? ??? ?
?? ?? ? 當然,筆者的目的并不是希望讀者真正的按照這里的介紹嘗試編寫一個mysql 的slave,只是想告訴讀者,模擬mysql slave拉取binlog并非是一件很神奇的事,只要你的網絡基礎知識夠扎實,完全可以做到。然而,這是一個龐大而復雜的工作。以一人之力,要完成這個工作,需要占用你大量的時間。好在,現在已經有很多開源的組件,已經實現了按照這個協議可以模擬成一個mysql的slave,拉取binlog。例如:
-
阿里巴巴開源的canal
-
美團開源的puma
-
linkedin開源的databus? ? ? ?...
?? ?? 你可以利用這些組件來完成數據同步,而不必重復造輪子。 假設你采用了上面某個開源組件進行同步,需要明白的是這個組件都要完成最基本的2件事:從源庫拉取binlog并進行解析,筆者把這部分功能稱之為binlog syncer;將獲取到的binlog轉換成SQL插入目標庫,這個功能稱之為sql writer。
?? ?? 為什么劃分成兩塊獨立的功能?因為binlog訂閱解析的實際應用場景并不僅僅是數據同步,如下圖:
? ? ?? ?如圖所示,我們可以通過binlog來做很多事,如:
-
實時更新搜索引擎,如es中的索引信息
-
實時更新redis中的緩存
-
發送到kafka供下游消費,由業務方自定義業務邏輯處理等
-
...
? ? ?? ?因此,通常我們把binlog syncer單獨作為一個模塊,其只負責解析從數據庫中拉取并解析binlog,并在內存中緩存(或持久化存儲)。另外,binlog syncer另外提一個sdk,業務方通過這個sdk從binlog syncer中獲取解析后的binlog信息,然后完成自己的特定業務邏輯處理。
?? ?? ? 顯然,在數據同步的場景下,我們可以基于這個sdk,編寫一個組件專門用于將binlog轉換為sql,插入目標庫,實現數據同步,如下圖所示:
?? ??? ?北京用戶的數據不斷寫入離自己最近的機房的DB,通過binlog syncer訂閱這個庫binlog,然后下游的binlog writer將binlog轉換成SQL,插入到目標庫。上海用戶類似,只不過方向相反,不再贅述。通過這種方式,我們可以實時的將兩個庫的數據同步到對端。當然事情并非這么簡單,我們有一些重要的事情需要考慮。
2.2 如何獲取全量+增量數據?
? ? ?? ?通常,mysql不會保存所有的歷史binlog。原因在于,對于一條記錄,可能我們會更新多次,這依然是一條記錄,但是針對每一次更新操作,都會產生一條binlog記錄,這樣就會存在大量的binlog,很快會將磁盤占滿。因此DBA通常會通過一些配置項,來定時清理binlog,只保留最近一段時間內的binlog。
? ? ? ?例如,官方版的mysql提供了expire_logs_days配置項,可以設置保存binlog的天數,筆者這里設置為0,表示默認不清空,如果將這個值設置大于0,則只會保存指定的天數。
? ? ? 另外一些mysql 的分支,如percona server,還可以指定保留binlog文件的個數。我們可以通過show binary logs來查看當前mysql存在多少個binlog文件,如下圖:
?
? ? ?? ?通常,如果binlog如果從來沒被清理過,那么binlog文件名字后綴通常是000001,如果不是這個值,則說明可能已經被清理過。當然,這也不是絕對,例如執行"reset master”命令,可以將所有的binlog清空,然后從000001重新開始計數。
? ? ? ?Whatever! 我們知道了,binlog可能不會一直保留,所以直接同步binlog,可能只能獲取到部分數據。因此,通常的策略是,由DBA先dump一份源庫的完整數據快照,增量部分,再通過binlog訂閱解析進行同步。
2.3?如何解決重復插入
考慮以下情況下,源庫中的一條記錄沒有唯一索引。對于這個記錄的binlog,通過sql writer將binlog轉換成sql插入目標庫時,拋出了異常,此時我們并不知道知道是否插入成功了,則需要進行重試。如果之前已經是插入目標庫成功,只是目標庫響應時網絡超時(socket timeout)了,導致的異常,這個時候重試插入,就會存在多條記錄,造成數據不一致。
因此,通常,在數據同步時,通常會限制記錄必須有要有主鍵或者唯一索引。
2.4?如何解決唯一索引沖突
?由于兩邊的庫都存在數據插入,如果都使用了同一個唯一索引,那么在同步到對端時,將會產生唯一索引沖突。對于這種情況,通常建議是使用一個全局唯一的分布式ID生成器來生成唯一索引,保證不會產生沖突。
另外,如果真的產生沖突了,同步組件應該將沖突的記錄保存下來,以便之后的問題排查。
2.5?對于DDL語句如何處理
如果數據庫表中已經有大量數據,例如千萬級別、或者上億,這個時候對于這個表的DDL變更,將會變得非常慢,可能會需要幾分鐘甚至更長時間,而DDL操作是會鎖表的,這必然會對業務造成極大的影響。
因此,同步組件通常會對DDL語句進行過濾,不進行同步。DBA在不同的數據庫集群上,通過一些在線DDL工具(如gh-ost),進行表結構變更。
2.6?如何解決數據回環問題
數據回環問題,是數據同步過程中,最重要的問題。我們針對INSERT、UPDATE、DELETE三個操作來分別進行說明:
INSERT操作
假設在A庫插入數據,A庫產生binlog,之后同步到B庫,B庫同樣也會產生binlog。由于是雙向同步,這條記錄,又會被重新同步回A庫。由于A庫應存在這條記錄了,產生沖突。
UPDATE操作
先考慮針對A庫某條記錄R只有一次更新的情況,將R更新成R1,之后R1這個binlog會被同步到B庫,B庫又將R1同步會A庫。對于這種情況下,A庫將不會產生binlog。因為A庫記錄當前是R1,B庫同步回來的還是R1,意味著值沒有變。
在一個更新操作并沒有改變某條記錄值的情況下,mysql是不會產生binlog,相當于同步終止。下圖演示了當更新的值沒有變時,mysql實際上不會做任何操作:
? ??
?? ??? ?上圖演示了,數據中原本有一條記錄(1,"tianshouzhi”),之后執行一個update語句,將id=1的記錄的name值再次更新為”tianshouzhi”,意味著值并沒有變更。這個時候,我們看到mysql 返回的影響的記錄函數為0,也就是說,并不會產生真是的更新操作。
?? ???? ?然而,這并不意味UPDATE 操作沒有問題,事實上,其比INSERT更加危險。考慮A庫的記錄R被連續更新了2次,第一次更新成R1,第二次被更新成R2;這兩條記錄變更信息都被同步到B庫,B也產生了R1和R2。由于B的數據也在往A同步,B的R1會被先同步到A,而A現在的值是R2,由于值不一樣,將會被更新成R1,并產生新的binlog;此時B的R2再同步會A,發現A的值是R1,又更新成R2,也產生binlog。由于B同步回A的操作,讓A又產生了新的binlog,A又要同步到B,如此反復,陷入無限循環中。
DELETE操作
?? ??? ?同樣存在先后順序問題。例如先插入一條記錄,再刪除。B在A刪除后,又將插入的數據同步回A,接著再將A的刪除操作也同步回A,每次都會產生binlog,陷入無限回環。
?? ??? ?關于數據回環問題,筆者有著血的教訓,曾經因為筆者的誤操作,將一個庫的數據同步到了自身,最終也導致無限循環,原因分析與上述提到的UPDATE、DELETE操作類似,讀者可自行思考。
?? ??? ?針對上述數據同步到過程中可能會存在的數據回環問題,最終會導致數據無限循環,因此我們必須要解決這個問題。由于存在多種解決方案,我們將在稍后統一進行講解。
?2.7 數據同步架構設計
?? ?? ? 現在,讓我們先把思路先從解決數據同步的具體細節問題轉回來,從更高的層面講解數據同步的架構應該如何設計。稍后的內容中,我們將講解各種避免數據回環的各種解決方案。
?? ??? ?前面的架構中,只涉及到2個DB的數據同步,如果有多個DB數據需要相互同步的情況下,架構將會變得非常復雜。例如:
? ? ?這個圖演示的是四個DB之間數據需要相互同步,這種拓撲結構非常復雜。為了解決這種問題,我們可以將數據寫入到一個數據中轉站,例如MQ中進行保存,如下:
我們在不同的機房各部署一套MQ集群,這個機房的binlog syncer將需要同步的DB binlog數據寫入MQ對應的Topic中。對端機房如果需要同步這個數據,只需要通過binlog writer訂閱這個topic,消費topic中的binlog數據,插入到目標庫中即可。一些MQ支持consumer group的概念,不同的consumer group的消費位置offset相互隔離,從而達到一份數據,同時供多個消費者進行訂閱的能力。
當然,一些binlog訂閱解析組件,可能實現了類似于MQ的功能,此時,則不需要獨立部署MQ。
那么MQ應該選擇什么呢?別問,問就是Kafka,具體原因問廝大。
????
3 數據據回環問題解決方案
?? ??? ?數據回環問題有多種解決方案,通過排除法,一一進行講解。
?3.1 同步操作不生成binlog
? ? ?? ?在mysql中,我們可以設置session變量,來控制當前會話上的更新操作,不產生binlog。這樣當往目標庫插入數據時,由于不產生binlog,也就不會被同步會源庫了。為了演示這個效果,筆者清空了本機上的所有binlog(執行reset master),現在如下圖所示:
?? ?忽略這兩個binlog event,binlog文件格式最開始就是這兩個event。
????接著,筆者執行set sql_log_bin=0,然后插入一條語句,最后可以看到的確沒有產生新的binlog事件:
?? ??? ?通過這種方式,貌似可以解決數據回環問題。目標庫不產生binlog,就不會被同步會源庫。但是,答案是否定的。我們是往目標庫的master插入數據,如果不產生binlog,目標庫的slave也無法同步數據,主從數據不一致。所以,需要排除這種方案。
?? ?? ? 提示:如果恢復set sql_log_bin=1,插入語句是會產生binlog,讀者可以自行模擬。
?3.2 控制binlog同步方向
?? ?? ? 既然不產生binlog不能解決問題。那么換一種思路,可以產生binlog。當把一個binlog轉換成sql時,插入某個庫之前,我們先判斷這條記錄是不是原本就是這個庫產生的,如果是,那么就拋棄,也可以避免回環問題。
?? ?? ? 現在問題就變為,如何給binlog加個標記,表示其實那個mysql集群產生的。這也有幾種方案,下面一一講述。
?3.2.1 ROW模式下的SQL
?? ?? ? mysql主從同步,binlog復制一般有3種模式。STATEMENT,ROW,MIXED。默認情況下,STATEMENT模式只記錄SQL語句,ROW模式只記錄字段變更前后的值,MIXED模式是二者混合。?binlog同步一般使用的都是ROW模式,高版本Mysql主從同步默認也是ROW模式。
我們想采取的方案是,在執行的SQL之前加上一段特殊標記,表示這個SQL的來源。例如
/*IDC1:DB1*/insert into users(name) values("tianbowen")?? ?? ? 其中/*IDC1:DB1*/是一個注釋,表示這個SQL原始在是IDC1的DB1中產生的。之后,在同步的時候,解析出SQL中的IDC信息,就能判斷出是不是自己產生的數據。
?? ?? ? 然而,ROW模式下,默認只記錄變更前后的值,不記錄SQL。所以,我們要通過一個開關,讓Mysql在ROW模式下也記錄INSERT、UPDATE、DELETE的SQL語句。具體做法是,在mysql的配置文件中,添加以下配置:
binlog_rows_query_log_events =1?? ?? ? 這個配置可以讓mysql在binlog中產生ROWS_QUERY_LOG_EVENT類型的binlog事件,其記錄的就是執行的SQL。
? ? ? ? 通過這種方式,我們就記錄下的一個binlog最初是由哪一個集群產生的,之后在同步的時候,sql writer判斷目標機房和當前binlog中包含的機房相同,則拋棄這條數據,從而避免回環。
?? ?? ? 這種思路,功能上沒問題,但是在實踐中,確非常麻煩。首先,讓業務對執行的每條sql都加上一個這樣的標識,幾乎不可能。另外,如果忘記加了,就不知道數據的來源了。如果采用這種方案,可以考慮在數據庫訪問層中間件層面添加支持在sql之前增加/*..*/的功能,統一對業務屏蔽。即使這樣,也不完美,不能保證所有的sql都通過中間件來來寫入,例如DBA的一些日常運維操作,或者手工通過mysql命令行來操作數據庫時,肯定會存在沒有添加機房信息的情況。
?? ??? ?總的來說,這個方案不是那么完美。
?3.2.2 通過附加表
?? ??? ?這種方案目前很多知名互聯網公司在使用。大致思路是,在db中都加一張額外的表,例如叫direction,記錄一個binlog產生的源集群的信息。例如
CREATE?TABLE?`direction`?(`idc`?varchar(255)?not?null,`db_cluster`?varchar(255)?not?null, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4idc字段用于記錄某條記錄原始產生的IDC,db_cluster用于記錄原始產生的數據庫集群(注意這里要使用集群的名稱,不能是server_id,因為可能會發生主從切換)。
??? ??假設用戶在IDC1的庫A插入的一條記錄(也可以在事務中插入多條記錄,單條記錄,即使不開啟事務,mysql默認也會開啟事務):
BEGIN; insert?into?users(name)?values("tianshouzhi”); COMMIT;那么A庫數據binlog通過sql writer同步到目標庫B時,sql writer可以提前對事務中的信息可以進行一些修改,,如下所示:
BEGIN; #往目標庫同步時,首先額外插入一條記錄,表示這個事務中的數據都是A產生的。 insert?into?direction(idc,db_cluster)?values("IDC1”,"DB_A”) #插入原來的記錄信息 insert?into?users(name)?values("tianshouzhi”); COMMIT;之后B庫的數據往A同步時,就可以根據binlog中的第一條記錄的信息,判斷這個記錄原本就是A產生的,進行拋棄,通過這種方式來避免回環。這種方案已經已經過很多的公司的實際驗證。
3.2.3 通過GTID
Mysql 5.6引入了GTID(全局事務id)的概念,極大的簡化的DBA的運維。在數據同步的場景下,GTID依然也可以發揮極大的威力。
GTID 由2個部分組成:
server_uuid:transaction_id其中server_uuid是mysql隨機生成的,全局唯一。transaction_id事務id,默認情況下每次插入一個事務,transaction_id自增1。注意,這里并不會對GTID進行全面的介紹,僅說明其在數據同步的場景下,如何避免回環、數據重復插入的問題。
GTID提供了一個會話級變量gtid_next,指示如何產生下一個GTID。可能的取值如下:
-
AUTOMATIC: 自動生成下一個GTID,實現上是分配一個當前實例上尚未執行過的序號最小的GTID。
-
ANONYMOUS: 設置后執行事務不會產生GTID,顯式指定的GTID。
?? ?默認情況下,是AUTOMATIC,也就是自動生成的,例如我們執行sql:
insert?into?users(name)?values("tianbowen”);
?? ?產生的binlog信息如下:
可以看到,GTID會在每個事務(Query->...->Xid)之前,設置這個事務下一次要使用到的GTID。
從源庫訂閱binlog的時候,由于這個GTID也可以被解析到,之后在往目標庫同步數據的時候,我們可以顯示的的指定這個GTID,不讓目標自動生成。也就是說,往目標庫,同步數據時,變成了2條SQL:
SET?GTID_NEXT=?'09530823-4f7d-11e9-b569-00163e121964:1’ insert into users(name) values("tianbowen")???????由于我們顯示指定了GTID,目標庫就會使用這個GTID當做當前事務ID,不會自動生成。同樣,這個操作也會在目標庫產生binlog信息,需要同步回源庫。再往源庫同步時,我們按照相同的方式,先設置GTID,在執行解析binlog后得到的SQL,還是上面的內容
SET GTID_NEXT= '09530823-4f7d-11e9-b569-00163e121964:1'insert into users(name) values("tianbowen")???????? ? ? ? 由于這個GTID在源庫中已經存在了,插入記錄將會被忽略,演示如下:
mysql>?SET?GTID_NEXT=?'09530823-4f7d-11e9-b569-00163e121964:1'; Query?OK,?0?rows?affected?(0.00?sec) mysql>?insert?into?users(name)?values("tianbowen"); Query OK, 0 rows affected (0.01 sec) #注意這里,影響的記錄行數為0???????注意這里,對于一條insert語句,其影響的記錄函數居然為0,也就會插入并沒有產生記錄,也就不會產生binlog,避免了循環問題。
如何做到的呢?mysql會記錄自己執行過的所有GTID,當判斷一個GTID已經執行過,就會忽略。通過如下sql查看:
mysql>?show?global?variables?like?"gtid_executed";+---------------+------------------------------------------+|?Variable_name?|?Value????????????????????????????????????|+---------------+------------------------------------------+|?gtid_executed?|?09530823-4f7d-11e9-b569-00163e121964:1-5?|+---------------+------------------------------------------+?? ?? ? 上述value部分,冒號":"前面的是server_uuid,冒號后面的1-5,是一個范圍,表示已經執行過1,2,3,4,5這個幾個transaction_id。這里就能解釋了,在GTID模式的情況下,為什么前面的插入語句影響的記錄函數為0了。
?? ?? ? 顯然,GTID除了可以幫助我們避免數據回環問題,還可以幫助我們解決數據重復插入的問題,對于一條沒有主鍵或者唯一索引的記錄,即使重復插入也沒有,只要GTID已經執行過,之后的重復插入都會忽略。
?? ?? ? 當然,我們還可以做得更加細致,不需要每次都往目標庫設置GTID_NEXT,這畢竟是一次網絡通信。sql writer在往目標庫插入數據之前,先判斷目標庫的server_uuid是不是和當前binlog事務信息攜帶的server_uuid相同,如果相同,則可以直接丟棄。查看目標庫的gtid,可以通過以下sql執行:
mysql>?show?variables?like?"server_uuid";+---------------+--------------------------------------+|?Variable_name?|?Value????????????????????????????????|+---------------+--------------------------------------+|?server_uuid???|?09530823-4f7d-11e9-b569-00163e121964?|+---------------+--------------------------------------+?? ?? ? GTID應該算是一個終極的數據回環解決方案,mysql原生自帶,比添加一個輔助表的方式更輕量,開銷也更低。需要注意的是,這倒并不是一定說GTID的方案就比輔助表好,因為輔助表可以添加機房等額外信息。在一些場景下,如果下游需要知道這條記錄原始產生的機房,還是需要使用輔助表。
4 開源組件介紹canal/otter
前面深入講解了單元化場景下數據同步的基礎知識。讀者可能比較感興趣的是,哪些開源組件在這些方面做的比較好。筆者建議的首選,是canal/otter組合。
canal的作用就是類似于前面所述的binlog syncer,拉取解析binlog。otter是canal的客戶端,專門用于進行數據同步,類似于前文所講解的sql writer。并且,canal的最新版本已經實現了GTID。
總結
以上是生活随笔為你收集整理的异地多活场景下的数据同步之道 | 珍藏版的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 老大难的空指针,如何优雅处理?
- 下一篇: Spring StateMachine,