利用redissyncer實現(xiàn)數(shù)據(jù)雙向同步

不知不覺【數(shù)據(jù)遷移專題】已經(jīng)進行了兩期,在先前《跨越異構鴻溝,Redis 遷移同步過程中的挑戰(zhàn)與解決方案》和《在線數(shù)據(jù)遷移,數(shù)字化時代的必修課》中,我們?yōu)榇蠹医榻B了數(shù)據(jù)遷移挑戰(zhàn)與技術選型,并詳細分享京東云自研開源的RedisSyncer 項目。本篇是系列內(nèi)容第三篇,我們來聊一聊如何用RedisSyncer實現(xiàn)數(shù)據(jù)雙向同步。
RedisSyncer是京東云自研的redis多任務同步中間件工具集,應用于redis單實例及集群同步。該工具集包括:
redis 同步服務引擎 redissyncer-server redissycner 客戶端 redissyncer-cli redis 數(shù)據(jù)校驗工具 redissycner-compare 基于docker-compse的一體化部署方案 redissyncer
目前在github開源:
https://github.com/TraceNature/redissyncer-server
雙向同步是指在兩個實例都有存量數(shù)據(jù)和寫流量的情況下進行兩實例同步,最終達到兩實例數(shù)據(jù)動態(tài)一致的過程 緩存數(shù)據(jù)全局可讀,防止緩存擊穿 保證緩存命中率,為數(shù)據(jù)庫減壓 當單一數(shù)據(jù)中心發(fā)生故障時,保證數(shù)據(jù)在另一中心完全可見
原生redis同步無法區(qū)分緩存數(shù)據(jù)來源 由于redis本身沒有實例標識(類似mysql的GTID),在雙向同步時形成數(shù)據(jù)回環(huán) redis環(huán)狀緩沖區(qū)覆蓋后,數(shù)據(jù)混淆且難于清理
利用數(shù)據(jù)沖銷的方式破除數(shù)據(jù)寫入環(huán)。該方案的必要條件是,同步的實例或集群寫入的key無沖突,即在數(shù)據(jù)中心A寫入的key,不會同一時間在B中心寫入相異值。
假設我們有兩個redis實例redis1和redis2,再分別定義兩個沖銷池set1和set2,記錄key及同步次數(shù)。
啟動redis1->redis2的全量同步任務 啟動redis1->redis2增量任務 2-1。增量任務先在set1做沖銷(set1中存在的數(shù)據(jù)刪除并丟棄同步) 2-2. 未被沖銷數(shù)據(jù)同步到redis2 啟動redis2->redis1的全量任務,此全量同步數(shù)據(jù)一定會作為增量形成回環(huán),所以要先寫入set1再寫入redis1,以便數(shù)據(jù)作為增量回環(huán)同步到redis2時利用set1沖銷 3-1,寫入set1 3-2,寫入redis1 啟動redis2->redis1增量任務,增量任務先在set2做沖銷(set2中存在的數(shù)據(jù)刪除并丟棄同步),增量任務先寫入set1在寫入redis1避免循環(huán)復制 4-1。通過set2沖銷數(shù)據(jù) 4-2,數(shù)據(jù)寫入set1 4-3,數(shù)據(jù)寫入redis1 改變redis1->redis2增量任務行為,增量任務先在set1做沖銷(set1中存在的數(shù)據(jù)刪除并丟棄同步),未沖銷數(shù)據(jù)先寫入set2再同步到redis2 5-1 寫入set2 5-2 寫入redis2

為什么增加第五步改變redis1->redis2增量任務行為呢?因為在第四步完成時set2中并沒有從redis1->redis2的增量數(shù)據(jù),這會造成從redis1->redis2的增量數(shù)據(jù)會轉(zhuǎn)換成redis2->redis1增量數(shù)據(jù)且在本地無法被沖銷,只有數(shù)據(jù)進入set1且被寫入redis1后再次作為增量數(shù)據(jù)向redis2同步時才會被沖銷,增加了網(wǎng)絡開銷同時redis1也增加了一次寫入負載。
數(shù)據(jù)沖銷方式及其缺陷
數(shù)據(jù)沖銷方式需要在每次發(fā)送數(shù)據(jù)前對數(shù)據(jù)進行緩沖,正常情況下緩沖內(nèi)存占用不大。但當網(wǎng)絡阻塞或由于網(wǎng)絡不暢無法沖銷數(shù)據(jù)時,會造成緩沖區(qū)暴增導致OOM 數(shù)據(jù)沖銷方式帶來的冷啟動問題 當任務異常中斷且redis offset被覆蓋的情況下,因為數(shù)據(jù)矯正依據(jù)缺失,需要重建緩存 若采用數(shù)據(jù)持久化的方式先持久化后發(fā)送的方式,那么在沖銷過程中會大大降低同步效率
業(yè)務雙寫是最符合人類直覺的雙向方案,同一份數(shù)據(jù)寫入兩個數(shù)據(jù)中心以保障數(shù)據(jù)冗余。但是在實際操作中會遇到數(shù)據(jù)寫入順序問題。
雙寫方案中的數(shù)據(jù)順序問題

