如何设计一个实时数据同步系统
前面文章介绍了我们开源的 redis 实时同步工具《我们开源啦》,今天,我们来一起了解下如何实现一个实时数据同步工具。
为什么要自研,使用开源方案不行吗?
我们调研了业界主流的几个工具,都有一些无法满足我们需求的地方,如 redis-shake 要求源 redis 和目的 redis 分片必须对称,不支持高可用,xpipe 不支持拓扑变化等等。
所以,我们自研了 redis-GunYu 来实现这些需求。
下面我们对比了这几个主流工具和 redis-GunYu 的差异:
redis-GunYu 还有一些其他优化点,如
对稳定性影响更小
复制优先级:可用指定优先从从库进行复制或主库复制
对 RDB 中的大 key 进行拆分同步
数据安全性
本地缓存支持数据校验
对 redis 限制更少
支持源和目标端不同的 redis 部署方式
兼容源和目的 redis 不同版本
运维更加友好
数据过滤:可以对某些正则 key,db,命令等进行过滤
API:可以通过 http API 进行运维操作,如强制全量复制,同步状态,暂停同步等等
监控:监控指标更丰富,如时间与空间维度的复制延迟指标
架构
如上图所示,源 redis 集群有 3 个节点,目标 redis 集群是 4 个节点;
同步工具集群由 2 个节点组成:
每个节点都会有 3 个同步模块(因为源 redis 有 3 个分片),互为主备,P2P 对等结构,将工具故障带来的影响降到最低。
两个同步节点各自 3 个同步模块会独立进行选举,选出最新数据的节点为 leader,其他节点为 followers,followers 从 leader 同步数据;如果 leader 节点故障,则最新数据的 follower 选举成为 leader。
同步模块的内部实现
每个同步模块都分如下三个子模块
输入端:伪装成 redis slave,从源 redis 节点(主节点或从节点)同步数据
通道端:即本地缓存,缓存 RDB 和 AOF 数据
输出端:从本地缓存读取数据写入到目标端 redis 主库
集群
高可用
我们希望同步工具本身是高可用的,一个节点故障,其他节点可以接管其任务,继续提供服务。
同时,针对每个源 redis 节点,必须保证只有一个节点进行数据同步,否则数据就可能不一致,所以要选举出一个节点为 leader,只有 leader 才能同步数据。
在分布式系统中,选举的实现通常分为两种
协商式:
最新数据投票
适用于有状态服务
选举时间长
抢占式:
先到先服务
适用于无状态服务
选举时间短
然而 redis 同步工具的选举有自己的特点
半状态化:有状态(缓存数据),但数据并不重要
选举时间:越快越好
那么 redis-GunYu 该用哪种方式呢?
我们结合了两种实现方式,对其优缺点进行了取舍。
如下图,有 3 个同步节点组成一个集群,同时依赖 etcd 集群辅助进行选举。
如果 1 个节点故障,那么其他 2 个节点会进入选举流程,如下图,A 先发起竞选,所以 A 成功了,A 暂时成为领导者(leader),但 B 以跟随者(follower)身份连接到 leader 后,leader 检查到 B 的数据更新,则会将领导权移交给节点 B。
为什么要这么设计呢?
我们先思考下几个问题:
同步工具主从节点之间的数据延迟有多大?
是快速继续服务重要还是最新数据重要?
1 秒钟能从 redis 源端同步多少数据?
先进行抢占式选举:
能够减少选举时间,快速选出 leader
同步工具主从延迟很低;就算抢占式选举出的 leader 数据不是最新的,由于同步的速度非常快,也会马上追上最新源端数据
再以数据新旧进行协商:
如果选出的 leader 数据落后很多,那么其他拥有更新数据的节点连接到 leader 后,leader 会将领导权移交给最新数据的 follower。所以如果新 leader 无法在瞬间追上源 redis 节点的数据,也会被其他节点抢夺 leader 权,这样就降低了源和目的 redis 数据差异
关于脑裂
试想一下,如果执行一条 INCR 操作,存在两个 leader,那么这条操作就会被执行两次。
还有更极端的情况,如下图所示,针对同一个 key 做 SET、DEL、SET 操作,如果两个 leader,那么可能一个先执行了,另一个 leader 执行到 DEL 就退出了,那么这个 key 就被永远删除了。
所以,要避免同时存在两个 leader 的情况发生。
在实现上,每个 leader 都有一个租期,leader 要定期去 etcd 续租,否则,租期就会过期,即失去领导权。
如下图,租期为 3 秒,则每隔 1 秒续租一次,如果失败重试一次,再失败则租期失效,集群重新进入选举状态。
拓扑感知
在实际生产环境中,会对 redis cluster 进行扩容、缩容、槽位迁移、主从切换等操作,这些操作都会导致拓扑结构的变化,而我们要感知到这些变化以更改同步的策略,下面几种情况需要调整策略:
如前面所讲,每一个源 redis 节点,都会有一个对应的同步模块,扩容、缩容都会增加 redis 节点,那么同步模块就要相应增加或减少
如果用户指定从源 redis 集群的主节点进行同步,主从切换后,我们要能够切换到新主节点上。
我们的一致性策略是和拓扑相关的,如果拓扑结构变化,要检查是否需要切换一致性策略。
本地缓存
先看看本地缓存的几个需求
数据管理
RDB 和 AOF 数据的管理
读写 RDB 和 AOF 数据
超过容量限制,回收缓存数据
数据监控
读、写监控指标
数据安全
数据校验
损坏数据处理
数据持久化策略
缓存数据的组织
每一个同步模块都对应一个缓存管道,管道由 RDB 和 AOF 有序地排列组成。
实现中,用[left, right)区间来描述管道的数据范围,left 表示最老数据,right 表示最新数据;每个 AOF 和 RDB 都有自己的 left 和 right 来表示其所拥有的数据范围。
AOF 的 left 是 AOF 文件数据的起始偏移量(对应源 redis 节点的数据偏移),right 等于 left+AOF 大小
RDB 比较特殊,left 表示为 0,right 表示 redis 创建 RDB 文件时的当前快照偏移量。
垃圾回收与读写
如果缓存数据量超过最大限制的阈值,则会触发垃圾回收,删除掉较老的 RDB 或 AOF,将缓存数据量降低到阈值之下。
redis-GunYu 会优先回收老数据,但如果数据文件有被引用(正在读写),则停止回收,等待引用为 0 再启动回收。
持久化策略
一个 IO 操作的 IO 栈是非常深的,从用户态到内核态,再到设备本身,都要经过一层层的缓存,每一个缓存都可能在程序崩溃、OS 故障、机器断电等异常情况下丢失数据。而缓存的存在又是架构抽象和设备管理的必然产物,正如名言“计算机科学中的所有问题都可以通过增加一个间接层来解决”说的一样。
下图展示了一个 IO 操作要经过的缓存(页缓存和块缓存物理上是指向同一内存的)。
为了提高读写性能,缓存有自己的持久化策略,不会因为用户每次调用 write 都会将数据写入到磁盘,否则对于写操作来说,就会造成严重的写放大。
例如内存页大小是 8KB,而我们一条命令的大小可能是 10B,如果写入 10B 的数据,马上就持久化到磁盘,就会实际造成 8KB 数据的写入,带来了严重的写放大,增加了写入延迟,同时也会缩短磁盘使用寿命。
所以,我们要在性能和数据安全上做取舍,不能每次有数据写入都进行持久化。而对于缓存数据,真正造成丢失了也没有太大问题,重新从 redis 同步就可以了,只是会增加全量同步的风险。
所以我们支持几种持久化策略,由用户自己选择:
由操作系统决定
定时持久化和脏数据大小满足一个条件即持久化
每次写入都持久化
数据校验
任何存储数据的设备都可能有损坏或故障的可能,如磁盘坏块,内存位翻转等等,所以我们需要对数据进行校验,以确保不会将错误的数据回放到目标端。
如果 CRC 校验失败,则会丢弃本地缓存,重新从源端 redis 同步最新数据。
输入端
输入端模块会伪装成 redis 从库(slave),通过 RESP 协议从源 redis 节点同步数据。
为了支持断点续传,所以要记录已经同步数据的位置(称为偏移量, offset),下次启动同步流程,则接着上次已同步的位置继续同步。
而这个同步位置,记录在目标 redis 节点上,每次启动要从目标 redis 获取偏移量,然后和本地缓存进行比较,具体比较过程如下:
如果 目标 offset < 本地缓存最老数据(left) :丢弃本地缓存,使用目标端偏移量进行同步
如果 本地缓存最老数据(left) < 目标 offset < 本地缓存最新数据(right) :使用本地缓存最新偏移量进行同步
如果 本地缓存最新数据(right) < 目标 offset :丢弃本地缓存,使用目标端偏移量进行同步
输出端
输出端分为三个步骤,解析 RDB 和 AOF 数据、处理数据、回放数据到目标端 redis。
数据解析
RDB 数据格式 :
RDB 数据布局如下图,
头部区域描述魔数,版本等
接下来是每个 DB 的 key value 分布
最后是尾部,用来记录 CRC 值
AOF 数据格式
AOF 类似数据库的 WAL(write append log)文件,由一个个操作日志组成,每个日志按照类似 TLV 方式进行编码(type length value)。
第一个字符是类型,再是数据大小,最后是数据本身。
如下图 SET 操作日志
*表示数组,3 表示有 3 个元素
$表示字符串,3 表示字符串长度
所以这条日志是描述一个数组,有 3 个元素,分别表示为 SET、key1、val1。
数据处理
数据处理主要对数据进行过滤,替换,如
按照命令过滤
按照前缀 key 过滤
过滤特殊命令:如过滤 cluster,flushdb 等命令(slot 不对称 flushdb 不应该进行回放)
后面还会支持插件这种更为灵活的处理方式。
数据回放
数据回放是最为复杂的(将数据回写到目标端 redis 集群),要考虑的点非常多
如何保证一致性?
如何优化回放性能?
如何批量进行回放
以什么方式将数据回放到目标端 redis
大 key 怎么回放
如何保证一致性
当源和目的 redis 集群的槽位对称时,每个目标 redis 节点都会维护一个单独的偏移量,分别记录源 redis 节点的数据偏移量;
redis-GunYu 将 AOF 命令和偏移量更新一起打包发送到目标端,这样保证数据一致性。下图中每个同步模块从自己的缓存队列中读取 AOF 数据,然后打包发送到目标端(图中红色方块为更新偏移量信息,绿色方块是 AOF 数据)。
当源和目的 redis 集群的槽位不对称时,整个目标 redis 集群维护一个偏移量,包含源 redis 节点的数据偏移量;
将发往同一个目标 redis 节点的命令打包一起发送,而偏移量则定期更新。
所以,如果同步程序崩溃,则有可能导致多回放数据,对于非类幂等操作则会导致数据不一致;当然正常退出是不会有这种问题的。
如何提高回放性能
我们希望在保证数据一致性的前提下,尽可能地提高回放性能,所以会对数据进行并发回放,同时也如前面所讲,会进行打包发送,在回放性能和低延迟之间进行平衡。
对于 RDB,数据之间是没有依赖关系的,则可以并发回放数据
而 AOF 的操作日志,数据之间会有依赖关系,无法进行并发回放,必须保证回放的有序性。但是我们可以将其打包,一起发送到目标端,以减少网络传输带来的消耗。打包发送同时考虑打包策略,打包多少命令,打包命令的时间跨度,字节数,同时要考虑和偏移更新、事务的处理要保持兼容。不同的业务对延迟的敏感程度不一样,所以这些都要求可配置,以满足不同需求。
这是逻辑上的一些优化,同时还有一些语言机制的优化,如缓存的复用,锁的优化等。
如何进行回放
对于 RDB 数据的回放,我们会优先尝试使用 restore 命令进行回放,这样可以确保较好地性能,restore 回放失败再尝试拆分成 redis 命令地形式进行回放。
但有些情况例外,需要将数据结构拆分成命令形式进行回放,如
某些特殊数据结构,如 FUNCTION
源和目标 redis 版本不一致,对于某些数据结构不兼容
数据结构太大
这块的处理是比较繁琐的,每个数据结构都要支持拆分成非 restore 命令进行回放,还要考虑源和目的版本,不同版本之间的命令兼容性等等。
可观测性
回放模块的可观测性也比较丰富,需要监控命令的数量、打包指标、失败指标、发送偏移、ack 偏移,以及时间和空间上的同步延迟。若配置文件启用了时间同步延迟,则会在源 redis 端写入一个特殊的 key,然后回放模块会检测到这个 key,发送前会计算时间差以表示回放延迟,这样,通过监控就能看出源和目标 redis 集群的数据延迟。
下图为空间和时间上的同步延迟指标
写在最后
本文中,我们自顶向下了解了 redis-GunYu 实时同步工具的实现原理,如何摄取数据,缓存数据,处理数据,再到回放数据,以及工具本身的高可用实现。redis-GunYu 还有很多需要完善、优化的地方,欢迎大家提交 issue 和 PR。
代码仓库地址 :https://github.com/mgtv-tech/redis-GunYu
版权声明: 本文为 InfoQ 作者【楚】的原创文章。
原文链接:【http://xie.infoq.cn/article/4e128e898eb2bf02f07acfa30】。文章转载请联系作者。
评论