在當今互聯網行業,大多數人互聯網從業者對"單元化"、"異地多活"這些詞彙已經耳熟能詳。而數據同步是異地多活的基礎,全部具有數據存儲能力的組件如:數據庫、緩存、MQ等,數據均可以進行同步,造成一個龐大而複雜的數據同步拓撲。html
本文將先從概念上介紹單元化、異地多活、就近訪問等基本概念。以後,將以數據庫爲例,講解在數據同步的狀況下,如何解決數據迴環、數據衝突、數據重複等典型問題。mysql
1 什麼是單元化
若是僅僅從"單元化」這個詞彙的角度來講,咱們能夠理解爲將數據劃分到多個單元進行存儲。"單元"是一個抽象的概念,一般與數據中心(IDC)概念相關,一個單元能夠包含多個IDC,也能夠只包含一個IDC。本文假設一個單元只對應一個IDC。redis
考慮一開始只有一個IDC的狀況,全部用戶的數據都會寫入同一份底層存儲中,以下圖所示:sql
這種架構是大多數據中小型互聯網公司採用的方案,存在如下幾個問題:數據庫
一、不一樣地區的用戶體驗不一樣。一個IDC必然只能部署在一個地區,例如部署在北京,那麼北京的用戶訪問將會獲得快速響應;可是對於上海的用戶,訪問延遲通常就會大一點,上海到北京的一個RTT可能有20ms左右。緩存
二、容災問題。這裏容災不是單臺機器故障,而是指機房斷電,天然災害,或者光纖被挖斷等重大災害。一旦出現這種問題,將沒法正常爲用戶提供訪問,甚至出現數據丟失的狀況。這並非不可能,例如:2015年,支付寶杭州某數據中心的光纜就被挖斷過;2018年9月,雲棲大會上,螞蟻金服當場把杭州兩個數據中心的網線剪斷。 網絡
爲了解決這些問題,咱們能夠將服務部署到多個不一樣的IDC中,不一樣IDC之間的數據互相進行同步。以下圖:session
經過這種方式,咱們能夠解決單機房遇到的問題:架構
一、用戶體驗。不一樣的用戶能夠選擇離本身最近的機房進行訪問運維
二、容災問題。當一個機房掛了以後,咱們能夠將這個機房用戶的流量調度到另一個正常的機房,因爲不一樣機房之間的數據是實時同步的,用戶流量調度過去後,也能夠正常訪問數據 (故障發生那一刻的少部分數據可能會丟失)。
須要注意的是,關於容災,存在一個容災級別的劃分,例如:單機故障,機架(rack)故障,機房故障,城市級故障等。咱們這裏只討論機房故障和城市故障。
-
機房容災 : 上面的案例中,咱們使用了2個IDC,可是2個IDC並不能具有機房容災能力。至少須要3個IDC,例如,一些基於多數派協議的一致性組件,如zookeeper,redis、etcd、consul等,須要獲得大部分節點的贊成。例如咱們部署了3個節點,在只有2個機房的狀況下, 必然是一個機房部署2個節點,一個機房部署一個節點。當部署了2個節點的機房掛了以後,只剩下一個節點,沒法造成多數派。在3機房的狀況下,每一個機房部署一個節點,任意一個機房掛了,還剩2個節點,仍是能夠造成多數派。這也就是咱們常說的"兩地三中心」。
-
城市級容災:在發生重大天然災害的狀況下,可能整個城市的機房都沒法訪問。一些組件,例如螞蟻的ocean base,爲了達到城市級容災的能力,使用的是"三地五中心"的方案。這種狀況下,3個城市分別擁有二、二、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.2 如何解決重複插入
考慮如下狀況下,源庫中的一條記錄沒有惟一索引。對於這個記錄的binlog,經過sql writer將binlog轉換成sql插入目標庫時,拋出了異常,此時咱們並不知道知道是否插入成功了,則須要進行重試。若是以前已是插入目標庫成功,只是目標庫響應時網絡超時(socket timeout)了,致使的異常,這個時候重試插入,就會存在多條記錄,形成數據不一致。
所以,一般,在數據同步時,一般會限制記錄必須有要有主鍵或者惟一索引。
2.3 如何解決惟一索引衝突
因爲兩邊的庫都存在數據插入,若是都使用了同一個惟一索引,那麼在同步到對端時,將會產生惟一索引衝突。對於這種狀況,一般建議是使用一個全局惟一的分佈式ID生成器來生成惟一索引,保證不會產生衝突。
另外,若是真的產生衝突了,同步組件應該將衝突的記錄保存下來,以便以後的問題排查。
2.4 對於DDL語句如何處理
若是數據庫表中已經有大量數據,例如千萬級別、或者上億,這個時候對於這個表的DDL變動,將會變得很是慢,可能會須要幾分鐘甚至更長時間,而DDL操做是會鎖表的,這必然會對業務形成極大的影響。
所以,同步組件一般會對DDL語句進行過濾,不進行同步。DBA在不一樣的數據庫集羣上,經過一些在線DDL工具(如gh-ost),進行表結構變動。
2.5 如何解決數據迴環問題
數據迴環問題,是數據同步過程當中,最重要的問題。咱們針對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.6 數據同步架構設計
如今,讓咱們先把思路先從解決數據同步的具體細節問題轉回來,從更高的層面講解數據同步的架構應該如何設計。稍後的內容中,咱們將講解各類避免數據迴環的各類解決方案。
前面的架構中,只涉及到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。
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=utf8mb4
idc字段用於記錄某條記錄原始產生的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。