RedisSyncer同步引擎的设计与实现 | 京东云技术团队

3,196 阅读11分钟

RedisSyncer 一款通过 replication 协议模拟 slave 来获取源 Redis 节点数据并写入目标 Redis 从而实现数据同步的 Redis 同步中间件。 该项目主要包括以下子项目:

  • redis 同步服务引擎 redissyncer-server
  • redissycner 客户端 redissyncer-cli
  • redis 数据校验工具 redissycner-compare
  • 基于 docker-compse 的一体化部署方案 redissyncer

本文主要介绍 reidssyncer 引擎(既 redissyncer-server)的设计与实现,以及引擎运行的机制。

同步流程

原生 redis master slave 模式主要分为两个阶段,第一个阶段同步 rdb 镜像,也就是全量同步部分;全量同步完成后进入命令传播模式,每个执行成功的数据变更操作会同步给 slave 节点。redissyncer 的模拟了这一机制并将两部分拆解,既可以执行完整同步任务也可以单独执行全量或增量同步。

  • 建立 socket

  • 发送 auth user password (6.0 新增 user)

          OK 成功
          其他 error
    
  • send->ping

           返回:
             ERR invalid password    密码错误
             NOAUTH Authentication required.没有发送密码
             operation not permitted 操作没权限
             PONG  密码成功
       
           作用:
              检测主从节点之间的网络是否可用。
              检查主从节点当前是否接受处理命令。
    
  • 发送从节点端口信息

          REPLCONF listening-port <port>
      
              -->OK 成功
              -->其他  失败
    
  • 发送从节点 IP

          REPLCONF ip-address <IP>
      
               --> OK 成功
               --> 其他  失败
    
  • 发送 EOF 能力(capability)

          REPLCONF capa eof
    
             --> OK 成功
             --> 失败
          作用:
             是否支持EOF风格的RDB传输,用于无盘复制,就是能够解析出RDB文件的EOF流格式。用于无盘复制的方式中。
             redis4.0支持两种能力 EOF 和 PSYNC2
             redis4.0之前版本仅支持EOF能力
    
  • 发送 PSYNC2 能力

           REPLCONF capa  PSYNC2
    
               --> OK 成功
               --> 失败
           作用: 
              告诉master支持PSYNC2命令 ,  master 会忽略它不支持的能力.  PSYNC2则表示支持Redis4.0最新的PSYN复制操作。
    
  • 发送 PSYNC

          PSYNC {replid} {offset}
    
            -->  FULLRESYNC  {replid}  {offset}   完整同步
            -->  CONTINUE 部分同步
            -->  -ERR 主服务器低于2.8,不支持psync,从服务器需要发送sync
            -->  NOMASTERLINK  重试
            -->  LOADING       重试
            -->  超过重试机制阈值宕掉任务
    
          读取PSYNC命令状态,判断是部分同步还是完整同步
    
  • PSYNC ---> 启动 heartbeat

          REPLCONF ACK <replication_offset>
          心跳检测
            在命令传播阶段,从服务器默认会以每秒一次的频率
            发送REPLCONF ACK命令对于主从服务器有三个作用:
          作用:
            检测主从服务器的网络连接状态;
            辅助实现min-slaves选项;
            检测命令丢失。
      
          REPLCONF  GETACK 
            ->REPLCONF ACK <replication_offset>
    

rdb 镜像同步完成后进入命令传播,master 会不断将变化数据推送给 slave。
为了保证
RedisSyncer 内部有断点续传、数据补偿、断线重连等机制来保证数据同步过程中稳定性和可用性,具体的机制如下。

断点续传机制

RedisSyncer 的断点续传机制是基于 Redis 的 replid 和 offset 来实现的,RedisSyncer 有两个版本的断点续传机制 v1 和 v2。

  • v1 版本:

v1 版本数据写入到目的端 redis 后,将 offset 持久化到本地,这样下次重启就从上次的 offset 拉取。但是由于该方案写目的端的操作和 offset 持久化不是一个原子的操作。如果中间发生中断会导致数据的不一致。 例如,先写入数据到目的端成功,后持久化 offset 还没成功就发生了宕机、重启等情况,那么再次断点续传拉取上一次的 offset 数据最后就不一致了。  

  • v2 版本:

