在完成事件接入的需求时,我们需要记录上一个批次拉取的事件,并与当前拉取到的事件做出比对,从而进行差分。我们目前的做法是使用redis来进行缓存:将上一个批次拉取到的事件缓存到一个list中。但是当事件数量过多时,value的大小会超过1M的限制,直接抛出异常。这其实是Tair出于性能的考虑而做出的限制,本文将谈谈我个人对于bigKey的理解。
1.什么是BigKey?
顾名思义,bigKey指一个key对应的value占据的内存空间相对比较大,bigKey通常会有两种表现形式:
- 字符串类型的:通常表现为value大于10k的String类型key。
- 非字符串类型/集合类型:通常表现为存储了过多元素的List、Hash、Set、ZSet类型key。
bigKey一旦产生,将会对tair的性能以及稳定性造成较大的影响,下面我将详细介绍一下bigKey的危害。
2.BigKey有什么危害?
bigKey给tair带来的危害是多方面的,性能下降只是其中的一方面,极端情况下,bigKey甚至会导致缓存服务崩溃。下面我将从几个角度进行分析。
2.1 性能影响
2.1.1 线程阻塞
由于redis采用的是单线程模型,对于key的增删改查都是在主线程中完成。此时,对于bigKey的操作将会阻塞主线程,成为一个明显的性能瓶颈,以对bigKey的删除耗时为例:我们可以看到:
- 当集合类型key中的元素数量从10万增加到100万时,其删除的耗时也成倍的增长。
- 当集合类型key中单个元素的大小增加时,其删除的耗时也相应的增长。
另外,在Redis执行异步重写操作时(bgrewriteaof),主线程会fork出一个子进程来执行重写命令,这个子进程会与主线程共享内存。当主线程收到了新增或者修改一个key的命令,主线程会申请一块额外的内存空间来保存数据。但如果这个key是一个bigKey时,主线程会去申请一块更大空间,同样会阻塞主线程(与JVM分配内存一样,涉及锁和同步)。如果申请不到足够的空间,会导致Swap甚至会有OOM的风险,这同样会降低Redis的性能和稳定性。
2.1.2 网络阻塞
Tair中一个key最大为1M,我们就以1M举例,当访问这个key的QPS为1000时,每秒将会有1GB左右的流量,对于带宽来说将是一个较大压力。如果这个bigKey是一个热点key时,后果将不堪设想。
2.1.3 数据迁移阻塞
如果主从同步的 client-output-buffer-limit 设置过小,并且 master 存在大量bigKey(数据量很大),主从全量同步时可能会导致 buffer 溢出,溢出后主从全量同步就会失败。如果主从集群配置了哨兵,那么哨兵会让 slave 继续向 master 发起全量同步请求,然后 buffer 又溢出同步失败,如此反复,会形成复制风暴,这会浪费 master 大量的 CPU、内存、带宽资源,也会让 master 产生阻塞的风险。 另外,当我们使用Redis Cluster时,由于Redis Cluster采用了同步迁移的方式,bigKey同样会阻塞主线程。这里提一下Codis,Codis在迁移bigKey时,使用了异步迁移 + 指令拆分的方式,对于bigKey (集合类型) 中每个元素,用一条指令进行迁移,而不是把整个 bigKey 进行序列化后再整体传输。这种化整为零的方式,就避免了 bigKey 迁移时,因为要序列化大量数据而阻塞的问题。
2.2 稳定性影响
众所周知,Redis 是典型的 client-server 架构,所有的操作命令都需要通过客户端发送给服务器端。为了避免客户端和服务器端的请求发送和处理速度不匹配,服务器为每个客户端都分配了输入缓冲区和输出缓冲区(默认大小为1GB),用于缓存客户端发送的命令和服务端返回的数据。当我们写入或者读取大量bigKey的时候,很有可能导致输入/输出缓冲区溢出。如果客户端占用的内存总量超过了服务器设置的maxmemory时(默认4GB),将会直接触发服务器的内存淘汰策略,如果有数据被淘汰,再要获取这些数据就需要到后端回源,间接降低了缓存系统的性能。同时,淘汰的如果是bigKey也同样会阻塞主线程。另外,在极端情况下,多个客户端占用了过多的内存将导致OOM,进而使得整个redis进程崩溃。
2.3 数据倾斜
使用切片集群的时候,我们通常会将不同的key存放在不同的实例上,如果存在bigKey的话,会导致相应实例的数据量增大,内存压力也相应增大。
3.怎样发现BigKey?
常用的做法是通过./redis-cli --bigkeys命令对整个redis中的键值对进行统计,输出每种数据类型中最大的 bigkey 的信息。一般会配合-i参数一起使用,控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。另外该命令不要在业务高峰期使用。
./redis-cli --bigkeys
-------- summary -------
Sampled 32 keys in the keyspace!
Total key length in bytes is 184 (avg len 5.75)
//统计每种数据类型中元素个数最多的bigkey
Biggest list found 'product1' has 8 items
Biggest hash found 'dtemp' has 5 fields
Biggest string found 'page2' has 28 bytes
Biggest stream found 'mqstream' has 4 entries
Biggest set found 'userid' has 5 members
Biggest zset found 'device:temperature' has 6 members
//统计每种数据类型的总键值个数,占所有键值个数的比例,以及平均大小
4 lists with 15 items (12.50% of keys, avg size 3.75)
5 hashs with 14 fields (15.62% of keys, avg size 2.80)
10 strings with 68 bytes (31.25% of keys, avg size 6.80)
1 streams with 4 entries (03.12% of keys, avg size 4.00)
7 sets with 19 members (21.88% of keys, avg size 2.71)
5 zsets with 17 members (15.62% of keys, avg size 3.40)
或者我们可以通过debug object key 命令去查看serializedlength属性,serializedlength表示key对应的value序列化后的字节数,通过观察serializedlength的大小可以辅助排查bigKey。使用scan + debug object key命令,我们可以计算其中每个key的serializedlength,进而发现其中的bigKey,并做好相应的监控和处理。不过对于集合类型的bigKey,debug object key 命令的执行效率不高,存在阻塞redis的风险。
4.怎样避免和处理BigKey?
对于字符串类型的key,我们通常要在业务层面将value的大小控制在10KB左右,如果value确实很大,可以考虑采用序列化算法和压缩算法来处理,推荐常用的几种序列化算法:Protostuff、Kryo或者Fst。以及常用的压缩算法:zstd、lz4或者谷歌的snappy(需要根据吞吐量和压缩比自行取舍)。下面附上各种压缩算法的相关性能:(来源:Facebook Zstandard 官网)
对于集合类型的key,我们通常要通过控制集合内元素数量来避免bigKey,通常的做法是将一个大的集合类型的key拆分成若干小集合类型的key来达到目的。值得一提的是,List、Hash、Set 和ZSet来说,在集合元素个数和元素大小小于一定的阈值时,会使用内存紧凑型的底层数据结构进行保存,从而节省内存,规则如下:
- List:当List对象保存的所有字符串元素长度都小于list-max-ziplist-value(默认64字节),且List对象保存的元素数量小于list-max-ziplist-entries(默认512)时,List对象将采用ziplist编码以节省内存。
- Hash:当Hash对象保存的键值对的key和value的字符串长度都小于hash-max-ziplist-value(默认64字节),且Hash对象保持的键值对数量小于hash-max-ziplist-entries(默认512)时,Hash对象将采用ziplist编码以节省内存。
- Set:当Set对象保存的所有元素都是整数值,且Set对象保存的元素数量不超过set-max-intset-entries(默认512)时,Set对象将采用intset编码以节省内存。
- ZSet:当ZSet对象保存的元素数量小于zset-max-ziplist-entries(默认128),且ZSet对象保存的所有元素的长度小于zset-max-ziplist-value(默认64)时,ZSet对象将采用ziplist编码以节省内存。
另外,在读取bigKey的时候,我们尽量不要一次性将全部数据读取出来,而是采用分批的方式进行读取:利用scan命令进行渐进式遍历,将大量数据分批多次读取出来,减小redis的压力,避免阻塞的风险。
同样的,在删除bigKey的时候我们也可以使用scan命令来进行批量删除。如果你是用的redis是4.0之后的版本,则可以利用unlink命令配合lazy free配置(需要手动开启)来进行异步删除,避免主线程阻塞。