1 优雅的key结构
Redis的Key虽然可以自定义,但最好遵循下面的几个最佳实践约定:
遵循基本格式:[业务名称]:[数据名]:[id]
,例如我们的登录业务,需要保存用户信息,其key可以设计成如下格式
这种设计的好处不仅在于可读性强,还在于可以避免key的冲突问题,而且方便管理
Key的长度不超过44字节
无论是哪种数据类型, key都是string类型,string类型的底层编码包含int、embstr和raw三种。如果key中全是数字,那么就会直接以int类型去存储,而int占用的空间也是最小的,当然出于业务需求,我们不可能将key设计为一个全数字的,而如果不是纯数字,底层存储的就是SDS内容,如果小于44字节,就会使用embstr类型,embstr在内存中是一段连续的存储空间,内存占用相对raw来说较小,而当字节数大于44字节时,会转为raw模式存储,在raw模式下,内存空间不是连续的,而是采用一个指针指向了另外一段内存空间,在这段空间里存储SDS内容,这样空间不连续,访问的时候性能也就会收到影响,还有可能产生内存碎片
需要注意的是,如果你的redis版本低于4.0,那么界限是39字节而非44字节
Key中不包含一些特殊字符
2 拒绝BigKey
2.1 判断BigKey
BigKey顾名思义就是一个很大的Key,这里的大并不是指Key本身很大,而是指包括这个Key的Value在内的一整个键值对很大
BigKey通常以Key-Value的大小或者Key中成员的数量来综合判定,例如:
- Key的Value过大:例如一个String类型的Key,它的Value为5MB
- Key中的成员数过多:例如一个ZSET类型的Key,它的成员数量为10000个
- Key中成员的Value过大:例如一个Hash类型的Key,它的成员数量虽然只有1000个,但这些成员的Value总大小为100 MB
那么如何判断元素的大小呢?redis中为我们提供了相应的命令,语法如下:
memory usage 键名
这条命令会返回一条数据占用内存的总大小,这个大小不仅包括Key和Value的大小,还包括数据存储时的一些元信息,因此可能你的Key与Value只占用了几十个字节,但最终的返回结果是几百个字节
但是我们一般不推荐使用memory指令,因为这个指令对CPU的占用率是很高的,实际开发中我们一般只需要衡量Value的大小或者Key中的成员数即可
例如如果我们使用的数据类型是String,就可以使用以下命令,返回的结果是Value的长度
strlen 键名
如果我们使用的数据类型是List,就可以使用以下命令,返回的结果是List中成员的个数
llen 键名
一般我们推荐,单个key的value小于10KB,集合类型的key元素数量小于1000
2.2 BigKey的危害
网络阻塞
当我们对一个BigKey发起读请求时,只需少量的QPS就可能导致带宽使用率被占满,导致Redis实例乃至所在物理机变慢,例如一个bigkey占用5M内存,只需要QPS达到20,那么1秒钟就会占100M的带宽
数据倾斜
集群环境下,由于所有插槽一开始都是均衡分配的,因此BigKey所在的Redis实例内存使用率会远超其他实例,从而无法使数据分片的内存资源达到均衡,最后不得不手动重新分配插槽,增加运维人员的负担
Redis阻塞
对元素较多的hash、list、zset等做运算会耗时较久,而且由于Redis是单线程的,在运算过程中会导致服务阻塞,无法接收其他用户请求
CPU压力
对BigKey的数据进行序列化或反序列化都会导致CPU的使用率飙升,影响Redis实例和本机其它应用
2.3 如何发现BigKey
既然我们知道了什么叫BigKey以及BigKey的危害,那么如何去快速发现Redis中所有的BigKey呢?这里为大家提供以下几种方案:
1)利用Redis本身提供的命令
利用以下命令,可以遍历分析所有key,并返回Key的整体统计信息与每种数据类型中Top1的BigKey
redis-cli -a 密码 --bigkeys
演示如下(这里我的redis没有设置密码,如果你的redis设置了密码,则需要使用 -a 密码
进行连接)
2)自己手动编写程序进行扫描
我们可以通过自己编写程序,将Redis中所有的数据查询出来并一一统计长度来找出BigKey,这里不建议使用keys *
来查询所有数据,因为keys *
是一次将所有的数据全部查找出来,如果数据量很大,key *
一次可能要几十秒甚至几分钟,在如此长的时间内,Redis的主线程会因为执行该命令而被阻塞。
这里建议使用redis提供的scan命令,语法如下:
scan 起始位置 count 数量
scan扫描有点类似于分页查询,而被分页的对象是redis中所有的数据,scan命令调用一次只会从指定的起始位置开始返回指定数量的数据,以及此次扫描结束时光标所在的位置,下一次扫描时就需要从这个光标开始继续往下扫描
这里提供一个已经编写好的查找BigKey的测试类,大家可以参考一下
import com.heima.jedis.util.JedisConnectionFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import redis.clients.jedis.Jedis; import redis.clients.jedis.ScanResult; import java.util.HashMap; import java.util.List; import java.util.Map; public class JedisTest { private Jedis jedis; @BeforeEach void setUp() { // 1.建立连接 // jedis = new Jedis("192.168.150.101", 6379); jedis = JedisConnectionFactory.getJedis(); // 2.设置密码 jedis.auth("123321"); // 3.选择库 jedis.select(0); } //设置string类型的长度上限,超过这个上限就判断为BigKey final static int STR_MAX_LEN = 10 * 1024; //设置集合类型允许的成员数量上限,超过这个上限就判断为BigKey final static int HASH_MAX_LEN = 500; @Test void testScan() { int maxLen = 0; long len = 0; String cursor = "0"; do { // 扫描并获取一部分key ScanResult<String> result = jedis.scan(cursor); // 记录cursor cursor = result.getCursor(); List<String> list = result.getResult(); if (list == null || list.isEmpty()) { break; } // 遍历 for (String key : list) { // 判断key的类型 String type = jedis.type(key); switch (type) { case "string": len = jedis.strlen(key); maxLen = STR_MAX_LEN; break; case "hash": len = jedis.hlen(key); maxLen = HASH_MAX_LEN; break; case "list": len = jedis.llen(key); maxLen = HASH_MAX_LEN; break; case "set": len = jedis.scard(key); maxLen = HASH_MAX_LEN; break; case "zset": len = jedis.zcard(key); maxLen = HASH_MAX_LEN; break; default: break; } if (len >= maxLen) { System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len); } } } while (!cursor.equals("0")); } @AfterEach void tearDown() { if (jedis != null) { jedis.close(); } } }
3)第三方工具
利用第三方工具,这里推荐Redis-Rdb-Tools,它会针对Redis的RDB快照文件来分析内存使用情况,由于分析的是快照文件,因此不会占用Redis服务的任何性能,但是时效性相对较差
Redis-Rdb-Tools的github网址:https://github.com/sripathikrishnan/redis-rdb-tools
4)网络监控
使用自定义工具,监控进出Redis的网络数据,超出预警值时主动告警。一般阿里云搭建的云服务器就有相关监控页面:
2.4 如何删除BigKey
BigKey内存占用较多,因此即便我们使用的是删除操作,删除BigKey也需要耗费很长时间,导致Redis主线程阻塞,引发一系列问题。
如果redis版本在4.0之后,我们可以通过异步删除命令unlink来删除一个BigKey,该命令会先把数据标记为已删除,然后再异步执行删除操作。
如果redis版本在4.0之前,针对集合类型,我们可以先遍历BigKey中所有的元素,先将子元素逐个删除,最后再删除BigKey。至于如何遍历,针对不同的集合类型,可以参考以下不同的命令
3 恰当的数据类型
找出BigKey中,我们应该如何对BigKey进行优化呢?这里我们需要选择恰当的数据类型
3.1 存储对象
如果我们要存储一个User对象,有三种存储方式:
1)JSON字符串
将一整个对象转成Json格式进行存储
user:1 | {“name”: “Jack”, “age”: 21} |
---|
优点:实现简单粗暴
缺点:数据耦合,不够灵活,且需要维护JSON结构,占用内存相对较大
2)字段打散
将对象的不同属性存储到不同的key中
key | value |
---|---|
user:1:name | Jack |
user:1:age | 21 |
优点:可以灵活访问对象任意字段
缺点:由于每条数据都会有一些元信息需要存储,因此将一个Key分成多个Key进行存储,占用的内存会变的更大,且由于字段分散,当我们需要做统一控制时会变得很困难
3)hash(推荐)
使用hash结构来存储对象,对象的一个属性对应集合中的一个成员
user:1 | name | jack |
age | 21 |
优点:hash结构底层会使用ziplist压缩列表,空间占用小,且可以灵活访问对象的任意字段
缺点:代码编写时相对复杂
3.2 Hash优化
假如有一个hash类型的key,其中有100万对field和value,field是自增id,这个key存在什么问题?如何优化?
key | field | value |
someKey | id:0 | value0 |
..... | ..... | |
id:999999 | value999999 |
当hash的entry数量超过500时,底层会使用哈希表存储而不是ZipList,内存占用会变得比较高,虽然这个数量限制我们是可以通过以下命令进行修改的
config set hash-max-ziplist-entries 数量
但是entry数量如果实在太大了还是会导致BigKey问题,这是需要优化的,这里提供以下两种解决思路:
1)拆分为String类型(不推荐)
将Hash中的每个成员单独使用一个String类型的key进行存储
key | value |
id:0 | value0 |
..... | ..... |
id:999999 | value999999 |
这种方案是不推荐的,存在的问题如下
- string结构底层没有太多内存优化的,且存储这些key的同时也会存储大量的元信息,虽然数据打散了,但是整体内存占用更多了
- 如果我们想要批量获取这些数据,会变得格外麻烦
2)拆分成多个Hash类型
拆分为小的hash,将 id / 100 作为key, 将id % 100 作为field,这样每100个元素为一个Hash,这种方式相对上面两种来说内存占用会少很多,而且解决了Bigkey的问题,当然多少个元素作为一个Hash是自己定义的,这里建议数量不要超过500
key | field | value |
key:0 | id:00 | value0 |
..... | ..... | |
id:99 | value99 | |
key:1 | id:00 | value100 |
..... | ..... | |
id:99 | value199 | |
.... | ||
key:9999 | id:00 | value999900 |
..... | ..... | |
id:99 | value999999 |