實戰(zhàn):從副本同步到故障切換)
TDengine 的異地多活方案是我近半年反復(fù)推演和壓測最多的一類架構(gòu)。起因并不復(fù)雜我們運營的設(shè)備監(jiān)控平臺把幾十萬測點的數(shù)據(jù)全部集中到一個集群某個機房的光纜一斷整個寫入鏈路就會立刻報警業(yè)務(wù)側(cè)只能臨時降級而“降級”這個詞在運維值班溝通里往往意味著半夜加班。一開始我以為“異地多活”就是把 TDengine 的副本數(shù)調(diào)大、把 dnode 分散到兩個機房就完事了。真正推演之后才發(fā)現(xiàn)這條路在跨地域場景下幾乎走不通。這篇文章不打算只講概念而是把我們實際選型、拓?fù)湓O(shè)計、故障切換和長期治理的經(jīng)驗整理出來給想用 TDengine 構(gòu)建高可靠的時序數(shù)據(jù)中心的團隊一個可落地的參考。1. 為什么一家做設(shè)備監(jiān)控的團隊會盯上“異地多活”1.1 先回答多活和備份的本質(zhì)差異很多團隊把“多活”和“備份”混在一起這是第一個要糾正的認(rèn)知偏差。備份是保留一份數(shù)據(jù)副本平時不對外服務(wù)故障發(fā)生后再想辦法恢復(fù)多活則是多個數(shù)據(jù)中心同時承擔(dān)讀寫任何一個點故障時訪問路徑可以在分鐘級甚至秒級切換到另一個點業(yè)務(wù)基本無感。我們的設(shè)備監(jiān)控平臺是典型的時序場景設(shè)備側(cè)采集器不斷上報電壓、電流、溫度、運行狀態(tài)等遙測數(shù)據(jù)每秒峰值寫入超過十萬條測點。這些數(shù)據(jù)有三個明顯特點寫入不能隨便暫停、時間軸連續(xù)不能斷、業(yè)務(wù)對缺失窗口的容忍上限通常只有幾分鐘。一旦單機房停電或者出口光纜中斷采集器還在繼續(xù)產(chǎn)生數(shù)據(jù)本地緩存寫滿之后就開始丟點而后臺的分析報表、實時大屏、報警規(guī)則全都依賴這條時序列。備份工具恢復(fù)得再快也沒法解決“寫入入口已經(jīng)不可用”的問題因為從故障發(fā)生到備份拉起中間的歷史窗口已經(jīng)無法補救。所以我們設(shè)計的目標(biāo)不是“把數(shù)據(jù)復(fù)制一份”而是讓讀寫兩端在任何單點故障下都能繼續(xù)工作。RTO按分鐘算RPO按秒到分鐘算而不是把希望寄托在幾小時后的手工恢復(fù)上。1.2 什么時候該上異地多活什么時候不該硬上不是所有場景都適合異地多活。時序數(shù)據(jù)的寫入天然是分布式追加模式每一條記錄幾乎都是獨立事件沒有跨行事務(wù)、沒有復(fù)雜的并發(fā)修改這決定了它比關(guān)系型數(shù)據(jù)庫更容易做多活。但如果你的業(yè)務(wù)強依賴“全局唯一的設(shè)備ID”“原子遞增的序列號”或者“寫入后立即在所有節(jié)點讀到同一條數(shù)據(jù)”那就要謹(jǐn)慎了這類強一致需求不是時序數(shù)據(jù)庫的設(shè)計重點。我的建議是給數(shù)據(jù)分級。設(shè)備上報的遙測指標(biāo)可以接受幾秒甚至幾十秒的滯后適合走異步同步但控制指令、配置下發(fā)的狀態(tài)記錄直接影響設(shè)備動作必須保證不丟不重。分級之后核心少量表走強一致或者應(yīng)用層特殊處理海量遙測表走異步同步整體成本會低很多。千萬別把“所有時序數(shù)據(jù)一視同仁”當(dāng)成省事結(jié)果就是該保的沒保住該省的沒省下來。2. TDengine 自身的冗余機制replica、選主和多數(shù)派到底怎么工作2.1 從 dnode 到 vnode再到復(fù)制組想設(shè)計異地多活先得理解 TDengine 集群的冗余單位。一個集群由若干 dnode 組成dnode 就是跑 taosd 的物理節(jié)點或虛機集群里還有 mnode 負(fù)責(zé)管理元數(shù)據(jù)、用戶權(quán)限和庫表結(jié)構(gòu)mnode 本身也是一個多副本的 Raft 組。業(yè)務(wù)數(shù)據(jù)寫入時會按時間窗口拆分成多個 vnode每個 vnode 可以理解為一段數(shù)據(jù)集的獨立復(fù)制單元它的多個副本分散在不同的 dnode 上副本數(shù)量由建庫時的 replica 參數(shù)決定。replica1 表示單副本只解決存儲不解決高可用replica3 是最常見的集群高可用配置。注意這里的高可用指的是“單臺或兩臺機器故障時數(shù)據(jù)不丟、服務(wù)不中斷”它默認(rèn)的網(wǎng)絡(luò)環(huán)境是一個低延遲、穩(wěn)定的數(shù)據(jù)中心內(nèi)部網(wǎng)絡(luò)。一旦把這三個副本拆到不同城市、不同機房問題就開始變得復(fù)雜了因為每個 vnode 內(nèi)的數(shù)據(jù)同步都要跨物理距離。2.2 一次寫入的完整握手為什么跨地域后就慢了TDengine 的寫入路徑大概是這樣的客戶端把數(shù)據(jù)發(fā)給某個 vnode 的 leaderleader 先寫自己的 WAL然后把日志同步給同組的 follower得到多數(shù)派確認(rèn)后才給客戶端返回成功。這個過程不是“發(fā)出去就完事”而是要等網(wǎng)絡(luò)來回確認(rèn)。在同一機房的局域網(wǎng)里這個往返可能不到 1 毫秒你的寫入幾乎感受不到副本同步的開銷??梢坏┛绯鞘袑>€ RTT 也許就有 10 到 30 毫秒如果走公網(wǎng)或者鏈路不穩(wěn)定可能就是 50 毫秒往上。每次寫入都額外付出這么一段等待時間寫入吞吐量就會斷崖式下降。可以想象一個場景三個秘書在不同城市共同維護一份會議紀(jì)要寫一條記錄之前必須獲得至少兩個人的確認(rèn)而每一次確認(rèn)都要打一個跨城電話。大量記錄并行處理時通訊線路就是最大的瓶頸。這就是為什么“同一集群、replica3、節(jié)點橫跨兩地”在很多異地場景下跑不出理想性能。2.3 多數(shù)派陷阱三副本兩地分布不等于高可用這里有一個特別容易踩的坑。假設(shè)你在 A 機房放兩個 dnodeB 機房放一個 dnode整個集群三副本??雌饋怼皟蓚€機房都有副本應(yīng)該很安全”但 Raft 的多數(shù)派機制決定了如果 A 機房整體故障兩個 A 機房的副本同時失聯(lián)剩下的那個副本無法湊齊多數(shù)派整個庫會拒絕寫入。同樣的問題也出現(xiàn)在讀路徑上leader 選舉需要多數(shù)派客戶端訪問也需要大多數(shù)節(jié)點正常。所以在跨機房部署單一集群時我更推薦“三個機房各一個副本”這種三角形分布任何一個機房故障另外兩個機房的副本仍然構(gòu)成多數(shù)派服務(wù)可以繼續(xù)。但這樣一來網(wǎng)絡(luò)鏈路的數(shù)量、鏈路成本、運維復(fù)雜度都會上升本質(zhì)上是在用基礎(chǔ)設(shè)施復(fù)雜度換高可用。3. 三條跨地域數(shù)據(jù)流同步副本、異步同步、雙寫之間的取舍3.1 先給結(jié)論沒有銀彈只有分層異地多活沒有一種配置適合所有數(shù)據(jù)。我們最后采用的思路是按數(shù)據(jù)重要性和訪問模式把數(shù)據(jù)流拆成三類分別走不同路線。這樣既控制了成本也把真正核心的數(shù)據(jù)放到了最高的可靠性保障上。先看一張對比表再展開講每條路線的適用邊界。維度路線A同一集群跨地域多副本路線B兩套集群異步同步路線C應(yīng)用層雙寫一致性強一致Raft 多數(shù)派確認(rèn)最終一致滯后秒級到分鐘級取決于實現(xiàn)通常最終一致寫入性能受跨地域 RTT 影響明顯寫入各自集群互不阻塞兩個集群各寫一份應(yīng)用開銷大單點故障容忍需節(jié)點分布滿足多數(shù)派任一集群故障仍可服務(wù)任一集群故障仍可服務(wù)回切復(fù)雜度低自動選主中需設(shè)計反向同步高需業(yè)務(wù)層切換和補償運維復(fù)雜度低但網(wǎng)絡(luò)要求苛刻中需要維護同步鏈路高需要冪等機制適用距離同城/專線低延遲異地、跨地域小數(shù)據(jù)量關(guān)鍵表3.2 同步副本和獨立集群的根本沖突在“一致性和可用性”路線A的優(yōu)勢是簡單數(shù)據(jù)天然就是一份不存在兩套集群之間的差異。只要網(wǎng)絡(luò)足夠好比如同城雙中心、專線延遲穩(wěn)定在 5 毫秒以內(nèi)完全可以把 TDengine 三副本拆到兩個或三個機房利用 Raft 自動實現(xiàn)故障切換。它的短板也恰恰來自強一致網(wǎng)絡(luò)抖動會直接影響寫入成功率跨城市的長鏈路帶來的不確定延遲會放大每一次寫放大。路線B是我們主推的異地方案。每個區(qū)域部署一套獨立集群設(shè)備只寫入本區(qū)域集群然后把增量數(shù)據(jù)異步同步到中心或另一個集群。這樣做的好處是區(qū)域內(nèi)的寫入完全不受遠端網(wǎng)絡(luò)影響故障時本地采集鏈路照樣工作壞處是兩套集群之間有數(shù)據(jù)滯后故障切換時需要容忍一小段臟數(shù)據(jù)或數(shù)據(jù)空洞回切時還要處理“舊集群里新寫入的數(shù)據(jù)”和“新集群里已積累的數(shù)據(jù)”如何合并。異步同步的具體實現(xiàn)可以用官方提供的數(shù)據(jù)同步工具也可以自己寫增量拉取任務(wù)。數(shù)據(jù)方向一般是從生產(chǎn)集群同步到中心查詢集群或者核心生產(chǎn)集群同步到災(zāi)備集群。同步粒度通常按數(shù)據(jù)庫或者按表來控制沒必要把所有歷史冷數(shù)據(jù)都實時同步過去。3.3 雙寫需要注意冪等和亂序路線C聽起來最直接業(yè)務(wù)代碼把同一條數(shù)據(jù)同時寫入兩套集群。但實際做起來最折磨人。首先應(yīng)用側(cè)寫入成本翻倍其次兩條鏈路到達目的集群的順序可能不一致如果場景里存在“同一時間戳、同一設(shè)備”的多條記錄后寫入的不一定是最新狀態(tài)就需要在目標(biāo)端設(shè)計冪等或者版本策略。我們的經(jīng)驗是雙寫只用于少數(shù)關(guān)鍵表比如設(shè)備啟停記錄、告警確認(rèn)狀態(tài)這類數(shù)據(jù)量小但價值高的場景海量遙測數(shù)據(jù)走路線B的異步同步既不壓垮應(yīng)用也不會因為同步慢而阻塞核心鏈路。4. 從“雙集群”到“可切換”一份拓?fù)湓O(shè)計與配置示例4.1 一套多寫匯聚拓?fù)湮覀兟涞貢r的真實環(huán)境是三個物理位置兩個生產(chǎn)園區(qū) A、B一個中心機房 C。A 和 B 分別部署一套獨立的 TDengine 集群負(fù)責(zé)承載本園區(qū)設(shè)備的所有寫入中心機房 C 部署一套匯聚集群通過異步同步把 A、B 兩套集群的核心庫表匯聚到一起供數(shù)據(jù)分析和統(tǒng)一看板查詢。這套拓?fù)涞摹岸嗷睢斌w現(xiàn)在兩個層面寫入層面A 和 B 各自獨立存活一個人掛了另一個不受影響查詢層面中心集群掛了各園區(qū)的本地集群仍然可以繼續(xù)支撐本園區(qū)的實時查詢。故障切換時我們只需要切換接入域名或配置漂移不需要遷移任何歷史數(shù)據(jù)。4.2 庫表設(shè)計與同步實現(xiàn)每個集群的庫表結(jié)構(gòu)必須保持完全一致否則同步任務(wù)會持續(xù)報錯。我們在發(fā)布流程里統(tǒng)一管理建庫建表腳本保證幾個集群同時更新 schema。建庫示例大概是這樣的-- 每個園區(qū)生產(chǎn)集群 CREATE DATABASE edge_iot DURATION 1d KEEP 365d REPLICA 3 BUFFER 256 WAL_LEVEL 2; -- 中心匯聚集群副本數(shù)可以根據(jù)查詢量調(diào)整 CREATE DATABASE center_iot DURATION 1d KEEP 365d REPLICA 3 BUFFER 256 WAL_LEVEL 2;注意這里 REPLICA3 是單集群內(nèi)的副本數(shù)不是跨集群的同步關(guān)系??缂旱臄?shù)據(jù)復(fù)制走的是同步任務(wù)而不是讓兩套集群組成一個大集群。同步任務(wù)的邏輯看起來非常簡單但細(xì)節(jié)都在容錯上。我們用偽代碼描述核心思路# 從源集群增量拉取數(shù)據(jù)寫入目標(biāo)集群 last_ts query_target_last_ts(source_table_name) while True: rows fetch_from_source( startlast_ts, tablesource_table_name, batch1000 ) if not rows: sleep(5) continue write_to_target( target_sql, rows, upsert_strategylast_row_win ) last_ts max(row[ts] for row in rows)這里最關(guān)鍵的參數(shù)是last_ts。它不是一個簡單的時間戳而是要精確記錄“源端已經(jīng)拉到哪一條”并且要定時持久化否則同步任務(wù)一旦重啟就可能從頭開始或跳過一段數(shù)據(jù)造成空洞或重復(fù)。每個目標(biāo)表建議加上一個標(biāo)識來源機房的 tag比如site這樣中心集群在查詢時可以按來源域過濾。因為時序數(shù)據(jù)的主鍵本質(zhì)是時間戳加設(shè)備標(biāo)識不同園區(qū)如果存在相同的設(shè)備編號不加來源 tag 就會互相覆蓋這是一個我們在早期踩過的坑。4.3 流量切換入口盡量別改業(yè)務(wù)代碼流量切換最怕的是讓每一臺采集器去改配置。我們的做法是所有采集器的寫入地址統(tǒng)一指向一個內(nèi)部域名域名通過負(fù)載均衡解析到 A 或者 B 的集群中心查詢統(tǒng)一指向中心域名中心域名后面掛多個只讀地址。故障發(fā)生時運維只需要在負(fù)載均衡或 DNS 層把域名切到可用集群。應(yīng)用代碼不需要感知后端是誰連接池也會在短暫的失敗重連后自動恢復(fù)。為了讓這個切換更平滑客戶端配置里可以一次配置多個節(jié)點地址TDengine 原生驅(qū)動會在第一個地址不可用時嘗試后面的節(jié)點。5. 真正的考驗是故障切換一次跨地域切換的完整復(fù)盤5.1 一次突然的故障演練我們每季度會做一次切換演練。最近一次模擬的是 A 園區(qū)到中心機房的專線完全中斷A 園區(qū)集群自身還在正常運行但中心查詢集群的同步任務(wù)開始積壓。演練時間線大概是這樣同步時延監(jiān)控觸發(fā)告警值班人員在 5 分鐘內(nèi)確認(rèn)是專線中斷。A 園區(qū)采集鏈路仍然正常本地集群繼續(xù)寫入業(yè)務(wù)無感。中心集群由于缺少 A 園區(qū)數(shù)據(jù)大屏和報表出現(xiàn)局部缺口自動切換規(guī)則觸發(fā)后中心域名的查詢流量被切到 A 園區(qū)的本地查詢?nèi)肟凇M饺蝿?wù)進入“斷點續(xù)傳”模式每 5 秒探測一次鏈路鏈路恢復(fù)后自動追平。追平后我們把中心查詢域名切回中心集群并抽查最近 5 分鐘數(shù)據(jù)的延遲。這個過程聽起來順利但真正執(zhí)行時出現(xiàn)過好幾個問題。5.2 切換后真正卡殼的三個技術(shù)問題第一個問題是連接池緩存。我們的應(yīng)用服務(wù)啟動時會從配置中心拉取 TDengine 地址列表但連接池一旦建立不會感知域名切換后的新地址。那次演練中雖然域名解析已經(jīng)切到 A 園區(qū)但部分長期存活的應(yīng)用進程仍然持著舊連接等舊連接超時后才緩慢恢復(fù)。解決辦法不復(fù)雜在應(yīng)用側(cè)設(shè)置連接池的失敗重連周期并且不要用商用的 VIP 而用支持多地址的客戶端配置讓驅(qū)動自動嘗試備選節(jié)點。第二個問題是設(shè)備離線后補傳。一些老設(shè)備在專線中斷期間把數(shù)據(jù)緩存在本地恢復(fù)后一次性補傳時間戳可能往前回填。如果目標(biāo)端以同一時間戳的新數(shù)據(jù)覆蓋舊數(shù)據(jù)就可能把原本正確的記錄給覆蓋掉。后來我們在寫入時做了策略區(qū)分實時寫入的遙測數(shù)據(jù)不管順序后到覆蓋先到但狀態(tài)類、事件類數(shù)據(jù)單獨走雙寫用業(yè)務(wù)側(cè)維護的序列號做最終一致。第三個問題是報警規(guī)則的 label 漂移。中心集群的報警規(guī)則是基于中心數(shù)據(jù)庫里的表結(jié)構(gòu)配置的切換查詢源到 A 園區(qū)后報警上下文里的 site 字段含義變了導(dǎo)致一些告警誤報。這個問題的根源是元數(shù)據(jù)管理沒跟上拓?fù)湓O(shè)計現(xiàn)在我們會在每個 tag 里明確標(biāo)注來源機和路由路徑切換時規(guī)則自動帶上 site 過濾條件。5.3 回切比切換更折磨人故障恢復(fù)后的回切往往比故障切換更容易出錯。鏈路剛恢復(fù)時不能立刻把流量整體切回因為中心集群的數(shù)據(jù)還差一個時間窗此時切回來會讓大屏看到一段“過去”。穩(wěn)妥的做法是讓同步任務(wù)先追平確認(rèn)中心集群的 last_ts 已經(jīng)接近 A 園區(qū)集群再開啟只讀校驗最后再把寫流量或者查詢流量逐步切回。回切時還要處理一個容易忽視的問題A 園區(qū)集群在中斷期間積累的本地數(shù)據(jù)和中心集群里已同步的舊數(shù)據(jù)可能存在交叉窗口。我們通常以源端為準(zhǔn)把交叉窗口的數(shù)據(jù)做一次全量覆蓋回放保證中心集群的最終狀態(tài)和 A 園區(qū)一致。如果這個窗口恰好有幾百萬條記錄同步回放的時間要充分評估否則原以為半小時能完成的切換最后拖了幾個小時。6. 長期治理把多活從“能看”升級到“能扛”6.1 六個比進程監(jiān)控更重要的指標(biāo)異地多活做得怎么樣不是看集群進程活著沒有而是看數(shù)據(jù)流是否可持續(xù)。我們?nèi)粘6⒘鶄€指標(biāo)每個指標(biāo)都對應(yīng)一個具體故障模式。指標(biāo)含義建議監(jiān)控方式同步時延目標(biāo)集群最新時間戳與源集群的差值超過 30 秒告警斷點位置差值同步任務(wù)持久化的 last_ts 與實際源端差距持續(xù)增長說明鏈路卡死寫入失敗率各集群的寫入拒絕率和超時率超過千分之一關(guān)注重新選舉次數(shù)復(fù)制組 leader 切換次數(shù)突增往往意味著網(wǎng)絡(luò)抖動vnode 副本狀態(tài)副本數(shù)是否長期小于預(yù)期小于預(yù)期意味著冗余失效最后寫入時間目標(biāo)表級別最近一次成功寫入避免“表級靜默斷流”其中最后寫入時間尤其值得關(guān)注。有次我們只看集群整體寫入量完全正常但其實某張核心表的同步任務(wù)早就停了寫入量被其他大表掩蓋。加了表級最后寫入時間監(jiān)控之后這類問題當(dāng)天就能暴露。6.2 數(shù)據(jù)對賬與斷點續(xù)傳異步同步做得再精細(xì)也難免出現(xiàn)偶發(fā)丟數(shù)據(jù)。我們每天凌晨會跑一次對賬任務(wù)隨機抽取若干表比較源端和目標(biāo)端的 last_ts 以及近 24 小時行數(shù)。偏差超過閾值就把對應(yīng)表加入回補隊列用查詢接口拉取缺失區(qū)間重新寫入。斷點續(xù)傳機制不能只依賴“同步任務(wù)重啟后從頭掃”。我們每個同步任務(wù)的斷點狀態(tài)都放在一個獨立的狀態(tài)表里而且用 TDengine 自己來存這樣同步任務(wù)既處理業(yè)務(wù)數(shù)據(jù)也把自身狀態(tài)當(dāng)成時序數(shù)據(jù)管理。任務(wù)重啟后先從狀態(tài)表里讀最近提交的 last_ts再往上覆蓋一段 10 分鐘的冗余窗口寧可重復(fù)寫一點也不能留空洞。6.3 讓切換變成一件乏味的事異地多活最怕的不是技術(shù)復(fù)雜而是平時沒人敢碰切換按鈕。我們第一年只做桌面推演第二年做季度演練第三年把切換流程寫成了半自動腳本值班人員只需要在確認(rèn)故障范圍后點一個“確認(rèn)切換”。這一步特別重要。因為真正發(fā)生故障時現(xiàn)場往往一片混亂與其讓焦慮的人去翻手冊不如把決策路徑簡到最少。我們的腳本會先做健康檢查判斷各集群存活狀態(tài)和同步時延再根據(jù)預(yù)設(shè)策略選擇切換目標(biāo)最后執(zhí)行流量切換并驗證新集群的寫入和查詢。給準(zhǔn)備做異地多活的團隊一個建議不要一上來就追求“雙活”這個概念先定義清楚故障發(fā)生后的第一分鐘要做什么、第十分鐘要做什么、第一天要做什么。把這幾分鐘內(nèi)的動作做成腳本每季度演練一次剩下的工作都是為了讓我那句話變得可信切換已經(jīng)不是新鮮事而是整個時序數(shù)據(jù)中心最普通的例行操作。