在 v2 版本策略中 RedisSyncer 会将每一个 pipeline 批次中不存在事务的的命令通过 multi 和 exec 进行包装,并在事务尾部插入 offset 检查点。 当断点续传时需要从目标 Redis 的所以 db 库中查找 checkpoint 并找到所对应源节点当最大 offset,再根据该 offset 进行断点续传。目前 v2 版本只支持目标为单机 Redis 的情况。 在 v2 版本中

  • v2 命令事务封装结构

  • v2 checkpoint 检查点结构:

    HASH  hset redis-syncer-checkpoint {value}
    {value}:
        * {ip}:{port}-runid     {replid}
        * {ip}:{port}-offset    {offset}
        * pointcheckVersion     {version}
    

在 Redis 的事务机制中虽然不支持回滚,并且如果事务中间命令执行出错后但是事务还是被执行完成,但是除特殊情况外能够保证一致性。 在 v2 的机制中,为了防止 ' 写放大 ' 会在目标 redis 的每一个逻辑库中写入一个 checkpoint,因此在执行断点续传操作的时候,同步工具会先扫描目标各个逻辑库中的 checkpoint 并选出里面最大 offset 的 checkpoint 作为断点续传的参数。  

数据补偿机制

在数据同步过程中,存在由于网络稳定性或其他因素导致 key 写入失败的情况,为此 redissyncer 实现了一套补偿机制来保证源端与目的端数据的一致性。 数据补偿的前提是命令写入的幂等性,因此在 RedisSyncer 中会先将 INCR、INCRBY、INCRBYFLOAT、APPEND、DECR、DECRBY 等部分非幂等命令转换成幂等命令后再写入目标端 Redis。 RedisSyncer 在目标为单机 Redis 或者 Proxy 的时候是通过 pipeline 机制将数据写入到目标 Redis 中的,每一个批次的 pipeline 的提交会返回一个结果列表, 同步工具会验证 pipeline 中结果的正确性,如果部分命令写入失败,同步工具对该批次与该 key 相关的命令进行重试。 如果重试超过指定的阀值,将会宕掉任务。对于存在大 key 的 list 等非幂等结构,将不会进行数据补偿,强制结束任务待人工处理。

断线重连机制

 由于网络抖动等原因可能会导致同步工具源端与目标端连接在同步过程中断开,因此需要断线重试机制来保证在任务同步的过程中如果出现异常断开的问题。断线重连机制存在于与源 Redis 节点和 RedisSyncer、RedisSyncer 与目标 Redis 节点的连接之间,两者分别有各自的处理机制。  

  • 源端重连机制

    源 Redis 与 RedisSyncer 的断线重连机制是通过记录的 offset 来实现的,当因网络异常等原因断开了连接时,RedisSyncer 会重新尝试与源 Redis 节点建立连接,并通过当前任务记录的 runid、offset 等信息去拉取断开之前的增量数据,连接重新建立成功后 RedisSyncer 的同步任务将会无感知继续同步。当断线重连超过指定重试阀值或者因为 offset 刷过导致没有办法续传数据时,RedisSyncer 会宕掉当前当同步任务,等待人工干预。

  • 目标端重连机制

    RedisSyncer 与目标 Redis 之间的断线重连机制是通过缓存上一批次的 pipeline 的命令来实现的,当连接断开异常时 RedisSyncer 进行重重连回放上一批次写入失败的命令。当回放失败或者超过连续重试次数 RedisSyncer 会宕掉当前当同步任务,等待人工干预。

命令的链式处理

RedisSyncer 中采用链式策略处理同步数据,任何一个策略返回失败,该 key 都将不会被同步。链式策略流程如图所示

 每一个 key 在 RedisSyncer 都会经过一个策略链进行处理,只要有一个策略未通过则这个 key 将不会同步到目标 Redis,比如 key 过期时间的计算策略如果计算出全量阶段 key 已过期,则将会自动抛弃该 key。

策略链中的策略包括

类型策略描述
DataAnalysisStrategy命令统计分析
KeyFilterStrategy命令过滤
DbMappingStrategyDb 映射
TimeCalculationStrategy过期时间计算
RdbCommandSendStrategy全量数据写入
AofCommandSendStrategy增量数据写入
..........

 

任务管理

  • 任务启动流程

  • 任务停止及清理流程

    任务主动停止时,RedisSyncer 会先停止源 Redis 端的数据写入然后进入数据保护状态,确保可能还处在 RedisSyncer 中未写入目标的少部分数据能够完整的写入目标端,并且正确的记录写入的最后一条数据的 offset 并持久化,保证断点续传时 RedisSyncer 能够提供正确的 offset。

  • 任务状态

    TYPEcodedescriptionstatus
    STOP0任务停止已使用
    CREATING1创建中已使用
    CREATED2创建完成已使用
    RUN3运行状态已使用
    BROKEN5任务异常已使用
    RDBRUNING6全量 RDB 同步过程中已使用
    COMMANDRUNING7增量同步中已使用
    FINISH8完成状态已使用 (用于文件导入)
  • 任务异常处理原则

    在 RedisSycner 任务中如果遇到可能会导致数据不一致的错误,RedisSyncer 都会宕掉任务,等待人工干预。

