文章目录
代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/21-redis-bigkey
一:介绍
1. 什么是Big Key
查看阿里云Redis开发规范,我们可以得知BigKEY
是这样定义的:
在Redis
中,一个字符串最大512MB
,一个二级数据结构(例如hash、list、set、zset
)可以存储大约40
亿个(2^32-1
)个元素,但实际上如果出现下面两种情况,我就会认为它是Bigkey
。
- 字符串类型:它的
big
体现在单个value
值很大,一般认为超过10KB
就是Bigkey
。 - 非字符串类型:哈希、列表、集合、有序集合,它们的
big
体现在元素个数太多(不要超过5000
)。
产生原因:
-
社交类:粉丝列表,在社交应用中,粉丝列表是一个常见的大键示例。当明星或大
V
的粉丝数量庞大时,粉丝列表的大小可能会快速增长,成为一个Bigkey
。这样的Bigkey
可能会影响读取和更新操作的性能,因为它需要处理大量的数据。对于这种情况,可以考虑采用分页加载、按需加载或使用其他技术手段来优化数据访问和管理。 -
统计类:按天存储用户集合 在统计功能或网站中,按天存储用户集合也可能导致
Bigkey
。例如,每天将用户的相关数据存储在Redis
中,随着时间的推移,这个键的大小会不断增长。大规模的用户集合可能会占用大量的内存和网络带宽,对Redis
实例的性能产生负面影响。对于这种情况,可以考虑使用合适的数据分片或分区策略,或者定期归档或压缩旧数据,以减小Bigkey
的大小。 -
缓存类:缓存数据从数据库加载后缓存数据是
Redis
的常见用途之一。然而,在缓存数据时,需要注意缓存的对象大小和相关的关联数据。当缓存对象非常大或包含大量关联数据时,可能导致Bigkey
的出现。这会影响读取和存储的性能,并且增加网络传输的开销。在缓存设计中,需要权衡数据的实际需求和缓存的大小,确保缓存对象合理且高效地使用。
2. BigKey的危害
2.1 内存空间不均匀
假设有一个Redis
集群,由多个节点组成,每个节点都负责存储和处理一部分数据。集群中的数据会根据一致性哈希算法进行分片和分配。现在假设集群中的某个节点(Node A
)存储了一个大型键值对,它占用了大量的内存空间。由于这个大型键值对的存在,Node A
的内存使用率很高,可能接近或达到了内存的上限。当集群中的其他节点(Node B、Node C
等)需要执行写入操作时,根据一致性哈希算法,这些写入操作会被路由到Node A
,因为Node A,B,C
三个都是Master
节点,负责不同的slot
,当前写操作可能根据分片规则需要存到Node A
节点负责的slot上
,由于Node A
的内存已经几乎耗尽,它可能无法存储新的数据。
在这种情况下,可能会发生以下问题:
- 写入失败:当集群中的其他节点无法将数据写入
Node A
时,写入操作会失败。这可能导致数据丢失或客户端的写入请求被拒绝。 - 数据不一致:由于集群中的不同节点存储了不同的数据,当其中一个节点无法存储新的数据时,数据的一致性可能会受到影响。一些节点可能具有更新的数据,而另一些节点可能具有旧的数据,导致数据不一致的状态。
- 集群负载不均衡:由于大型键值对的存在,导致部分节点的内存使用率高于其他节点。这可能导致集群的负载不均衡,某些节点承载了更多的请求和数据负担,而其他节点相对空闲。
2.2 超时阻塞
当一个Redis
实例中存在一个非常大的字符串值时,例如一个键的值是一个几百兆甚至几个G
的字符串,这个大型键值对的读写操作可能会导致阻塞问题。
假设有一个Redis键名为"large_value"
,其对应的字符串值非常大。现在有一个写操作需要更新这个键的值,同时有多个读操作需要获取该键的值。
以下是一个示例的代码片段,模拟了这种情况:
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"strings"
"sync"
)
var rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
var ctx = context.Background()
func init() {
if err := rdb.Ping(ctx).Err(); err != nil {
panic(any(err))
}
}
func main() {
// 创建一个 WaitGroup 用于等待所有操作完成
var wg sync.WaitGroup
// 创建一个 Goroutine 执行写操作
wg.Add(1)
go writeOperation(&wg)
// 创建多个 Goroutine 执行读操作
for i := 0; i < 5; i++ {
wg.Add(1)
go readOperation(&wg)
}
// 等待所有操作完成
wg.Wait()
// 关闭 Redis 连接
err := rdb.Close()
if err != nil {
fmt.Println("Failed to close Redis connection:", err)
}
}
// writeOperation 向redis写
func writeOperation(wg *sync.WaitGroup) {
defer func() {
wg.Done()
if err := recover(); err != any(nil) {
fmt.Println("writeOperation panic:", err)
}
}()
largeValue := generateLargeValue(100000000)
err := rdb.Set(ctx, "large_key", largeValue, 0).Err()
if err != nil {
fmt.Println("Write operation failed:", err)
return
}
fmt.Println("Write operation completed.")
}
// readOperation 读操作函数,获取大型键值对的值
func readOperation(wg *sync.WaitGroup) {
defer func() {
wg.Done()
if err := recover(); err != any(nil) {
fmt.Println("writeOperation panic:", err)
}
}()
value, err := rdb.Get(ctx, "large_key").Result()
if err != nil {
fmt.Println("Read operation failed:", err)
return
}
fmt.Println("Read operation completed. Value length:", len(value))
}
// generateLargeValue 生成较大的字符串值
func generateLargeValue(size int) string {
return strings.Repeat("X", size)
}
上面代码运行后结果:
看到,5
个协程都没有读到值,原因是我们在写key
的协程中,耗时较长,5
个读协程执行了,因此读到数据都是nil
,因为读取时写入操作还没有完成,但程序运行完后,large_key
是写成功了的。
设置完BigKey后,可以打开redis客户端查看key的大小以及key的内容
查看key
大小,注意:redis4.0以后才支持memory命令
127.0.0.1:6379> memory usage large_key
(integer) 100000000
如果执行get large_key
,则会看到满屏的XXXXX....
,因为太大了
当我们再次执行程序时,可能就会报下面的错误了,读写都超时了
Read operation failed: read tcp 192.168.197.1:52937->192.168.197.129:6379: i/o timeout
Write operation failed: write tcp 192.168.197.1:52939->192.168.197.129:6379: i/o timeout
Read operation failed: read tcp 192.168.197.1:52941->192.168.197.129:6379: i/o timeout
Read operation failed: read tcp 192.168.197.1:52938->192.168.197.129:6379: i/o timeout
Read operation failed: read tcp 192.168.197.1:52940->192.168.197.129:6379: i/o timeout
Read operation failed: read tcp 192.168.197.1:52942->192.168.197.129:6379: i/o timeout
2.3. 网络拥塞
假设有一个名为"big_key
"的键,它存储的值大小为1MB
。现有一个具有1000
个客户端并发访问的场景,每个客户端每秒钟都需要获取"big_key
"的值。这样就会导致每秒钟产生1000MB
的网络流量。
对于普通的千兆网卡(1 Gbps)
的服务器来说,其理论上的最大传输速率为125MB/s
。然而,考虑到其他网络负载、操作系统和硬件的开销,实际可用的带宽可能会更低。
在这种情况下,每秒钟产生1000MB
的网络流量超出了服务器网络带宽的限制。这将导致网络拥塞和性能下降,甚至可能导致其他服务受到影响。
此外,如果服务器采用单机多实例的方式进行部署,意味着多个实例共享相同的网络带宽和硬件资源。当一个实例的客户端大量访问"big_key
"时,网络流量的增加可能会对其他实例产生影响,降低它们的性能和响应能力。
这种情况下的后果可能包括:
- 网络拥塞和延迟增加:大量的网络流量超出了服务器网络带宽的限制,导致网络拥塞,影响其他网络通信和响应时间。
- 响应时间延长:由于服务器负载过高,无法及时处理客户端的请求,导致响应时间延长。
- 服务不可用:如果服务器无法处理大量的请求和流量,可能会导致服务不可用,甚至崩溃。
因此,对于大型键值对(Bigkey
),特别是在高并发访问的场景下,需要谨慎设计和管理,以避免对网络带宽和服务器性能产生过大的压力。多实例的方式来部署,也就是说一个Bigkey
可能会对其他实例造成影响,其后果不堪设想。
2.4. 过期删除
当一个大键(Bigkey
)在 Redis
中设置了过期时间,并且没有启用Redis 4.0
引入的过期异步删除(lazyfree-lazy-expire yes
)选项时,会存在阻塞 Redis
的风险。
在 Redis
中,过期键的删除是通过内部的循环事件来处理的。当键过期时,Redis
会在适当的时间点检查并删除过期键。这个过期删除操作是在 Redis
的主线程中执行的,因此会阻塞主线程的执行,导致其他操作无法得到及时响应。
以下是这种情况的详细说明:
- 设置过期时间:使用
Redis
的命令(如SET
)设置键的过期时间,例如EXPIRE key_name seconds。
- 内部循环事件:
Redis
会周期性地检查过期键并删除它们。这个过期删除操作是在Redis
主线程中顺序执行的,即一个接一个地检查每个键的过期时间并删除过期的键。 - 阻塞
Redis
主线程:当有大量的键需要过期删除时,如果这些键的删除操作耗时较长,就会阻塞Redis
主线程的执行。这意味着主线程无法及时处理其他请求和操作,导致响应延迟增加。
2.5. 迁移困难
在对大键(Bigkey
)进行迁移时,使用 Redis
的 MIGRATE
命令可能会面临一些问题。 MIGRATE
命令实际上是通过组合使用 DUMP、RESTORE
和 DEL
命令来完成数据迁移的原子操作。
以下是关于大键迁移可能遇到的问题:
- 迁移失败:由于大键的数据量较大,进行
DUMP
操作可能会消耗大量的时间和内存资源。如果在迁移过程中发生网络故障或迁移操作超时,可能导致迁移失败并丢失部分数据。此外,如果目标实例上的内存不足以容纳大键的数据,也会导致迁移失败。 - 阻塞
Redis
:大键的迁移通常需要较长的时间,这会导致执行MIGRATE
命令的Redis
实例在迁移过程中被阻塞。这会影响其他请求和操作的响应时间,并可能导致客户端的阻塞和性能下降。
二:如何发现BigKEY
1. redis-cli --bigkeys
redis-cli
提供了--bigkeys
来查找bigkey
,例如下面就是一次执行结果:
可以看到--bigkeys
给出了每种数据结构的top 1 bigkey
,同时给出了每种数据类型的键值个数以及平均大小。
--bigkeys对问题的排查非常方便,但是在使用它时也有几点需要注意。
- 建议在从节点执行,因为
--bigkeys
也是通过scan
完成的。 - 建议在节点本机执行,这样可以减少网络开销。
- 如果没有从节点,可以使用
--i
参数,例如(--i 0.1
代表100
毫秒执行一次) --bigkeys
只能计算每种数据结构的top1
,如果有些数据结构非常多的bigkey
,也搞不定。
2. debug object
3. memory usage
三:如何处理BigKey
1. Redis 4.0+
如果你使用Redis 4.0+
,一条异步删除unlink
就解决。
2. Redis 4.0以前
string
一般来说,对于string类型使用del命令不会产生阻塞。
del bigkey
hash
使用hscan
命令,每次获取部分(例如100
个)field-value
,再利用hdel
删除每个field
(为了快速可以使用pipeline
)。
list
Redis
并没有提供lscan
这样的API
来遍历列表类型,但是提供了ltrim
这样的命令可以渐进式的删除列表元素,直到把列表删除。
set
使用sscan
命令,每次获取部分(例如100
个)元素,再利用srem
删除每个元素。
sorted set
使用zscan
命令,每次获取部分(例如100
个)元素,再利用zremrangebyrank
删除元素。
可以看到对于string类型,del删除速度还是可以接受的。但对于二级数据结构,随着元素个数的增长以及每个元素字节数的增大,删除速度会越来越慢,存在阻塞Redis的隐患。所以在删除它们时候建议采用渐进式的方式来完成:hscan、ltrim、sscan、zscan。
四:最好方式,事前预防
-
数据拆分:如果一个大的数据结构被存储为一个
Bigkey
,可以考虑将其拆分为多个小的数据结构。例如,将一个大的哈希表拆分为多个小的哈希表,或将一个大的列表拆分为多个小的列表。这样可以减小单个键的大小,降低对Redis
的负载。 -
字段选择:在缓存数据时,只缓存业务上需要的字段,而不是将整个对象或数据集都存储为
Bigkey
。通过选择性地缓存字段,可以减少存储空间的占用和网络传输的数据量。 -
数据压缩:对于大型数据结构,可以考虑使用数据压缩算法对数据进行压缩,然后存储在
Redis
中。这样可以减小键的大小,节省存储空间,并在传输数据时减少网络流量。 -
定期清理:针对设置了过期时间的
Bigkey
,可以定期进行清理。使用过期异步删除的配置可以减少清理操作对Redis
的阻塞影响。定期清理可以避免bigkey
占用过多的内存资源,并保持Redis
的性能稳定。 -
数据分片:如果
Bigkey
的读写频率很高,可以考虑使用Redis
的分片技术,将数据分散到多个Redis
实例上。这样可以均衡负载,减少单个实例的压力,提高整体性能和可扩展性。 -
数据预热:在系统启动或负载较低的时候,可以通过预先加载和填充
Bigkey
的方式,将数据提前加载到Redis
中。这样可以避免在高负载时期突然加载大量数据而导致的性能问题。 -
合理设置
Redis
的内存限制:根据实际情况和可用内存资源,合理设置Redis
的内存限制。避免给Redis
分配过小的内存,导致频繁的内存回收操作,影响性能。 -
本地缓存:减少访问
Redis
次数,降低危害,但是要注意这里有可能因此本地的一些开销(例如使用堆外内存会涉及序列化,Bigkey
对序列化的开销也不小)