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

602 阅读9分钟

前言:什么是大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问题?

  1. 《阿里云》文档提到了四点建议:对大Key进行拆分、对大Key进行清理、监控实例的内存水位、对过期数据进行定期清理。
  2. 提前规划数据结构,从系统设计阶段开始优化数据结构,避免产生大 Key。
  3. 为大 Key 设置合理的过期时间,确保无用数据及时清理。
  4. 将数据分片存储到多个 Redis 实例中,避免单个实例中的大 Key 问题。
  5. 避免直接删除大 Key,通过异步任务分批删除其数据,减少阻塞。通过 UNLINK 命令替代 DEL。

如果在实际应用中,我们不得不将超过大 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问题的风险。

示例1

例如,下面的JSON

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文件格式就变成了

syntax = "proto3";

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

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

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

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%以上。

示例2

一个使用 Protocol Buffers (PB) 优化 Redis 大 key 的完整示例,包含服务端和客户端的 Golang 实现

proto文件定义
syntax = "proto3";

package user;

message User {
  string id = 1;
  string name = 2;
  int32 age = 3;
  string email = 4;
  repeated string hobbies = 5;
  map<string, string> settings = 6;
}
服务端:JSON → PB → Redis
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/go-redis/redis/v8"
	"google.golang.org/protobuf/proto"
	"your-project/user" // 生成的PB包
)

func main() {
	// 连接Redis
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})

	// 模拟从数据库读取的JSON数据
	jsonData := map[string]interface{}{
		"id":      "123",
		"name":    "Alice",
		"age":     30,
		"email":   "alice@example.com",
		"hobbies": []string{"reading", "coding"},
		"settings": map[string]string{
			"theme":  "dark",
			"locale": "en-US",
		},
	}

	// 将JSON转换为PB对象
	userPB := &user.User{
		Id:    jsonData["id"].(string),
		Name:  jsonData["name"].(string),
		Age:   int32(jsonData["age"].(int)),
		Email: jsonData["email"].(string),
	}
	userPB.Hobbies = jsonData["hobbies"].([]string)
	
	// 处理map类型
	for k, v := range jsonData["settings"].(map[string]interface{}) {
		userPB.Settings[k] = v.(string)
	}

	// 序列化为二进制
	data, err := proto.Marshal(userPB)
	if err != nil {
		log.Fatalf("Failed to marshal user: %v", err)
	}

	// 存入Redis
	err = rdb.Set(context.Background(), "user:123", data, 0).Err()
	if err != nil {
		log.Fatalf("Failed to set Redis: %v", err)
	}

	fmt.Println("User data stored in Redis (PB format)")
}
客户端:Redis → PB → JSON
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/go-redis/redis/v8"
	"google.golang.org/protobuf/proto"
	"your-project/user" // 生成的PB包
)

func main() {
	// 连接Redis
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})

	// 从Redis获取数据
	data, err := rdb.Get(context.Background(), "user:123").Bytes()
	if err != nil {
		log.Fatalf("Failed to get Redis: %v", err)
	}

	// 反序列化为PB对象
	userPB := &user.User{}
	err = proto.Unmarshal(data, userPB)
	if err != nil {
		log.Fatalf("Failed to unmarshal user: %v", err)
	}

	// 转换为JSON(可选)
	userJSON, err := json.MarshalIndent(map[string]interface{}{
		"id":      userPB.Id,
		"name":    userPB.Name,
		"age":     userPB.Age,
		"email":   userPB.Email,
		"hobbies": userPB.Hobbies,
		"settings": userPB.Settings,
	}, "", "  ")
	if err != nil {
		log.Fatalf("Failed to marshal to JSON: %v", err)
	}

	fmt.Println("User data retrieved from Redis:")
	fmt.Println(string(userJSON))
}
关键优化点
  1. 空间优化:PB 相比 JSON 通常节省 50%-90% 空间。
  2. 性能提升:PB 序列化 / 反序列化速度比 JSON 快 3-10 倍。
  3. 二进制存储:Redis 存储二进制更高效,避免字符串编码开销。
注意事项
  1. 兼容性:修改 .proto 文件时需遵循向后兼容原则。
  2. 调试工具:使用 protoc --decode 查看 PB 二进制内容。
  3. 错误处理:实际生产中需增加更完善的错误处理和重试机制。

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

大key拆分方案

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

image.png

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

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

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

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

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

写操作参考代码如下:

// 数据元信息
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 数据。
// 从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服务开发高手课》