rdb 跨版本同步实现

rdb 文件存在向前兼容问题,即高版本的 rdb 文件无法导入低 rdb 版本的 Redis

  • 跨版本迁移实现机制

    1. 对于可能存在大 key 的结构比如:SET,ZSET,LIST,HASH 等结构:
    2. 对于其他命令如:String 等结构: 为保证其命令幂等性,命令解析器会根据目标 REDIS 节点的 RDB 版本进行序列化 (实现 DUMP),传输模块会使用 REPLACE 反序列化到目标节点。(其中在 redis3.0 以下版本 REPLACE 命令不支持 [REPLACE])
    1. 对于对数据成员没有顺序性要求的命令如:SET,ZSET,HASH 命令解析器将其解析成一个或多个 sadd,zadd,hmset 等命令进行处理
    2. 对于对数据成员有顺序性要求的命令如:List 等命令,若被命令解析器判断为大 key 并将其拆分为多个子命令,此时必须保证按顺序发送至目标 REDIS 节点
    1. REDIS 跨版本间存在的问题: 由于 REDIS 是向下兼容 (低版本无法兼容高版本 RDB),在其 RDB 文件协议中存在一个 vesion 版本号标识,REDIS 在 RDB 导入或者全量同步执行 rdbLoad 时会先检测 RDB VERSION 是否符合向下兼容,如果不符合则会抛出 Can’t handle RDB format version   错误。
    2. syncer 跨版本实现机制 对于全量同步 RDB 数据部分 syncer 将其分命令为两类进行处理

RDB 文件协议中关于 RDB VERSION 部分

REDIS RDB文件结构开头部分示例
 ----------------------------# RDB is a binary format. There are no new lines or spaces in the file.
 52 45 44 49 53              # Magic String "REDIS"
 30 30 30 37                 # 4 digit ASCCII RDB Version Number. In this case, version = "0007" = 7   RDB VERSION字段
 ----------------------------
 FE 00                       # FE = code that indicates database selector. db number = 00

关于 RDB VERSION 检查部分伪代码

def rdbLoad(filename):
    rio =  rioInitWithFile(filename);
    # 设置标记:
    # a. 服务器状态:rdb_loading = 1
    # b. 载入时间:loading_start_time = now_time
    # c. 载入大小:loading_total_bytes = filename.size
    startLoading(rio)
    # 1.检查该文件是否为RDB文件(即文件开头前5个字符是否为"REDIS")
    if !checkRDBHeader(rio):
        redislog("error, Wrong signature trying to load DB from file") 
        return
    # 2.检查当前RDB文件版本是否兼容(向下兼容)
    if !checkRDBVersion(rio): 
        redislog("error, Can't handle RDB format version")
        return
 .........
    //Redis中关于RDB_VERSION检查的代码
    rdbver = atoi(buf+5);
    if (rdbver < 1 || rdbver > RDB_VERSION) {
        rdbCheckError("Can't handle RDB format version %d",rdbver);
        goto err;
    }

 

RDB 同步过程中的大 Key 拆分

RedisSyncer 在全量同步阶段在遇到 LIST、SET、ZSET、HASH 等结构等时候,当数据大小超过阀值后 RedisSyncer 会通过迭代器的形式将 key 拆分成多个子命令写入目标库。防止部分超大 key 一次性读入内存导致程序产生 oom 并提高同步的速度。而对于不存在大 key 的命令同步工具会通过序列化逆序列化的形式写入目标。

附录一  Redis RDB 协议

redis RDB Dump 文件格式

