Redis高可用之大key问题解决方案 学习笔记

前言:什么是大key?

Redis 作为单线程应用程序,它的请求处理模式类似排队系统,所有请求只能依序逐个处理。一旦处于队列前面的请求处理时间过长,后续请求的等待时间就会被迫延长。

倘若现在某个键值对数据量过大,Redis 针对这个请求的 I/O 操作耗时以及整体处理时间都将显著增加。不仅 Redis 的吞吐会下降,而且大量后续请求都会因长时间得不到响应,而导致延迟上涨,甚至引发超时。在实际应用场景中,这种现象被定义为 "大 Key 问题",而那些数据量过大的键(Key)与值(Value)则被称为 "大 Key"。

实际上,对于大key并没有一个统一的标准,这里提供一些阿里云提供的参考标准:

  • 从 Key 的数据量来说,一个 String 类型的 Key,如果它的数据量达到 5MB,我们就可以认为它是一个"大 Key"。
  • 从 Key 成员数量来看,一个 ZSET 类型的 Key,如果它包含的成员数量超过了 10,000 个,这也符合"大 Key"的定义。
  • 从 Key 的成员数据量来看,一个 Hash 类型的 Key,即使它的成员数量只有 2,000 个,但如果这些成员的 Value(值)加起来总大小超过了 100MB,那它同样可以被视为"大 Key"。

本文我们将探讨一个问题: 如果在实际应用中,我们不得不将超过大 Key 标准的数据存储到 Redis 中,我们应该如何进行优化以规避大 Key 问题呢?

一般主要有2种方案可供选择:

  1. 数据压缩方案,通过压缩技术减少数据的体积,使其符合大 Key 的标准,从而减少对 Redis 性能的影响
  2. 大 Key 拆分方案,将一个大型的 Key 拆分成多个小型的 Key,这样可以分散单个 Key 对 Redis 性能的负担

数据压缩方案

在实际的应用场景中,我们经常需要将数据以 JSON 字符串的形式存储到 Redis 中。

那么,对于这类数据,如果能在存储前去除 JSON 中多余的数据,比如 JSON 里面的 Key,那么存储到 Redis 的数据量就会相应减少。

我们可以利用 Protocol Buffers(PB) 等序列化工具对数据进行预处理,然后再存储到 Redis中。这样做可以显著减少存储在 Redis中的数据大小,降低出现大 Key问题的风险。

例如,下面的JSON

csharp 复制代码
func main() {
    dataStr := []byte(`{
        "product_id":1,
        "name":"aaaa",
        "price":111,
        "url":"https://www.xxx.com/image.jpg"
    }`)
    fmt.Println(len(dataStr)) // json字符串字节数,输出94
}

转为PB文件格式就变成了

ini 复制代码
syntax = "proto3";

option go_package = "./pb";  // 将这里修改为你期望的包名和路径

message Product {
  int32 product_id = 1;
  string name = 2;
  int32 price = 3;
  string url = 4;
}

然后生成pb.go文件使用如下

css 复制代码
func main() {
    product := &pb.Product{
        ProductId: 1,
        Name:      "aaaa",
        Price:     111,
        Url:       "https://www.xxx.com/image.jpg",
    }
    data, _ := proto.Marshal(product)
    fmt.Println(len(data)) // PB序列化后字节数,输出41
}

可以看出存储到 Redis 的数据将显著减少,从94变成了41字节。和直接存入JSON字符串相比,数据大小降低了50%以上。

但是,如果 PB 序列化后的数据仍然较大,或者不适合使用 PB 序列化的情况,比如 Value 不是 JSON 形式,我们又该怎么办呢?

大key拆分方案

所谓大 Key 拆分,是指将一个 Key 的数据,拆分成多个小块,每个小块存入不同的 Redis Server 里,从而避免单个 Redis Server 处理大 Key 的压力。

核心痛点:如何规避脏读现象?

正如下方的图里呈现的那样,当新数据写入 sub_key1,但尚未更新 sub_key2 时,如果有读 Redis 的请求,就有可能读取到旧的 sub_key2 数据与新的 sub_key1 数据,进而拼接出一个在实际中并不存在的数据,这会给数据的准确性和可靠性带来极大的负面影响。

我们来学习一种在实际应用中较为通用的大 Key 拆分方案。它的核心设计理念在于巧妙借助版本号这一机制,而非采用直接覆盖原始数据的做法,从而达成有效防止脏读的目的。

写大key操作关键流程如下:

  1. 首先,对于大 Key,我们需要对数据进行 MD5 运算,并提取运算结果的后 6 位作为本次数据的特定版本号;
  2. 接着,我们按字节大小做拆分,子 Key 拼接上版本号,避免直接覆盖之前的数据,导致脏读;
  3. 然后,我们更新 Key 的元数据信息,元数据信息里记录了子 Key 信息,从而使线上生效;
  4. 最后,我们需要给旧的子 Key 设置过期时间,而不是直接删除,避免有 Client 正在读旧子 Key 数据。

写操作参考代码如下:

go 复制代码
// 数据元信息
type MetaInfo struct {
        Data     []byte   `json:"data"` // 如果不是大Key直接取这个字段,避免需要请求Redis两次
        IsBigKey bool     `json:"is_big_key"`// 标记是否是大Key,如果不是,直接取Data字段
        Keys     []string `json:"keys"` // 子Key数组
}

// 将Value按字节大小拆分后存入Redis
func storeValueInRedis(ctx context.Context, key string, value []byte, chunkSize int) error {
        // 计算需要多少个chunk
        totalChunks := len(value) / chunkSize
        if len(value)%chunkSize != 0 {
                totalChunks++
        }
        // 默认小Key
        meta := MetaInfo{IsBigKey: false, Data: value}
        // 大key处理
        if totalChunks > 1 {
                // md5后6位作为数据版本号
                version := md5LastSixBytes(value)
                keys := make([]string, 0, totalChunks)
                // 创建Pipeline
                pipe := redisClient.Pipeline()
                // 存储每个chunk
                for i := 0; i < totalChunks; i++ {
                        start := i * chunkSize
                        end := (i + 1) * chunkSize
                        if end > len(value) {
                                end = len(value)
                        }
                        chunk := value[start:end]

                        // 构造每个chunk的Key
                        chunkKey := fmt.Sprintf("%s:%s:%d", key, version, i)
                        keys = append(keys, chunkKey)
                        // 将chunk存入Pipeline
                        pipe.Set(ctx, chunkKey, chunk, 0)

                }
                // 执行Pipeline中的所有命令
                _, err := pipe.Exec(ctx)
                if err != nil {
                        return err
                }
                meta = MetaInfo{IsBigKey: true, Keys: keys, Data: nil}
        }
        metaByte, err := json.Marshal(meta)
        if err != nil {
                return err
        }
        // 获取原来的数据元信息,以便设置过期时间
        oldMetaByte, err := redisClient.Get(ctx, key).Bytes()
        if err != nil {
                return err
        }
        // 新数据生效
        _, err = redisClient.Set(ctx, key, metaByte, 0).Result()
        if err != nil {
                return err
        }
        var oldMetaInfo MetaInfo
        err = json.Unmarshal(oldMetaByte, &oldMetaInfo)
        if err != nil {
                return err
        }
        if oldMetaInfo.IsBigKey {
                // 获取旧Key,设置旧Key过期时间,比如说10分钟,防止服务端还有旧数据在读
        }
        return nil
}

再来看看,大 Key 读取的关键流程:

  1. 首先,我们需要根据查询的 Key,从 Redis 获取包含子 Key 的元数据信息;
  2. 接着,我们需要根据子 Key,获取各个子 Key 的数据;
  3. 最后,把获取的子 Key 数据拼接起来,就得到了我们需要的完整大 Key 数据。
go 复制代码
// 从Redis获取数据
func getDataFromRedis(ctx context.Context, key string) ([]byte, error) {
        // 获取数据元信息
        metaByte, err := redisClient.Get(ctx, key).Bytes()
        if err != nil {
                return nil, err
        }

        var metaInfo MetaInfo
        err = json.Unmarshal(metaByte, &metaInfo)
        if err != nil {
                return nil, err
        }
        // 不是大Key,直接取Data字段数据
        if !metaInfo.IsBigKey {
                // 如果不是大Key,直接返回Data字段
                return metaInfo.Data, nil
        }

        // 如果是大Key,使用Pipeline从多个键中获取数据
        pipe := redisClient.Pipeline()

        // 将所有Get操作添加到Pipeline
        for _, chunkKey := range metaInfo.Keys {
                pipe.Get(ctx, chunkKey)
        }

        // 执行Pipeline中的所有命令
        cmds, err := pipe.Exec(ctx)
        if err != nil {
                return nil, err
        }

        // 获取的各个子Key数据进行拼接,就是完整的数据
        var data []byte
        for _, cmd := range cmds {
                if cmd.Err() != nil {
                        return nil, cmd.Err()
                }

                chunkData := []byte(cmd.String())
                if err != nil {
                        return nil, err
                }
                data = append(data, chunkData...)
        }

        return data, nil
}

扩展知识

pb和JSON序列化的区别是什么?

待笔者补充

参考

《go服务开发高手课》

相关推荐
小码编匠12 分钟前
C# 实现西门子S7系列 PLC 数据管理工具
后端·c#·.net
Postkarte不想说话16 分钟前
Ubuntu24.04搭建TrinityCore魔兽世界
后端
Weison16 分钟前
Apache Doris Trash与Recover机制
后端
codelang2 小时前
Cline + MCP 开发实战
前端·后端
风象南3 小时前
SpringBoot中6种自定义starter开发方法
java·spring boot·后端
Asthenia041212 小时前
Spring AOP 和 Aware:在Bean实例化后-调用BeanPostProcessor开始工作!在初始化方法执行之前!
后端
Asthenia041213 小时前
什么是消除直接左递归 - 编译原理解析
后端
Asthenia041213 小时前
什么是自上而下分析 - 编译原理剖析
后端
Asthenia041213 小时前
什么是语法分析 - 编译原理基础
后端