并發(fā)環(huán)境中同時寫入同一個key的情況下,并不能保障key寫入redis的順序。造成key的結果不一致。

通過統(tǒng)一隊列解決順序問題 網(wǎng)絡中斷導致數(shù)據(jù)缺失 強一致性會導致單機房不可用的情況下寫操作全局不可用,并需要在數(shù)據(jù)在某一次提交不完全成功的情況下提供回滾機制、及數(shù)據(jù)補償機制 隊列帶來寫效率損失,redis失去作為緩存層的意義

每個數(shù)據(jù)中心建立redis 讀寫實例與只讀實例 讀寫數(shù)據(jù)中的落地數(shù)據(jù)通過redissyncer同步到對端只讀實例 應用讀數(shù)據(jù)時先讀取只讀實例若有數(shù)據(jù)返回則返回;若無數(shù)據(jù)返回則讀取讀寫實例 雙讀方案的限制條件 key的生成在全局具有唯一性既兩個中心不出現(xiàn)重復的key 避免incr 、 lpush 等非冪等操作 由于網(wǎng)絡抖動可能造成數(shù)據(jù)流中斷,盡管redissyncer以及對非冪等命令做了處理,但是極端情況仍然可能造成數(shù)據(jù)不準確影響業(yè)務
環(huán)境列表
| 主機名 | IP地址 | 部署軟件或工具 |
|---|---|---|
| az_a1 | 10.0.0.110 | redis5.0 |
| az_a1 | 10.0.0.110 | redissyncer |
| az_a2 | 10.0.0.111 | redis5.0 |
| az_b1 | 10.0.0.112 | redis5.0 |
| az_b1 | 10.0.0.112 | redissyncer |
| az_b2 | 10.0.0.113 | redis5.0 |
az_a1 代表 a 中心的 redis RW 實例;az_a2 代表 a 中心 redis RO 實例;az_b1 代表 b 中心 redis RW 實例;az_b2 代表 b 中心 redis RO 實例
分別在 az_a1、az_b1上部署 redissyncer 用于同步到對端數(shù)據(jù)中心
通過 redisdual 模擬雙讀客戶端
實施細則
部署 redis 詳見 redis 部署文檔,這里不累述 部署redissyncer(docker方式); az_a1、az_b1 上執(zhí)行 clone redissyncer 項目 1git clone https://github.com/TraceNature/redissyncer.git
2cd redissyncer
3docker-compose up -d部署 redissyncer-cli 用于與服務器通訊
下載客戶端程序
1wget https://github.com/TraceNature/redissyncer-cli/releases/download/v0. 1.0/redissyncer-cli-0.1.0-linux-amd64.tar.gz
2
3tar zxvf redissyncer-cli-0.1.0-linux-amd64.tar.gz配置.config.yaml 文件
1# redissyncer-server 訪問地址及端口
2syncserver: http://127.0.0.1:8080
3# 訪問 redissyncer-server 的 token??梢酝ㄟ^ redissyncer-cli login 命令獲得。默認用戶名 admin 默認密碼 123456.完整命令 redissyncer-cli login admin 123456
4token: 379F5E2BD55A4608B6A7557F0583CFC5az_a1 配置同步任務同步到 az_b2 編輯任務文件 synctask/a1_to_b2.json 1{
2"sourcePassword": "redistest0102",
3"sourceRedisAddress": "10.0.0.110:16375",
4"targetRedisAddress": "10.0.0.113:16375",
5"targetPassword": "redistest0102",
6"taskName": "a1_to_b2",
7"targetRedisVersion": 5.0,
8"autostart": true,
9"afresh": true,
10"batchSize": 100
11}啟動任務
1redissyncer-cli-0.1.0-linux-amd64 -i
2redissyncer-cli> task create source synctask/a1_to_b2.json;az_b1 配置同步任務同步到 az_a2
編輯任務文件 synctask/b1_to_a2.json
1{
2 "sourcePassword": "redistest0102",
3 "sourceRedisAddress": "10.0.0.112:16375",
4 "targetRedisAddress": "10.0.0.111:16375",
5 "targetPassword": "redistest0102",
6 "taskName": "b1_to_a2",
7 "targetRedisVersion": 5.0,
8 "autostart": true,
9 "afresh": true,
10 "batchSize": 100
11}啟動任務
1redissyncer-cli-0.1.0-linux-amd64 -i
2redissyncer-cli> task create source synctask/b1_to_a2.json;通過redisdual 模擬redis 雙讀
克隆 https://github.com/TraceNature/redisdual.git 項目自行編譯 config.yaml 文件參數(shù)詳解
1# 日志配置不需改動
2zap:
3level: 'debug'
4format: 'console'
5prefix: '[redisdual]'
6director: 'log'
7link-name: 'latest_log'
8show-line: true
9encode-level: 'LowercaseColorLevelEncoder'
10# stacktrace-key: 'stacktrace'
11log-in-console: true
12
13# 執(zhí)行時間間隔,單位毫秒,可以控制每次執(zhí)行的間隔時長,便于觀察日志
14execinterval: 1
15
16# 循環(huán)執(zhí)行最大步長,當大于步長時歸零;當達到步長時,重新執(zhí)行寫入和雙讀操作
17loopstep: 30
18
19# 本地key前綴,區(qū)分本地寫入key和remote端寫入的key
20localkeyprefix: a
21remotekeyprefix: b
22
23# redis 讀寫實例
24redisrw:
25db: 0
26addr: '114.67.76.82:16375'
27password: 'redistest0102'
28# redis 只讀實例
29redisro:
30db: 0
31addr: '114.67.120.120:16375'
32password: 'redistest0102'主要代碼分析 redisdual/cmd/start.go;func dual(rw *redis.Client, ro *redis.Client, key string) 函數(shù).雙讀的主要邏輯,先讀RO實例,有返回值輸出,無返回值讀RW庫,有返回值輸出,無返回值結束查詢 1func dual(rw *redis.Client, ro *redis.Client, key string) {
2 roResult, err := ro.Get(key).Result()
3
4 if err == nil && roResult != "" {
5 global.RSPLog.Sugar().Infof("Get key %s from redisro result is:%s ", key, roResult)
6 return
7 }
8
9 rwResult, err := rw.Get(key).Result()
10 if err != nil || rwResult == "" {
11 global.RSPLog.Sugar().Infof("key %s no result return!", key)
12 return
13 }
14
15 global.RSPLog.Sugar().Infof("Get key %s from redisrw result is: %s ", key, rwResult)
16
17}redisdual/cmd/start.go;func startServer() 函數(shù)。啟動服務,定時執(zhí)行RW實例寫入。并執(zhí)行雙讀操作 1// -d 后臺啟動
2if global.RSPViper.GetBool("daemon") {
3cmd, err := background()
4if err != nil {
5panic(err)
6}
7
8//根據(jù)返回值區(qū)分父進程子進程
9if cmd != nil { //父進程
10fmt.Println("PPID: ", os.Getpid(), "; PID:", cmd.Process.Pid, "; Operating parameters: ", os.Args)
11return //父進程退出
12} else { //子進程
13fmt.Println("PID: ", os.Getpid(), "; Operating parameters: ", os.Args)
14}
15}
16
17global.RSPLog = core.Zap()
18global.RSPLog.Info("server start ... ")
19
20pidMap := make(map[string]int)
21// 記錄pid
22pid := syscall.Getpid()
23pidMap["pid"] = pid
24
25pidYaml, _ := yaml.Marshal(pidMap)
26dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
27if err != nil {
28panic(err)
29}
30
31if err := ioutil.WriteFile(dir+"/pid", pidYaml, 0664); err != nil {
32global.RSPLog.Sugar().Error(err)
33panic(err)
34}
35global.RSPLog.Sugar().Infof("Actual pid is %d", pid)
36
37//redis 讀寫實例
38redisRW := GetRedisRW()
39
40//redis 只讀實例
41redisRO := GetRedisRO()
42
43//清理RW
44redisRW.FlushAll()
45
46global.RSPLog.Sugar().Info("execinterval:", global.RSPViper.GetInt("execinterval"))
47loopstep := global.RSPViper.GetInt("loopstep")
48i := 0
49for {
50if i > loopstep {
51i = 0
52}
53key := global.RSPViper.GetString("localkeyprefix") + "_key" + strconv.Itoa(i)
54redisRW.Set(key, key+"_"+strconv.FormatInt(time.Now().UnixNano(), 10), 3600*time.Second)
55dual(redisRW, redisRO, global.RSPViper.GetString("localkeyprefix")+"_key"+strconv.Itoa(i))
56dual(redisRW, redisRO, global.RSPViper.GetString("remotekeyprefix")+"_key"+strconv.Itoa(i))
57i++
58time.Sleep(time.Duration(global.RSPViper.GetInt("execinterval")) * time.Millisecond)
59}
啟動redisdual 并觀察日志
redisdual start
redis的雙向同步方案的機制大致就是以上三種,具體生產(chǎn)中采用哪種方式要根據(jù)業(yè)務特性進行權衡。從數(shù)據(jù)安全和維護成本方面考慮,雙讀方案從運維成本來講是最少的,且在故障發(fā)生時不會引起數(shù)據(jù)混淆。



