前言:什么是大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问题?
- 《阿里云》文档提到了四点建议:对大Key进行拆分、对大Key进行清理、监控实例的内存水位、对过期数据进行定期清理。
- 提前规划数据结构,从系统设计阶段开始优化数据结构,避免产生大 Key。
- 为大 Key 设置合理的过期时间,确保无用数据及时清理。
- 将数据分片存储到多个 Redis 实例中,避免单个实例中的大 Key 问题。
- 避免直接删除大 Key,通过异步任务分批删除其数据,减少阻塞。通过 UNLINK 命令替代 DEL。
如果在实际应用中,我们不得不将超过大 Key 标准的数据存储到 Redis 中,我们应该如何进行优化以规避大 Key 问题呢?
一般主要有2种方案可供选择:
- 数据压缩方案,通过压缩技术减少数据的体积,使其符合大 Key 的标准,从而减少对 Redis 性能的影响
- 大 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))
}
关键优化点
- 空间优化:PB 相比 JSON 通常节省 50%-90% 空间。
- 性能提升:PB 序列化 / 反序列化速度比 JSON 快 3-10 倍。
- 二进制存储:Redis 存储二进制更高效,避免字符串编码开销。
注意事项
- 兼容性:修改
.proto文件时需遵循向后兼容原则。 - 调试工具:使用
protoc --decode查看 PB 二进制内容。 - 错误处理:实际生产中需增加更完善的错误处理和重试机制。
但是,如果 PB 序列化后的数据仍然较大,或者不适合使用 PB 序列化的情况,比如 Value 不是 JSON 形式,我们又该怎么办呢?
大key拆分方案
所谓大 Key 拆分,是指将一个 Key 的数据,拆分成多个小块,每个小块存入不同的 Redis Server 里,从而避免单个 Redis Server 处理大 Key 的压力。
核心痛点:如何规避脏读现象?
正如下方的图里呈现的那样,当新数据写入 sub_key1,但尚未更新 sub_key2 时,如果有读 Redis 的请求,就有可能读取到旧的 sub_key2 数据与新的 sub_key1 数据,进而拼接出一个在实际中并不存在的数据,这会给数据的准确性和可靠性带来极大的负面影响。
我们来学习一种在实际应用中较为通用的大 Key 拆分方案。它的核心设计理念在于巧妙借助版本号这一机制,而非采用直接覆盖原始数据的做法,从而达成有效防止脏读的目的。
写大key操作关键流程如下:
- 首先,对于大 Key,我们需要对数据进行 MD5 运算,并提取运算结果的后 6 位作为本次数据的特定版本号;
- 接着,我们按字节大小做拆分,子 Key 拼接上版本号,避免直接覆盖之前的数据,导致脏读;
- 然后,我们更新 Key 的元数据信息,元数据信息里记录了子 Key 信息,从而使线上生效;
- 最后,我们需要给旧的子 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 读取的关键流程:
- 首先,我们需要根据查询的 Key,从 Redis 获取包含子 Key 的元数据信息;
- 接着,我们需要根据子 Key,获取各个子 Key 的数据;
- 最后,把获取的子 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服务开发高手课》