編者薦語:
2019年4月16日跨機房Redis同步中間件(Rotter)上線,團餐率先商用:
以下文章來源於雲縱達摩院 ,作者楊海波
禧雲信息/研發中心/楊海波 20191115
關鍵詞:Rotter,Redis,多活,跨機房,同步
一、項目介紹
Rotter是禧雲自主研發的跨機房Redis雙向同步解決方案(下文簡稱為方案),具有零侵入、高吞吐量、低延時、高堆積能力等特點。當前版本支持Sentinel模式和單點模式Redis架構。從2019年Q2上線至今,服務於三個獨立業務線,線上運行穩定,同步延時基本在50ms以內。
1.1 系統架構
整個Redis跨機房雙向同步系統分為三層:
- 控制台Manager
- 節點發現和數據傳輸層(ZK+Redis)
- 數據同步層Rotter
系統架構如圖1-1

圖1-1
其中,
- Manager負責任務配置、數據展示、監控報警等,各機房獨立部署;
- ZK為跨機房集群,A機房為Leader節點,B機房為Follow節點。ZK在方案中負責Rotter節點的注冊發現和任務調度。
- 多活Redis在方案中扮演數據隊列的角色,降低了Manager和Rotter節點之間的耦合度。
- Rotter是Redis同步任務的執行者,包含replicator和sync兩個角色。
- replicator是國人開源的基於java語言的redis主從協議實現者redis-replicator,負責解析redis節點指令。
- sync負責redis指令跨機房寫入,處理同步回環,同步指令監控等。
1.2 同步流程
Rotter中采用鏈式處理同步數據,任何一個Filter返回失敗,該指令將不會同步。鏈式流程如圖1-2

圖1-2
- ParseEventFilter:格式化同步指令;生成指令回環校驗key;賦值指令所屬DB;過濾Rotter自身產生的指令
- DBFilter:過濾掉不需要同步的DB
- KeyFilter:過濾掉不需要同步的KEY
- CircleSyncFilter:過濾掉回環指令和刪除保護指令
- MultiThreadFilter:多線程分發,提高同步效率
- OvertimeFilter:經過隊列積壓,判斷該指令是否已經過期
- DeleteKeyFilter:生成刪除保護KEY
- RateLimitFilter:限流,包括帶寬和指令數
- SendTargetFilter:執行目標redis寫入
- MonitorFilter:監控和上報正在同步的指令
- ComputeRateFilter:計算同步速率、帶寬、隊列積壓長度等
二、背景&目標
2.1 背景
異地多活系統自2018年Q2上線以來,各核心業務系統都已陸續接入異地雙活架構。異地多活已經成為我司保障服務高可用的常規武器。在我司的異地雙活解決方案中,首先通過動態域名和阿里雲全局流量管理服務達到用戶流量的初步划分,然后在Nginx層根據流量規則對用戶流量精准轉發,確保同一個用戶的請求能夠穩定地請求到同一個機房,從而保證兩個機房不會同時修改同一份數據。
即便如此,可靠的跨機房的數據同步仍然是雙活方案最基本要求。多活業務依賴非多活業務數據或者流量調度時都依賴數據同步服務。MySQL層的數據同步是通過阿里巴巴開源的otter完成的,Redis的跨機房雙向數據同步業內缺少合適解決方案。
沒有Redis雙向同步,往往需要犧牲業務的時效性,或者業務系統需要寫一些額外的代碼來處理緩存數據缺失的情況;另外,用戶會話數據需要落到MySQL中來確保流量調度時用戶無感知。
2.2 目標
基於背景部分闡述,我們可以知道,我司的跨機房Redis同步方案是跨機房異地多活方案的一個延伸,最根本的訴求就是讓我司的業務不會因為接入多活系統受到限制,讓我們工程師可以像實現單機房業務一樣實現多機房業務。
所以,在項目啟動之初就定下以下目標:
- 零侵入:業務系統不需要做任何改造就能接入
- 高吞吐量:基於現有業務峰值TPS乘以10,得出TPS要達到1萬
- 低延時:我司的多活業務不會出現跨機房讀取數據的情況,所以定的目標延時低於1s。實際情況延時在50ms左右
- 高堆積能力:基於跨機房網絡的不確定性,當網絡閃斷時能夠保證指令不丟失
- 高可用性:當網絡故障或者Redis宕機恢復時,同步任務能自動恢復
- 可配置性:業務系統可以自由定制需要同步哪些Key
三、技術選型
要達到一條Redis指令寫入兩個機房的Redis節點,可以從client端、代理層、server端三個層面分別實現。
先說client端解決方案面臨的問題:
- 我們需要提供一套統一的工具包,為每個指令提供一個是否需要同步到另一機房的參數;
- 我們可能還需要為工具包提供同步寫另一機房還是異步寫另一機房的參數;
- 寫另一機房失敗本機房數據是否需要回滾?
- 現在java業務系統使用jedis和redisson都有,適配難度大,且不利於客戶端版本升級
所以,客戶端側解決方案被第一個否決了。
再來看代理層解決方案會面臨哪些問題:
- 代理的性能怎么樣,代理本身的延時高不高?
- 我司現有Redis架構基本都是Sentinel模式的,代理層解決方案每接入一個同步任務就需要重新調整Redis架構和網絡,對運維同學不太友好
- 代理層解決方案同樣面臨同步寫其他機房還是異步寫其他機房,寫其他機房失敗怎么處理的問題。
- 開源界的Redis代理項目基本都不是基於java語言的,對於我們團隊來說二次開發的難度較大。
代理層解決方案應該能解決我們的問題,但也不是特別理想。
接下來看看如果我們從服務端側解決,會面臨哪些問題:
- 我們需要解析到客戶端的指令
- 需要解決同步回環問題:A -> B -> A
解決掉這兩個問題,一個Redis指令寫入兩個機房的基本上就解決了。所以我們選擇了從server端同步來實現。Redis主從協議的實現采用的國人開源的redis-replicator;回環同步問題下文中會詳細講到。
四、實現細節
本節會詳細介紹一些具體問題的解決方案。
4.1 網絡架構
我司在網絡層用雙機房雙向VPN隧道打通局域網,北京到上海的網絡延時穩定在30ms左右。這樣我們的Redis節點就不需要暴露在公網,數據安全這塊就不需要考慮了。
4.2 同步回環
同步回環是指同一條指令在兩個Redis節點重復執行,看系統架構圖的下面一截:圖4-2