----------------------------# RDB is a binary format. There are no new lines or spaces in the file.
52 45 44 49 53              # Magic String "REDIS"
30 30 30 37                 # 4 digit ASCCII RDB Version Number. In this case, version = "0007" = 7
----------------------------
FE 00                       # FE = code that indicates database selector. db number = 00
----------------------------# Key-Value pair starts
FD $unsigned int            # FD indicates "expiry time in seconds". After that, expiry time is read as a 4 byte unsigned int
$value-type                 # 1 byte flag indicating the type of value - set, map, sorted set etc.
$string-encoded-key         # The key, encoded as a redis string
$encoded-value              # The value. Encoding depends on $value-type
----------------------------
FC $unsigned long           # FC indicates "expiry time in ms". After that, expiry time is read as a 8 byte unsigned long
$value-type                 # 1 byte flag indicating the type of value - set, map, sorted set etc.
$string-encoded-key         # The key, encoded as a redis string
$encoded-value              # The value. Encoding depends on $value-type
----------------------------
$value-type                 # This key value pair doesn't have an expiry. $value_type guaranteed != to FD, FC, FE and FF
$string-encoded-key
$encoded-value
----------------------------
FE $length-encoding         # Previous db ends, next db starts. Database number read using length encoding.
----------------------------
...                         # Key value pairs for this database, additonal database

FF                          ## End of RDB file indicator
8 byte checksum             ## CRC 64 checksum of the entire file.

RDB文件以魔术字符串“REDIS”开头。
52 45 44 49 53 # "REDIS"

RDB 版本号
接下来的 4 个字节存储 rdb 格式的版本号。这 4 个字节被解释为 ascii 字符,然后使用字符串到整数转换转换为整数。
00 00 00 03 # Version = 3

Database Selector
一个Redis实例可以有多个数据库。
单个字节0xFE标记数据库选择器的开始。在该字节之后,一个可变长度字段指示数据库编号。请参阅“长度编码”部分以了解如何读取此数据库编号。

键值对
在数据库选择器之后,该文件包含一系列键值对。
za
每个键值对有 4 个部分 -

1.密钥到期时间戳。
2.指示值类型的一字节标志
3.密钥,编码为 Redis 字符串。请参阅“Redis 字符串编码”
4.根据值类型编码的值。参见“Redis 值编码”

 

附录二 Redis RESP 协议

Redis RESP 协议

RESP 协议是在 Redis 1.2 中引入的,但它成为了 Redis 2.0 中与 Redis 服务器通信的标准方式。是在 Redis 客户端中实现的协议。 RESP 实际上是一种序列化协议,它支持以下数据类型:简单字符串、错误、整数、批量字符串和数组。

RESP 在 Redis 中用作请求 - 响应协议的方式如下:

  • 客户端将命令作为批量字符串的 RESP 数组发送到 Redis 服务器。
  • 服务器根据命令实现以其中一种 RESP 类型进行回复。

在 RESP 中,某些数据的类型取决于第一个字节:

  • 对于简单字符串,回复的第一个字节是 “+”
  • 对于错误,回复的第一个字节是 “-”
  • 对于整数,回复的第一个字节是 “:”
  • 对于批量字符串,回复的第一个字节是 “$”
  • 对于数组,回复的第一个字节是 “*”

RESP 能够使用稍后指定的批量字符串或数组的特殊变体来表示 Null 值。在 RESP 中,协议的不同部分总是以 “\r\n”(CRLF)终止。

RESP Simple Strings

'+' 字符开头,后跟不能包含 CR 或 LF 字符(不允许换行)的字符串,以 CRLF 结尾(即 “\r\n”)。如:

"+OK\r\n"

RESP Errors

 "-Error message\r\n"  

如:

-ERR unknown command 'foobar'
-WRONGTYPE Operation against a key holding the wrong kind of value

RESP Integers

Integers 只是一个 CRLF 终止的字符串,代表一个整数,以 “:” 字节为前缀。 例如

":0\r\n" 
":1000\r\n"

Bulk Strings

用于表示长度最大为 512 MB 的单个二进制安全字符串。批量字符串按以下方式编码:

  • “$” 字节后跟组成字符串的字节数(前缀长度),以 CRLF 结尾。
  • 实际的字符串数据。
  • 最后的 CRLF。

“foobar” 的编码如下:

"$6\r\nfoobar\r\n"

当字符串为空

"$0\r\n\r\n"

Bulk Strings 还可以用于表示 Null 值的特殊格式来表示值不存在。在这种特殊格式中,长度为 -1,并且没有数据,因此 Null 表示为:

"$-1\r\n"

RESP Arrays

格式:

  • 一个 '*' 字符作为第一个字节,然后是数组中元素的数量作为十进制数,然后是 CRLF。
  • Array 的每个元素的附加 RESP 类型。 空数组表示为:
"*0\r\n"

“foo” 和 “bar” 的数组表示为

"*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"

["foo",nil,"bar"] (Null elements in Arrays)

*3\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbar\r\n

作者:京东科技 贾世闻 展恩强

来源:京东云开发者社区