Redis指定key数据恢复能力

140 阅读6分钟

该功能在实际业务场景中,常用于在 Redis 集群中出现误删键或者需要在旧版本快照(RDB)中恢复某些关键数据时,通过对 RDB 文件的解析和对目标 Redis 节点的写入来实现「只恢复指定 Key」的能力。


1. 整体功能概览

我们有两类 Redis 集群:Redis1.0Redis2.0。它们在底层哈希槽数量、配置信息以及获取集群节点 IP/分片分布的方式上存在差异。针对这两者,我们做了如下设计:

Redis1.0

采用 16384 个哈希槽 (兼容 Redis Cluster 经典做法)。

无法直接从如SmartClient中拿到集群节点,需要通过一个 “已知 Pod” 的 IP 来执行 redis-cli cluster nodes 命令,获取整个集群的槽分配信息。

通过解析结果定位到「负责指定 Key (slot) 的主节点」的真实 Pod IP 及其 PodName,再结合本地 RDB 快照进行 Key 恢复。

Redis2.0

采用 4096 个哈希槽。

从 SmartClient 可以拿到集群的 View(包括 shard -> slot、节点 IP、主从等信息),直接计算 key 所属的 slot → 找出对应 shard → 找到主节点 IP。

拼出对应 RDB 文件路径后恢复指定 Key。

在上层 API 层,我们的需求是提供一个 HTTP 接口(例如:POST /key_recover),前端(或调用者)会带上以下参数:

sub_cluster: 子集群名称

key_name: 要恢复的 Key

version: 集群版本

kube_ns: 该子集群所在的 k8s Namespace(默认为 default)

系统在收到请求后,会去数据库查询该子集群究竟是 Redis1.0 还是 Redis2.0。然后分别走不同的流程进行恢复。


2. 核心流程概述

对于 Redis 集群,恢复指定 Key 大体要做的事情是:

找到负责该 Key 的真正 Redis 节点的 IP

Redis1.0:通过 “(某个Pod) → cluster nodes → 解析 slot→IP”。

Redis2.0:通过 SmartClient.LoadView(...) 获得 shard2slot 映射,然后将 key 计算出 slot 并定位 shard → master IP。

找到本地 RDB 文件

系统会在对应节点上或在某处生成了 dump_.rdb,我们只要能知道正确的 PodName,就能拼凑出相应的 RDB 文件路径。例如:

如果 1.0 在解析 cluster nodes 时发现真正负责 slot 的 PodName 不同于 “-0-0”,则要拼那台对应 PodName 的 RDB 文件。

在 RDB 文件中解析指定 Key

使用开源的 RDB 解析库(如 github.com/dongmx/rdb):

只关注某个 key,不用解析整个 RDB;

一旦解析到就终止,减少内存占用。

将此 Key 的数据插回到目标 Redis 节点

建立一个 Redis 连接(IP: 6379),按类型(string/hash/set/list/zset/stream)写入。

写入成功则算恢复完成。


3. 主要代码文件结构

我们将主要代码拆分为两个部分:

key_recover_api.go

负责处理 HTTP 请求 (KeyRecoverHandler)。

根据子集群名从数据库读出 is_v2 字段,区分 Redis1.0 / Redis2.0。

分别调用 handleRedis1_0(...) 或 handleRedis2_0(...)。

其中包含了对于 1.0 集群的 cluster nodes 调用和解析、以及 2.0 的 SmartClient 调用等。

key_recover_service.go

里面有 KeyRecoverService,以及对 RDB 文件解析的逻辑。

核心方法:RecoverKey(ctx, podName, targetKey, redisIP, redisPwd, redisDB)。

内部会定位 RDB 文件 => 只解析指定 targetKey => 将解析到的数据写入目标 Redis。

下面我们详细说明每一步如何实现。


4. key_recover_api.go 的实现思路

4.1 KeyRecoverHandler (HTTP 接口入口)

func KeyRecoverHandler(c *gin.Context)

4.2 handleRedis1_0(...) 详解

Redis1.0 里没有统一的元信息中心可供查询,因此做法是:

拿一个已知的 Pod(我们暂定统一用 k8redis---0-0)来获取集群信息,这个 Pod IP 我们通过 k8s API 或 client-go 获取。

对该 Pod 执行 redis-cli -h cluster nodes 命令拿到文本输出。

解析文本:对于每一行“master”行,里面会有 slot 区间信息(如 0-546, 547-1093, ...),找到覆盖我们想要恢复的 key 的 slot 行,以及对应的 IP/域名(如 k8redis-xxx-0-0.svc:6379)。

如果所解析到的 PodName 跟最初的 “-0-0” 不同,就说明该 key 真正位于另一个分片的主节点上。那就必须再用这个实际 PodName 来拼出 RDB 文件名。