圖4-2
如果不對同步指令加任何干預,業務寫的一條指令會在A、B兩個機房的Redis節點上無限循環執行:
- 業務系統在A機房redis-A寫入指令 set a 1
- A機房replicator-A作為redis-A的從節點接收到指令set a 1
- rotter-A將指令set a 1寫入B機房redis-B
- B機房replicator-B作為redis-B的從節點接收到指令set a 1
- rotter-B將指令set a 1寫入A機房redis-A …
為了打破這個循環,我們采用了增加一個輔助key的辦法:
- 業務系統在A機房redis-A寫入指令 set a 1
- A機房replicator-A作為redis-A的從節點接收到指令set a 1
- rotter-A MD5(set a 1) 得到circle-key-md5,拼裝成指令setex circle-key-md5 120 1
- rotter-A將指令setex circle-key-md5 120 1和指令set a 1一起寫入B機房redis-B
- B機房replicator-B作為redis-B的從節點接收到指令setex circle-key-md5 120 1 和set a 1
- rotter-B直接忽略circle-key指令
- rotter-B在本機房執行del circle-key-md5,如果成功說明是回環KEY,不需要同步至A機房
4.3 高吞吐量
要提高吞吐量我們首先需要知道性能瓶頸在哪。從上面處理回環同步的問題我們可以看到,同步流程中存在兩處需要業務Redis交互:
- Rotter收到同步指令之后需要在本機房執行del circle-key-md5來判斷當前指令是否為循環指令
- Rotter將同步指令和circle-key-md5寫入另一機房Redis
這里我們有兩個武器:多線程和Pipeline。
多線程:
引入多線程的同時會引入另外一個問題:如何保證指令的順序性?
順序性的保證在同步回環校驗階段和跨機房寫入階段略有不同。
同步回環階段我們只要得到指令是否為回環指令就行,和各指令之間校驗的順序沒關系,但需要保證在往下一環節發送的時候是有序的。所以我們是采用線程隊列實現的。關鍵代碼如下 :
……
寫目標機房階段我們需要嚴格保證執行順序,假如業務系統執行兩條指令set a 1和del a,如果同步時執行順序反了,會對業務系統產生不可預估的后果。但如果是兩個不同的key,大部分場景下是可以交換執行順序的。
所以我們自己實現了一個簡單的有序線程池,對同步的key取hash后再取mod,mod值相同的指令放在同一線程執行,這樣就保證了同一個key的執行順序一定是有序的。同時我們還支持將指定的key分配到同一個線程執行,滿足業務系統存在key之間相互依賴的場景。
Pipeline:
通過redis pipeline批量執行指令能夠大量的減少Rotter和Redis的交互次數,但也會帶來一個問題:不能將指令阻塞在pipeline中太久而增加同步延時,所以我們需要另外一個線程來觸發提交pipeline數據,目前Rotter采取的策略是每100條或者10ms發送一次。
4.4 高堆積能力
由於跨機房網絡的不確定性,網絡隔離隨時可能發生。為了保證同步指令不丟失,我們需要把指令寫到磁盤文件中,等到網絡恢復時再從磁盤隊列讀出來寫到目標機房,
為了保證同步效率,我們這里使用的是磁盤和內存混合隊列MixBlockingQueue
……
下面我們再來看看磁盤隊列是怎么實現的
磁盤隊列包含兩個文件:數據文件和索引文件。數據結構如圖4-4

