30.Go处理Redis BigKey

代码地址: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)进行迁移时,使用 RedisMIGRATE 命令可能会面临一些问题。 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对序列化的开销也不小)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值