最后调用 KeyRecoverService.RecoverKey(...),把解析得到的主节点 IP 以及实际 PodName 传给它,从而实现恢复。

其中, “如何计算 key => slotIndex”

Redis1.0 正常是 CRC16(key) % 16384。

代码中用一个 calcSlotForKey_1_0(key) 函数来封装。

4.3 handleRedis2_0(...) 详解

Redis2.0 有一个元信息中心 + SmartClient,因此:

先初始化 SmartClient (指定 etcd endpoints 等)。

构建 View(model.NewView(ccManager, subCluster)),然后调用 sc.LoadView(view) 获得 view.Shard2SlotBitmap、view.Nodes 等信息。

计算 slot = calcSlotForKey_2_0(key) (这里是 % 4096)。

遍历 view.Shard2SlotBitmap 找到包含此 slot 的 shardID。

在 view.Nodes 里找 (node.ShardId == shardID && node.IsMaster==true) 的 IP。

依然要拼一个 PodName 来找 RDB 文件,比如用 k8redis---0-0。

最终调用 KeyRecoverService.RecoverKey(...) 完成数据恢复。


5. rdr_service.go (或 service/key_recover_service.go) 里的实现思路

这是核心的「从本地 RDB 文件里提取某个 Key,再写回 Redis」的逻辑。可分两大块:

5.1 确定 RDB 文件并解析指定 Key

我们约定 RDB 文件的命名:

/redis-cluster/dump_.rdb 比如:PodName = k8redis-myCluster-v1-2-0,去掉最后 2 个字符就得到 k8redis-myCluster-v1-2,最终文件为:dump_k8redis-myCluster-v1-2.rdb。

用 os.Open(...) 打开该 .rdb 文件,使用 github.com/dongmx/rdb 解析(或其他 RDB 解析库)。

实现一个自定义的 Decoder,重写回调函数(Set、StartHash/EndHash、StartList/EndList等),关心指定 Key。

当解码器读到该 Key 时,立刻将其保存在 d.currentEntry 并把结果通过 channel 发送到外层,然后就可以终止继续解析(做一些优化也可以)。

5.2 将该 Key 的数据插回目标 Redis

确定了要连接的 Redis IP (取出 ip:6379),redisPwd,redisDB 等信息。

按照 Key 的数据结构分别做写入:

string → SET key value

hash → HSET key field1 val1 field2 val2 ...

set → SADD key member1 member2 ...

sortedset → ZADD key score1 member1 score2 member2 ...

list → RPUSH key elem1 elem2 ...

stream → 先 DEL key,再 XADD key ID=xxx field1 value1 ...,依次插回原来的消息记录。

这样就能把旧的 RDB 里保存的这个 Key 完整地还原到指定节点中。


6. 数据结构的恢复原理

由于 Redis 不同数据类型在 RDB 文件中存储格式各异,因此在解析时要分别处理:

String

只要解析到一个 SET(key, value) 回调就代表这个 key 是字符串类型,其数据在 value 里。写回时只需 SET key value。

Hash

解析过程中,会先触发 StartHash(key, length, expiry, info) 回调,我们就知道这是一个哈希类型。

接着会多次调用 Hset(key, field, value) 把所有 field-value 放进一个 map[string]string。

最后 EndHash(key) 表示这个 key 的哈希解析结束,此时我们把整张 hash 通过 HSET key field1 val1 ... 写回。

Set

类似 hash,先 StartSet(...) -> 多次 Sadd(key, member) -> EndSet(key)。

用一个 []string 存所有 member 即可。最后 SADD key mem1 mem2 ...。

SortedSet (Zset)

StartZSet(...) -> 多次 Zadd(key, score, member) -> EndZSet(key)。

我们可以用一个 map[string]float64 或数组收集全部 score/member,然后写回时用 ZAdd。

List

StartList(...) -> 每个元素对应 Rpush(key, value) -> EndList(key)。

我们存到一个 []string,最后 RPUSH key [elements...]。

Stream

这里相对复杂,需要解析 RDB 中的 Stream 存储结构(使用 listpack 进行编码)。

解码完成后,我们得到一个 ID => 多条消息的结构(每条消息有 Fields -> Values);

恢复时先 DEL key 避免重复,然后再按消息 ID 升序依次 XADD key ID=xxx ...。

在我们的示例中,这些回调接口都集中在自定义的 Decoder 里,例如:

func (d *Decoder) Set(key, value []byte, expiry int64, info *rdb.Info) { if string(key) == d.targetKey { // 就把它存到 d.Entries } } ...

这样只要我们的 targetKey 匹配,才会真正记录数据;否则会直接忽略,以达到 “只恢复指定 Key” 的目的。