本文整理自 Redis 技术交流群的线上分享活动,有对 Redis 技术感兴趣的朋友可以加群主微信 gnuhpc 加入。
文章主要内容围绕 Redis-replicator 的设计与实现,提纲如下:
Redis-replicator 的设计动机
Redis-replicator 的设计与实现
Redis Replication 的协议简析
设计可插拔式 API 以及开发中的取舍
总结
首先,有两个材料可能需要大家提前预习一下,以便更轻松地了解此次分享的内容。
https://redis.io/topics/protocol
https://github.com/leonchen83/redis-replicator/wiki/RDB-dump-data-format
在之前的开发中,经常有如下需求:
Redis 数据的跨机房同步
异构数据的迁移,比如 Redis 到 MySQL、MQ
Redis 跨机房同步,传统的方式通常采取双写的方式,这样会生产一种非常难以维护的用户代码。稍微好一点的做法是提炼出一个中间层。但也难以保证同时双写成功,因此又需要做复杂的异常处理,这同时也增加了程序的响应时间。
除了双写的方式,还有一种方式是利用 Redis 自身的 Replication 协议,让一台机器成为另一台机器的 slave,用这种方式来同步数据。
这种方式的问题是,双机房中必须有一个是 master,一个是 slave。在切换的过程中,需要作 slave 提升等处理,变相增加了运维难度。而且一般在集群环境中,用户常常期望两个机房各一个独立集群,而不是两个机房组成一个混合集群(这样出问题切换方便些),并且保持两个独立集群之间数据是同步的。
如下图所示:
上面是属于同构数据迁移,再来说说异构数据迁移。现实需求中,有可能会有异构迁移的情况,比如 Redis 每日数据量很大,需要把一些数据以文件或者数据库存储的方式落盘(MySQL、MQ、SSDB 等),每日异地备份等等,如果还是采用双写等方式处理的话,又会有代码扩张、维护困难等上述提过的问题。
如下图所示:
在以上的需求中,催生了我开发 Redis-replicator 的动机。 这个工具完整实现了 Redis Replication 协议,并把 RDB 以及 AOF 解析成一个一个的事件供用户消费,并且支持 Redis4.0 的新特性以及新命令。
如果用 Redis-replicator 来实现上述需求的话,可以不干扰用户态的代码,单独用这个工具实现中间件来进行异构,同构数据同步备份等任务。
如下图所示:
那么讲完了动机,我们可以探寻一下 Redis-replicator 的实现。Redis-replicator 的架构如下所示 :
通用的代码如下:
Replicator replicator = new RedisReplicator("redis://127.0.0.1:6379");
replicator.addRdbListener(new RdbListener.Adaptor() {
// 解析 RDB 事件
@Override
public void handle(Replicator replicator, KeyValuePair> kv) {
System.out.println(kv);
}
});
replicator.addCommandListener(new CommandListener() {
// 解析 AOF 实时命令
@Override
public void handle(Replicator replicator, Command command) {
System.out.println(command);
}
});
replicator.open();
这里稍微对代码做一下解释,首先是 Redis 的 URI 表示redis://127.0.0.1:6379
,这种表示通过 socket 进行在线的实时数据同步,不但支持在线实时同步,而且 Redis-replicator 也可以进行离线的 RDB 以及 AOF 文件的解析,相应的 URI 修改为redis:///path/to/dump.rdb
或redis:///path/to/appendonly.aof
,其余的代码保持不变。
RdbListener 表示监听 RDB 事件,CommandListener 表示监听 AOF 事件。所以我们可以仅仅更改 URI 来做到远程同步和文件解析之间的自由切换。
在对架构和样例代码有一定了解之后,我们来了解一下源码的目录结构和一些关键的 class。
源码结构如下图所示:
上图中 cmd 包和 AOF 事件相关,比如在同步完 RDB 数据之后 master 写入了一条这样的命令set foo bar
,就会产生一条 Command 并触发 CommandListener。(重点类有 Command、CommandParser、CommandListener、ReplyParser)
event 包包含了 RDB 事件与 AOF 事件的基类 Event,以及包含两个自定义事件 PreFullSyncEvent 和 PostFullSyncEvent,这两个自定义事件标记了全量数据同步的开始和结束(增量同步不触发这两个标记事件)。
io、net、util 包与 Redis-replicator 的网络传输以及内部用数据结构相关,不多做介绍。
rdb 包和 RDB 事件相关,会把 RDB 的数据流解析成一个一个 KeyValuePair 并触发 RdbListener。同时这个包也包含了 Module 解析和自定义 RDB 解析器相关的类。(重点的类有 KeyValuePair、Module、ModuleParser、RdbVisitor、RdbParser)
还有根目录下的一些重点类:ReplicatorListener 包含用户所有可以注册的监听器,Configuration 包含一切可配置的参数,Replicator 是实现 Replication 协议的重要接口。
讲到这里,就再仔细说一下 Redis Replication 协议,很多同学以为这个协议很复杂,实现起来很困难。但实际上如果仔细了解这个协议的话,即使用 Java 这种略臃肿的语言,在 3000 行内也可以实现一个完整的同步协议(Redis-replicator 第一版 5000 行代码)。我鼓励大家也去用不同语言来实现 Redis 的同步协议,以丰富 Redis 的工具链。
具体的协议格式是一个非严格(这里的非严格是指 AOF 的格式有可能不是标准格式,因为有可能在两个 AOF 命令之间插入\n
)的 AOF 格式,第一个 AOF 是同步命令的回复,第二个 AOF 命令很特殊,是一个 RESP Bulk String,其内包含了 RDB 格式。
其余的 AOF 就是 master 的实时命令。了解 AOF 格式的话请参照 https://redis.io/topics/protocol,关于增量同步还是全量同步返回的格式也有不同,如下图所示:
第一个 AOF 是同步命令的回复,在同步之前我们要发送同步命令,比如 2.8 版本之前我们要发送SYNC
, 2.8 之后我们要发送PSYNC repl-id repl-offset
开启 PSYNC 同步,repl-id 占 40 字节,不知道 repl-id 的情况下发送?
, repl-offset 表示同步的 offset,不知道 offset 的情况下发送-1
,回复的话有可能是如下形式:+FULLRESYNC repl-id offset\r\n
或者+CONTINUE\r\n
或者 Redis-4.0 引入的 PSYNC2 回复+CONTINUE repl-id\r\n
上面我们说第二个 AOF 是一个 RESP Bulk String,那么其符合$payload\r\nRDB
(注意结尾没有\r\n
) 这样的形式,payload 表示要传输的 rdb 大小,内容的话就是一个完整的 RDB 文件。
关于 RDB 文件的格式,我做了一个 RDB data format wiki 供大家详细了解,在此不做赘述。
https://github.com/leonchen83/redis-replicator/wiki/RDB-dump-data-format
稍微需要注意的是,如果 redis-server 开启了repl-diskless-sync = yes
那么这个格式会稍有变化。
在 https://redis.io/topics/protocol 文档中 RESP Bulk String 还有一种没有提到的格式用在同步协议中, $EOF:<40 bytes delimiter>\r\nRDB<40 bytes delimiter>
,此时的 payload 变成EOF:<40 bytes delimiter>
所以在实现同步协议的时候需要注意。
第二点需要注意的是如果 master 产生的 RDB 特别巨大的时候,在同步 RDB 之前会发送连续的\n
以此来维持与 slave 的连接。所以同步的数据流有可能是这样的:
+FULLRESYNC8de1787ba490483314a4d30f1c628bc5025eb761 2443808505\r\n\n\n\n\n\n\n$payload\r\nRDB<其他 AOF 命令>
参照 https://redis.io/topics/protocol 进行解析。
在 RDB 传输完之后如果 master 端没有其他要同步的命令时,master 会定时发送PING
命令给 replicator。
在 RDB 传输完之后 replicator 要记录传输的字节数当作 offset,定时给 master 发送REPLCONF ACK offset
。
我们从第二节的代码中可以用很简单的方式与 Redis master 实现同步,这小节我们主要讲 Redis-replicator 的扩展性,从以下几个方面来详细说明:
当 Redis-server 版本升级到比如 4.2,有 STREAM 相关的新命令时如何扩展
当处理比如超过本机内存的大 KV 如何扩展
当加载 Redis-4.0 新特性 Module(比如 rejson)时如何扩展
先讨论第一点,当升级 Redis-server 有新的命令而 Redis-replicator 不支持时,可以使用命令扩展。
写一个命令解析器并注册进 Redis-replicator 中即可 handle 新的命令。一个详细的例子在 CommandExtensionExample,
https://github.com/leonchen83/redis-replicator/blob/master/examples/com/moilioncircle/examples/extension/CommandExtensionExample.java
再讨论第二点,由于 Redis-replicator 默认是把 KV 完全读到内存再交由用户处理的,当处理比如超过本机内存的大 KV 时,会引发 OOM。一个比较好的方法是以迭代的方式来处理大 KV。
在 Redis-replicator 中,可以注册自己的 RDB 解析器来应对这种情况,一个好消息是此工具已经内置了处理大 KV 的 RDB 解析器 ValueIterableRdbVisitor ,
https://github.com/leonchen83/redis-replicator/blob/master/src/main/java/com/moilioncircle/redis/replicator/rdb/iterable/ValueIterableRdbVisitor.java
与此相关的例子在 HugeKVSocketExample ,
https://github.com/leonchen83/redis-replicator/blob/master/examples/com/moilioncircle/examples/huge/HugeKVSocketExample.java
再讨论第三点,加载自定义 Module 时,可以实现自定义的 Module parser 并注册到 Redis-replicator 中,实现 Module 扩展,一个相关的例子在 ModuleExtensionExample,
https://github.com/leonchen83/redis-replicator/blob/master/examples/com/moilioncircle/examples/extension/ModuleExtensionExample.java
总结设计可插拔式 API 的重点是要求平等对待内建 (built-in)API 和外部 API。Redis-replicator 只提供了一个同步协议的大框架,其内的命令解析、RDB 解析、Module 解析都是可插拔的,这样可以提供最大的灵活性给用户。
4.2.1 无绪
最近我读完一本书很有启发,书名叫《软件框架设计的艺术》,书中提到了一个叫无绪
的概念,大意是当你依赖一个库,可以不用深入了解这个库的内部实现,就可直接根据 API 上手使用,并做出相对可靠的应用程序。
对这个概念我深以为然,但是这本书是我写完 Redis-replicator 之后才读到的,有一些不一致为了兼容性已经不可更改(有兴趣的朋友可以找一找代码存在的问题),但总体上根据 Redis-replicator 提供的文档以及 example 和对 issue 的快速回应以及修改可以让依赖此库风险可控。
4.2.2 兼容
同样还是《软件框架设计的艺术》这本书,提到了一个兼容性问题。书中有一句话:API 就如同恒星,一旦出现,便与我们永恒共存。大意就是一个 API 在被用户发现并使用了之后,就尽量不要做不兼容的修改,做出不兼容修改用户升级时会产生运行时错误等等问题,降低用户对一个库的好感度。我举一个在 Redis-replicator 中存在的例子。
用户实现自己的 RDB 解析器时需要继承 RdbVisitor 这个类,
https://github.com/leonchen83/redis-replicator/blob/master/src/main/java/com/moilioncircle/redis/replicator/rdb/RdbVisitor.java
这个类如果被设计成接口, Redis 每增加一个存储结构,这个接口就要增加一个方法,即使用户没用到这么高版本的 Redis 也要对实现类进行修改。设计成抽象类的话,每次升级 Redis-replicator,不会对用户代码造成影响,仅仅在同时升级了 Redis-server 的时候才会出现异常。
4.2.3 依赖
开发基础库上选择依赖一定要更加谨慎。因为 Java 的 jar hell 等原因,在一个稍微复杂的系统中,出现循环依赖,以及依赖同一个包的不同版本这种情况会经常发生。比如在一个工程中经常有多个版本的 slf4j-api、netty。在不实际运行的话很难发现问题。
第二点就是在设计公共库涉及写日志时,最好不要依赖具体的 log 实现,要尽量依赖 log 的 API(commons-logging、slf4j-api 等)。一个不好的例子是:
org.apache.zookeeper
zookeeper
3.4.11
这个包经常用在 ZooKeeper 客户端中比如 curator-client,然而这个包依赖了一个很低版本的 log4j 实现库,导致实际应该依赖 log 的 API 变成依赖于 log 的实现库,如果用户选择的是 logback 这种实现库来写日志的话,会有一些冲突,需要各种桥接来做 work around。在 Redis-replicator 中,唯一依赖的 jar 包是 commons-logging,尽最大程度保证用户与自己的工程依赖的兼容性。
限于篇幅和重点,并没有展现 Redis-replicator 的全部功能,比如此工具还可以做 RDB 及 AOF 文件的拆分与合并,RDB 格式转 Redis 的 dump 格式 (和 dump 命令得到的格式一致),以及 RDB 与 AOF 文件的备份和 Redis-4.0 混合格式的支持等。欢迎关注并 star Redis-replicator。
大型网站架构技术
程序员修炼之道
大型web系统数据缓存设计
基于 Redis 实现分布式应用限流
Cache缓存技术全面解析
京东到家库存系统分析
Nginx 缓存引发的跨域惨案
浅谈Dubbo服务框架
数据库中间件架构 | 架构师之路
MySQL优化精髓
看完本文有收获?请转发分享给更多人
欢迎关注“畅聊架构”,我们分享最有价值的互联网技术干货文章,助力您成为有思想的全栈架构师,我们只聊互联网、只聊架构!打造最有价值的架构师圈子和社区。
长按下方的二维码可以快速关注我们