圖4-4
其中索引文件是定長的18字節:
- redisOffset(8字節):存儲每條同步指令在復制積壓緩沖區的offset,每次重新啟動同步任務時可以通過二分查找確定是否需要從磁盤中繼續讀取指令。
- dataOffset(8字節):存儲指定在磁盤文件中的位置
- dataLength(2字節):存儲當前指令的長度
由於索引文件中數據長度為2字節,所以單條指令長度最大值為32kb,超過此大小則會丟棄這條指令
五、其他問題
5.1 Rotter連接業務Redis時造成Redis全量dump而影響業務系統
背景知識一:復制積壓緩沖區
復制積壓緩沖區是一個保存在主節點的一個固定長度的先進先出的隊列,默認大小 1MB。 這個隊列在 slave 連接時創建。這時主節點響應寫命令時,不但會把命令發送給從節點,也會寫入復制緩沖區。他的作用就是用於部分復制和復制命令丟失的數據補救。通過 info replication 可以看到相關信息。
背景知識二:Redis主從同步協議
協議名
|
Redis版本
|
原理
|
問題
|
SYNC
|
2.8以下
|
每次都生成RDB文件,復制到slave節點
|
網絡秒級抖動都會造成master bgsave,影響服務可用性
|
PSYNC
|
2.8及以上
|
引入復制積壓緩沖區,當redis復制中斷后,slave上報原master runid + 當前已同步master的offset會嘗試批量同步
|
redis slave重啟或者 redis master發生故障切換,slave需進行全量重同步。
|
PSYNC2
|
4.0及以上
|
用master_replid1(當前主從復制ID)和master_replid2(上一次主從復制ID)取代了runid,故障切換后master_replid2替換為master_replid1。
|
背景知識三:Redis全量同步流程
- slave發送Replication ID, offset到master
- master開啟一個進程執行bgsave,生成RDB文件。在此期間所有新的指令都會緩存到當前slave的輸出緩沖區中
- master把RDB文件發送到slave
- slave把RDB文件加載到內存
- master把輸出緩沖區中的指令增量同步到slave
在這個過程中,如果Redis存儲數據量過大,會導致生成RDB文件大,對磁盤IO的壓力會非常大,一旦磁盤IO打滿會導致Redis進入假死狀態,進程沒掛,客戶端拿不到連接。
我們的解決方案有兩點:
- 優先連接Redis從節點,減少bgsave對業務的影響
- 配置同步任務時可以選擇是否忽略全量dump,當忽略全量dump時,Rotter會通過redis info命令拿到復制積壓緩沖區的offset,從最新的位置開始增量同步。
5.2 如何防止Rotter同步反向污染源Redis數據
Redis數據同步和MySQL數據同步是相互獨立的,當MySQL同步延時較大時可能會出現Redis數據反向污染的情況。案例如下:

圖5-2
如圖5-2流程所示,Rotter同步會造成業務系統原本已經A機房刪掉了記錄a又被同步到A機房Redis了。
為了解決這個問題,我們引入了刪除保護的概念:一段時間(默認2分鍾)內在A機房刪除的數據不會從B機房同步到A機房。具體做法如下:
- 圖5-2中第3步Rottor-A將del a指令同步到B機房之前,生成刪除保護指令setex rotter:delete:a 120 1,將這兩條指令都寫入redis-B
- 圖5-2中第6步Rotter-B同步指令set a oldValue先執行 exists rotter:delete:a,返回成功判定key a是剛被刪除的,不會同步A機房