NoSQL 資料庫的主主備份原理及實施方案圖解
Tarantool DBMS的高效能應該很多人都聽說過,包括其豐富的工具套件和某些特定功能。比如,它擁有一個非常強大的on-disk儲存引擎Vinyl,並且知道怎樣處理JSON文件。然而,大部分文章往往忽略了一個關鍵點:通常,Tarantool僅僅被視為儲存器,而實際上其最大特點是能夠在儲存器內部寫程式碼,從而高效處理資料。如果你想知道我和igorcoding是怎樣在Tarantool內部建立一個系統的,請繼續往下看。
如果你用過Mail.Ru電子郵件服務,你應該知道它可以從其他賬號收集郵件。如果支援OAuth協議,那麼在收集其他賬號的郵件時,我們就不需要讓使用者提供第三方服務憑證了,而是用OAuth令牌來代替。此外,Mail.Ru Group有很多專案要求通過第三方服務授權,並且需要使用者的OAuth令牌才能處理某些應用。因此,我們決定建立一個儲存和更新令牌的服務。
我猜大家都知道OAuth令牌是什麼樣的,閉上眼睛回憶一下,OAuth結構由以下3-4個欄位組成:
Oauth結構程式碼
{ “token_type” : “bearer”, “access_token” : “XXXXXX”, “refresh_token” : “YYYYYY”, “expires_in” : 3600 }
- 訪問令牌(access_token)——允許你執行動作、獲取使用者資料、下載使用者的好友列表等等;
- 更新令牌(refresh_token)——讓你重新獲取新的access_token,不限次數;
- 過期時間(expires_in)——令牌到期時間戳或任何其他預定義時間,如果你的access_token到期了,你就不能繼續訪問所需的資源。
現在我們看一下服務的簡單框架。設想有一些前端可以在我們的服務上寫入和讀出令牌,還有一個獨立的更新器,一旦令牌到期,就可以通過更新器從OAuth服務提供商獲取新的訪問令牌。
如上圖所示,資料庫的結構也十分簡單,由兩個資料庫節點(主和從)組成,為了說明兩個資料庫節點分別位於兩個資料中心,二者之間由一條垂直的虛線隔開,其中一個資料中心包含主資料庫節點及其前端和更新器,另一個資料中心包含從資料庫節點及其前端,以及訪問主資料庫節點的更新器。
面臨的困難
我們面臨的主要問題在於令牌的使用期(一個小時)。詳細瞭解這個專案之後,也許有人會問“在一小時內更新1000萬條記錄,這真的是高負載服務嗎?如果我們用一個數除一下,結果大約是3000rps”。然而,如果因為資料庫維護或故障,甚至伺服器故障(一切皆有可能)導致一部分記錄沒有得到更新,那事情將會變得比較麻煩。比如,如果我們的服務(主資料庫)因為某些原因持續中斷15分鐘,就會導致25%的服務中斷(四分之一的令牌變成無效,不能再繼續使用);如果服務中斷30分鐘,將會有一半的資料不能得到更新;如果中斷1小時,那麼所有的令牌都將失效。假設資料庫癱瘓一個小時,我們重啟系統,然後整個1000萬條令牌都需要進行快速更新。這算不算高負載服務呢?
一開始一切都還進展地比較順利,但是兩年後,我們進行了邏輯擴充套件,增加了幾個指標,並且開始執行一些輔助邏輯…….總之,Tarantool耗盡了CPU資源。儘管所有資源都是遞耗資源,但這樣的結果確實讓我們大吃一驚。
幸運的是,系統管理員幫我們安裝了當時庫存中記憶體最大的CPU,解決了我們隨後6個月的CPU需求。但這只是權宜之計,我們必須想出一個解決辦法。當時,我們學習了一個新版的Tarantool(我們的系統是用Tarantool 1.5寫的,這個版本除了在Mail.Ru Group,其他地方基本沒用過)。Tarantool 1.6大力提倡主主備份,於是我們想:為什麼不在連線主主備份的三個資料中心分別建立一個資料庫備份呢?這聽起來是個不錯的計劃。
三個主機、三個資料中心和三個更新器,都分別連線自己的主資料庫。即使一個或者兩個主機癱瘓了,系統仍然照常執行,對吧?那麼這個方案的缺點是什麼呢?缺點就是,我們將一個OAuth服務提供商的請求數量有效地增加到了三倍,也就是說,有多少個副本,我們就要更新幾乎相同數量的令牌,這樣不行。最直接的解決辦法就是,想辦法讓各個節點自己決定誰是leader,那樣就只需要更新儲存在leader上的節點了。
選擇leader節點
選擇leader節點的演算法有很多,其中有一個演算法叫Paxos,相當複雜,不知道怎樣簡化,於是我們決定用Raft代替。Raft是一個非常通俗易懂的演算法,誰能通訊就選誰做leader,一旦通訊連線失敗或者其他因素,就重新選leader。具體實施辦法如下:
Tarantool外部既沒有Raft也沒有Paxos,但是我們可以使用net.box內建模式,讓所有節點連線成一個網狀網(即每一個節點連線剩下所有節點),然後直接在這些連線上用Raft演算法選出leader節點。最後,所有節點要麼成為leader節點,要麼成為follower節點,或者二者都不是。
如果你覺得Raft演算法實施起來有困難,下面的Lua程式碼可以幫到你:
Lua程式碼
local r = self.pool.call(self.FUNC.request_vote, self.term, self.uuid) self._vote_count = self:count_votes(r) if self._vote_count > self._nodes_count / 2 then log.info(“[raft-srv] node %d won elections”, self.id) self:_set_state(self.S.LEADER) self:_set_leader({ id=self.id, uuid=self.uuid }) self._vote_count = 0 self:stop_election_timer() self:start_heartbeater() else log.info(“[raft-srv] node %d lost elections”, self.id) self:_set_state(self.S.IDLE) self:_set_leader(msgpack.NULL) self._vote_count = 0 self:start_election_timer() end
現在我們給遠端伺服器傳送請求(其他Tarantool副本)並計算來自每一個節點的票數,如果我們有一個quorum,我們就選定了一個leader,然後傳送heartbeats,告訴其他節點我們還活著。如果我們在選舉中失敗了,我們可以發起另一場選舉,一段時間之後,我們又可以投票或被選為leader。
只要我們有一個quorum,選中一個leader,我們就可以將更新器指派給所有節點,但是隻準它們為leader服務。
這樣我們就規範了流量,由於任務是由單一的節點派出,因此每一個更新器獲得大約三分之一的任務,有了這樣的設定,我們可以失去任何一臺主機,因為如果某臺主機出故障了,我們可以發起另一個選舉,更新器也可以切換到另一個節點。然而,和其他分散式系統一樣,有好幾個問題與quorum有關。
“廢棄”節點
如果各個資料中心之間失去聯絡了,那麼我們需要有一些適當的機制去維持整個系統正常運轉,還需要有一套機制能恢復系統的完整性。Raft成功地做到了這兩點:
假設Dataline資料中心掉線了,那麼該位置的節點就變成了“廢棄”節點,也就是說該節點就看不到其他節點了,叢集中的其他節點可以看到這個節點丟失了,於是引發了另一個選舉,然後新的叢集節點(即上級節點)被選為leader,整個系統仍然保持運轉,因為各個節點之間仍然保持一致性(大半部分節點仍然互相可見)。
那麼問題來了,與丟失的資料中心有關的更新器怎麼樣了呢?Raft說明書沒有給這樣的節點一個單獨的名字,通常,沒有quorum的節點和不能與leader聯絡的節點會被閒置下來。然而,它可以自己建立網路連線然後更新令牌,一般來說,令牌都是在連線模式時更新,但是,也許用一個連線“廢棄”節點的更新器也可以更新令牌。一開始我們並不確定這樣做有意義,這樣不會導致冗餘更新嗎?
這個問題我們需要在實施系統的過程中搞清楚。我們的第一個想法是不更新:我們有一致性、有quorum,丟失任何一個成員,我們都不應該更新。但是後來我們有了另一個想法,我們看一下Tarantool中的主主備份,假設有兩個主節點和一個變數(key)X=1,我們同時在每一個節點上給這個變數賦一個新值,一個賦值為2,另一個賦值為3,然後,兩個節點互相交換備份日誌(就是X變數的值)。在一致性上,這樣實施主主備份是很糟糕的(無意冒犯Tarantool開發者)。
如果我們需要嚴格的一致性,這樣是行不通的。然而,回憶一下我們的OAuth令牌是由以下兩個重要因素組成:
- 更新令牌,本質上永久有效;
- 訪問令牌,有效期為一個小時;
我們的更新器有一個refresh函式,可以從一個更新令牌獲取任意數量的訪問令牌,一旦釋出,它們都將保持一個小時內有效。
我們考慮一下以下場景:兩個follower節點正在和一個leader節點互動,它們更新自己的令牌,接收第一個訪問令牌,這個訪問令牌被複制,於是現在每一個節點都有這個訪問令牌,然後,連線中斷了,所以,其中一個follower節點變成了“廢棄”節點,它沒有quorum,既看不到leader也看不到其他follower,然而,我們允許我們的更新器去更新位於“廢棄”節點上的令牌,如果“廢棄”節點沒有連線網路,那麼整個方案都將停止執行。儘管如此,如果發生簡單的網路拆分,更新器還是可以維持正常執行。
一旦網路拆分結束,“廢棄”節點重新加入叢集,就會引發另一場選舉或者資料交換。注意,第二和第三個令牌一樣,也是“好的”。
原始的叢集成員恢復之後,下一次更新將只在一個節點上發生,然後備份。換句話來說,當叢集拆分之後,被拆分的各個部分各自獨立更新,但是一旦重新整合,資料一致性也因此恢復。通常,需要N/2+1個活動節點(對於一個3節點叢集,就是需要2個活動節點)去保持叢集正常運轉。儘管如此,對我們而言,即使只有1個活動節點也足夠了,它會傳送儘可能多的外部請求。
重申一下,我們已經討論了請求數量逐漸增加的情況,在網路拆分或節點中斷時期,我們能夠提供一個單一的活動節點,我們會像平時一樣更新這個節點,如果出現絕對拆分(即當一個叢集被分成最大數量的節點,每一個節點有一個網路連線),如上所述,OAuth服務提供商的請求數量將提升至三倍。但是,由於這個事件發生的時間相對短暫,所以情況不是太糟,我們可不希望一直工作在拆分模式。通常情況下,系統處於有quorum和網路連線,並且所有節點都啟動執行的狀態。
分片
還有一個問題沒有解決:我們已經達到了CPU上限,最直接的解決辦法就是分片。
假設我們有兩個資料庫分片,每一個都有備份,有一個這樣的函式,給定一些key值,就可以計算出哪一個分片上有所需要的資料。如果我們通過電子郵件分片,一部分地址儲存在一個分片上,另一部分地址儲存在另一個分片上,我們很清楚我們的資料在哪裡。
有兩種方法可以分片。一種是客戶端分片,我們選擇一個返回分片數量的連續的分片函式,比如CRC32、Guava或Sumbur,這個函式在所有客戶端的實現方式都一樣。這種方法的一個明顯優勢在於資料庫對分片一無所知,你的資料庫正常運轉,然後分片就發生了。
然而,這種方法也存在一個很嚴重的缺陷。一開始,客戶端非常繁忙。如果你想要一個新的分片,你需要把分片邏輯加進客戶端,這裡的最大的問題是,可能一些客戶端在使用這種模式,而另一些客戶端卻在使用另一種完全不同的模式,而資料庫本身卻不知道有兩種不同的分片模式。
我們選擇另一種方法—資料庫內部分片,這種情況下,資料庫程式碼變得更加複雜,但是為了折中我們可以使用簡單的客戶端,每一個連線資料庫的客戶端被路由到任意節點,由一個特殊函式計算出哪一個節點應該被連線、哪一個節點應該被控制。前面提到,由於資料庫變得更加複雜,因此為了折中,客戶端就變得更加簡單了,但是這樣的話,資料庫就要對其資料全權負責。此外,最困難的事就是重新分片,如果你有一大堆客戶端無法更新,相比之下,如果資料庫負責管理自己的資料,那重新分片就會變得非常簡單。
具體怎樣實施呢?
六邊形代表Tarantool實體,有3個節點組成分片1,另一個3節點叢集作為分片2,如果我們將所有節點互相連線,結果會怎樣呢?根據Raft,我們可以知道每一個叢集的狀態,誰是leader伺服器誰是follower伺服器也一目瞭然,由於是叢集內連線,我們還可以知道其他分片(例如它的leader分片或者follower分片)的狀態。總的來說,如果訪問第一個分片的使用者發現這並不是他需要的分片,我們很清楚地知道應該指導他往哪裡走。
我們來看一些簡單的例子。
假設使用者向駐留在第一個分片上的key發出請求,該請求被第一個分片上的某一個節點接收,這個節點知道誰是leader,於是將請求重新路由到分片leader,反過來,分片leader對這個key進行讀或寫,並且將結果反饋給使用者。
第二個場景:使用者的請求到達第一個分片中的相同節點,但是被請求的key卻在第二個分片上,這種情況也可以用類似的方法處理,第一個分片知道第二個分片上誰是leader,然後把請求送到第二個分片的leader進行轉發和處理,再將結果返回給使用者。
這個方案十分簡單,但也存在一定的缺陷,其中最大的問題就是連線數,在二分片的例子中,每一個節點連線到其他剩下的節點,連線數是6*5=30,如果再加一個3節點分片,那麼連線數就增加到72,這會不會有點多呢?
我們該如何解決這個問題呢?我們只需要增加一些Tarantool例項,我們叫它代理,而不叫分片或資料庫,用代理去解決所有的分片問題:包括計算key值和定位分片領導。另一方面,Raft叢集保持自包含,只在分片內部工作。當使用者訪問代理時,代理計算出所需要的分片,如果需要的是leader,就對使用者作相應的重定向,如果不是leader,就將使用者重定向至分片內的任意節點。
由此產生的複雜性是線性的,取決於節點數量。現在一共3個節點,每個節點3個分片,連線數少了幾倍。
代理方案的設計考慮到了進一步規模擴充套件(當分片數量大於2時),當只有2個分片時,連線數不變,但是當分片數量增加時,連線數會劇減。分片列表儲存在Lua配置檔案中,所以,如果想要獲取新列表,我們只需要過載程式碼就好了。
綜上所述,首先,我們進行主主備份,應用Raft演算法,然後加入分片和代理,最後我們得到的是一個單塊,一個叢集,所以說,目前這個方案看上去是比較簡單的。
剩下的就是隻讀或只寫令牌的的前端了,我們有更新器可以更新令牌,獲得更新令牌後把它傳到OAuth服務提供商,然後寫一個新的訪問令牌。
前面說過我們的一些輔助邏輯耗盡了CPU資源,現在我們將這些輔助資源移到另一個叢集上。
輔助邏輯主要和地址簿有關,給定一個使用者令牌,就會有一個對應的地址簿,地址簿上的資料量和令牌一樣,為了不耗盡一臺機器上的CPU資源,我們顯然需要一個與副本相同的叢集,只需要加一堆更新地址簿的更新器就可以了(這個任務比較少見,因此地址簿不會和令牌一起更新)。
最後,通過整合這兩個叢集,我們得到一個相對簡單的完整結構:
令牌更新佇列
為什麼我們本可以使用標準佇列卻還要用自己的佇列呢?這和我們的令牌更新模型有關。令牌一旦釋出,有效期就是一個小時,當令牌快要到期時,需要進行更新,而令牌更新必須在某個特定的時間點之前完成。
假設系統中斷了,但是我們有一堆已到期的令牌,而在我們更新這些令牌的同時,又有其他令牌陸續到期,雖然我們最後肯定能全部更新完,但是如果我們先更新那些即將到期的(60秒內),再用剩下的資源去更新已經到期的,是不是會更合理一些?(優先順序別最低的是還有4-5分鐘才到期的令牌)
用第三方軟體來實現這個邏輯並不是件容易的事,然而,對於Tarantool來說卻不費吹灰之力。看一個簡單的方案:在Tarantool中有一個儲存資料的元組,這個元組的一些ID設定了基礎key值,為了得到我們需要的佇列,我們只需要新增兩個欄位:status(佇列令牌狀態)和time(到期時間或其他預定義時間)。
現在我們考慮一下佇列的兩個主要功能—put和take。put就是寫入新資料。給定一些負載,put時自己設定好status和time,然後寫資料,這就是建立一個新的元組。
至於take,是指建立一個基於索引的迭代器,挑出那些等待解決的任務(處於就緒狀態的任務),然後核查一下是不是該接收這些任務了,或者這些任務是否已經到期了。如果沒有任務,take就切換到wait模式。除了內建Lua,Tarantool還有一些所謂的通道,這些通道本質上是互聯光纖同步原語。任何光纖都可以建立一個通道然後說“我在這等著”,剩下的其他光纖可以喚醒這個通道然後給它傳送資訊。
等待中的函式(等待發布任務、等待指定時間或其他)建立一個通道,給通道貼上適當的標籤,將通道放置在某個地方,然後進行監聽。如果我們收到一個緊急的更新令牌,put會給通道發出通知,然後take接收更新任務。
Tarantool有一個特殊的功能:如果一個令牌被意外發布,或者一個更新令牌被take接收,或者只是出現接收任務的現象,以上三種情況Tarantool都可以跟蹤到客戶端中斷。我們將每一個連線與指定給該連線的任務聯絡起來,並將這些對映關係保持在會話儲存中。假設由於網路中斷導致更新過程失敗,而且我們不知道這個令牌是否會被更新並被寫回到資料庫。於是,客戶端發生中斷了,搜尋與失敗過程相關的所有任務的會話儲存,然後自動將它們釋放。隨後,任意已釋出的任務都可以用同一個通道給另一個put傳送資訊,該put會快速接收和執行任務。
實際上,具體實施方案並不需要太多程式碼:
Put和take程式碼
function put(data) local t = box.space.queue:auto_increment({ ‘r’, -- [[ status ]] util.time(), -- [[ time ]] data -- [[ any payload ]] }) return t end function take(timeout) local start_time = util.time() local q_ind = box.space.tokens.index.queue local _,t while true do local it = util.iter(q_ind, {‘r’}, {iterator = box.index.GE}) _,t = it() if t and t[F.tokens.status] ~= ‘t’ then break end local left = (start_time + timeout) — util.time() if left <= 0 then return end t = q:wait(left) if t then break end end t = q:taken(t) return t end function queue:taken(task) local sid = box.session.id() if self._consumers[sid] == nil then self._consumers[sid] = {} end local k = task[self.f_id] local t = self:set_status(k, ‘t’) self._consumers[sid][k] = {util.time(), box.session.peer(sid), t} self._taken[k] = sid return t end function on_disconnect() local sid = box.session.id local now = util.time() if self._consumers[sid] then local consumers = self._consumers[sid] for k, rec in pairs(consumers) do time, peer, task = unpack(rec) local v = box.space[self.space].index[self.index_primary]:get({k}) if v and v[self.f_status] == ‘t’ then v = self:release(v[self.f_id]) end end self._consumers[sid] = nil end end
Put只是接收使用者想要插入佇列的所有資料,並將其寫入某個空間,如果是一個簡單的索引式FIFO佇列,設定好狀態和當前時間,然後返回該任務。
接下來要和take有點關係了,但仍然比較簡單。我們建立一個迭代器,等待接收新任務。Taken函式只需要將任務標記成“已接收”,但有一點很重要,taken函式還能記住哪個任務是由哪個程式接收的。On_disconnect函式可以釋出某個特定連線,或者釋出由某個特定使用者接收的所有任務。
是否有可選方案?
當然有。我們本可以使用任意資料庫,但是,不管我們選用什麼資料庫,我們都要建立一個佇列用來處理外部系統、處理更新等等問題。我們不能僅僅按需更新令牌,因為那樣會產生不可預估的工作量,不管怎樣,我們需要保持我們的系統充滿活力,但是那樣,我們就要將延期的任務也插入佇列,並且保證資料庫和佇列之間的一致性,我們還要被迫使用一個quorum的容錯佇列。此外,如果我們把資料同時放在RAM和一個(考慮到工作量)可能要放入記憶體的佇列中,那麼我們就要消耗更多資源。
在我們的方案中,資料庫儲存令牌,佇列邏輯只需要佔用7個位元組(每個元組只需要7個額外的位元組,就可以搞定佇列邏輯!),如果使用其他的佇列形式,需要佔用的空間就多得多了,大概是記憶體容量的兩倍。
總結
首先,我們解決了連線中斷的問題,這個問題十分常見,使用上述的系統讓我們擺脫了這個困擾。
分片幫助我們擴充套件記憶體,然後,我們將連線數從二次方減少到了線性,優化了業務任務的佇列邏輯:如果發生延期,更新我們所能更新的一切令牌,這些延期並非都是我們的故障引起的,有可能是Google、Microsoft或者其他服務端對OAuth服務提供商進行改造,然後導致我們這邊出現大量的未更新的令牌。
去資料庫內部運算吧,走近資料,你將擁有便利、高效、可擴充套件和靈活的運算體驗!
相關文章
- NoSQL 資料庫的主主備份SQL資料庫
- 學一點 mysql 雙機異地熱備份----快速理解mysql主從,主主備份原理及實踐MySql
- 詳解車企產品主資料規劃實施方案
- NoSQL 資料庫案例實戰 -- MongoDB資料備份、恢復SQL資料庫MongoDB
- 初探MySQL資料備份及備份原理MySql
- 資料庫備份方案資料庫
- Mysql 資料庫主庫,備庫實時同步配置MySql資料庫
- CDGA|主資料管理如何實施?
- MySQL主從配置及mysqldump備份MySql
- 備份主備庫都能用的指令碼(zt)指令碼
- BlueHost Linux主機建立資料完全備份圖文教程Linux
- 淺談資料庫備份方案資料庫
- sql server 資料庫備份方案SQLServer資料庫
- Dedecms備份的資料檔案位置及備份資料庫的方法資料庫
- mysql主備庫資料不一致的原因和解決方案MySql
- mysql資料庫的主從複製和主主複製實踐MySql資料庫
- DM8資料庫備份還原的原理及應用資料庫
- 某電信HP雙機主資料網路卡切換實施方案
- 【MYSQL實時備份】主從模式MySql模式
- Data Guard備份資料庫位置及目錄的選擇方案資料庫
- mongodb主從備份MongoDB
- MySQL主從備份MySql
- redis主從備份Redis
- Oracle資料庫三種備份方案Oracle資料庫
- 一個備份集同時恢出dataguard的主庫&備庫
- redis主備部署方案Redis
- MySQL備份與主備配置MySql
- 巧用天翼雲盤備份雲主機資料
- 09.redis 哨兵主備切換時資料丟失的解決方案Redis
- Mysql資料庫備份及恢復MySql資料庫
- BMMySQL定時備份資料庫(全庫備份)的實現meuMySql資料庫
- 執行主備庫切換以解決主庫儲存不足
- 主備庫切換以解決主庫儲存空間不足
- 從大資料量主庫建立備庫大資料
- 主備資料庫狀態手工比對(一)資料庫
- 主備資料庫狀態手工比對(二)資料庫
- Centos Mysql 主從備份CentOSMySql
- 實現MySQL資料庫的實時備份MySql資料庫