BigKey
bigKey 是指key 对应的value 所占的内存空间比较大,例如一个字符串类型的value 可以最大存储到512MB,一个列表类型的value 最多可以存储 2(32)-1个元素。bigKey 无论是空间复杂度和时间复杂度都不太友好,下面我们将介绍它的危害。
bigKey 的危害体现在五个方面
- 内存空间不均匀(平衡):例如在 Redis Cluster 中,bigKey 会造成节点的内存空间使用不均匀。
- 超时阻塞:由于Redis 单线程的特性,操作bigKey 比较耗时,也就意味着阻塞 Redis 可能性增大。
- 网络拥塞:每次获取bigKey 产生的网络流量较大,假设一个bigKey 为1MB,每秒访问为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按字节算是128MB/S)的服务器来说简直是灭顶之灾。
- 主动删除,被动过期删除,数据迁移等操作,由于处理Key的时间过长,也会发生阻塞。
- list , hash 等数据结构,大量的elements ,需要多次遍历,多次的系统调用也会耗费很多时间。
判断一个key 的大小,可以使用 debug object key 命令 查看 serializedlength 大小,如下:
127.0.0.1:6379> set name "11111111111111111111111111111111111"
OK
127.0.0.1:6379> debug object name
Value at:0x7f9563606630 refcount:1 encoding:embstr serializedlength:12 lru:12109803 lru_seconds_idle:11
127.0.0.1:6379> strlen name
(integer) 35
数据压缩
gzip是GNUzip的缩写,最早用于UNIX系统的文件压缩。HTTP协议上的gzip编码是一种用来改进web应用程序性能的技术,web服务器和客户端(浏览器)必须共同支持gzip。目前主流的浏览器,Chrome,firefox,IE等都支持该协议。常见的服务器如Apache,Nginx,IIS同样支持gzip。
gzip压缩比率在3到10倍左右,可以大大节省服务器的网络带宽。而在实际应用中,并不是对所有文件进行压缩,通常只是压缩静态文件。
同理,对一些比较大的Key,我们可以采用GZip 压缩,大大减少内存的使用和网络开销,但会增加CPU开销。GzipUtil 工具类定义如下
public final class GzipUtil {
private static final Logger log = LoggerFactory.getLogger(GzipUtil.class);
private static final int BYTE_LEN = 256;
public static final String GZIP_ENCODE_UTF_8 = "UTF-8";
private GzipUtil() {
}
public static String gzip(String text) {
if (null != text && text.length() >= 1) {
String gzipText = null;
ByteArrayOutputStream bos = null;
GZIPOutputStream gos = null;
try {
bos = new ByteArrayOutputStream();
gos = new GZIPOutputStream(bos);
gos.write(text.getBytes("UTF-8"));
gos.finish();
gzipText = new String((new Base64()).encode(bos.toByteArray()), "UTF-8");
} catch (Exception var8) {
log.error("Fail to gzip text: {}", text, var8);
} finally {
close(gos, bos);
}
return gzipText;
} else {
log.warn("Illegal empty text string to gzip, and will return null");
return null;
}
}
public static String ungzip(String gzipText) {
if (null != gzipText && gzipText.length() >= 1) {
String text = null;
ByteArrayOutputStream bos = null;
ByteArrayInputStream bis = null;
GZIPInputStream gis = null;
try {
bis = new ByteArrayInputStream((new Base64()).decode(gzipText.getBytes("UTF-8")));
gis = new GZIPInputStream(bis);
byte[] buffer = new byte[1024];
int offset = true;
bos = new ByteArrayOutputStream();
int offset;
while((offset = gis.read(buffer)) > 0) {
bos.write(buffer, 0, offset);
}
text = bos.toString();
} catch (Exception var10) {
log.error("Fail to ungzip gzipText : {}", gzipText, var10);
} finally {
close(gis, bis, bos);
}
return text;
} else {
log.warn("Illegal empty gzipText string, and will return null");
return null;
}
}
public static final String zip(String text) {
if (null != text && text.length() >= 1) {
ByteArrayOutputStream bos = null;
ZipOutputStream zos = null;
String zipText = null;
try {
bos = new ByteArrayOutputStream();
zos = new ZipOutputStream(bos);
zos.putNextEntry(new ZipEntry("0"));
zos.write(text.getBytes("UTF-8"));
zos.closeEntry();
zipText = new String((new Base64()).encode(bos.toByteArray()), "UTF-8");
} catch (Exception var8) {
log.error("Fail to zip text {}", text, var8);
} finally {
close(zos, bos);
}
return zipText;
} else {
log.warn("Illegal empty text string, and will return null");
return null;
}
}
public static final String unzip(String zipText) {
if (null != zipText && zipText.length() >= 1) {
ByteArrayOutputStream bos = null;
ByteArrayInputStream bis = null;
ZipInputStream zis = null;
String text = null;
try {
bos = new ByteArrayOutputStream();
bis = new ByteArrayInputStream((new Base64()).decode(zipText.getBytes("UTF-8")));
zis = new ZipInputStream(bis);
zis.getNextEntry();
byte[] buffer = new byte[1024];
int offset = true;
int offset;
while((offset = zis.read(buffer)) > 0) {
bos.write(buffer, 0, offset);
}
text = bos.toString();
} catch (IOException var10) {
log.error("Fail to unzip zipText: {}", zipText, var10);
} finally {
close(zis, bis, bos);
}
return text;
} else {
log.warn("Illegal empty zipText string, and will return null");
return null;
}
}
public static void close(Closeable... closeables) {
if (null != closeables && closeables.length >= 1) {
Closeable[] var1 = closeables;
int var2 = closeables.length;
for(int var3 = 0; var3 < var2; ++var3) {
Closeable closeable = var1[var3];
try {
if (null != closeable) {
closeable.close();
}
} catch (Exception var6) {
log.error("Fail to close {}", closeable.getClass().getSimpleName(), var6);
}
}
}
}
}
存储数据和查询数据代码如下:
public void put(@NotNull String cacheKey , String data) {
// GZIP压缩
String value = GzipUtil.gzip(data);
cacheService.put(cacheKey, data);
}
public void get(@NotNull String cacheKey) {
String gzip = cacheService.get(cacheKey);
if (StringUtils.isNotBlank(gzip)) {
String data = GzipUtil.ungzip(gzip);
return data;
}
return null;
}
Redis Scan 命令用于迭代数据库中的数据库键
SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。
SCAN 返回一个包含两个元素的数组, 第一个元素是用于进行下一次迭代的新游标, 而第二个元素则是一个数组, 这个数组中包含了所有被迭代的元素。如果新游标返回 0 表示迭代已结束。
127.0.0.1:6379> scan 0
1) "0"
2) 1) "cities"
2) "2021-06-04_users"
3) "name"
4) "2021-06-03_users"
5) "key"
127.0.0.1:6379>
寻找热点key
热门新闻事件 或 商品 通常 会给系统 带来巨大的流量,对存储这些信息的Redis 来说 却是一个巨大的挑战。 以 Redis Cluster 为例子,它会造成整体流量的不均衡,个别节点出现OPS过大的情况,极端情况下热点Key甚至会超过Redis 本身能够承受的OPS,因此寻找热点Key 对于开发和运维人员非常重要。
热点Key的危害:
- 请求过于集中在几个分片,导致这几个分片压力过大,无法发挥集群功能。
- 当单个分片压力过大,无法通过增加分片来解决问题。
ops是什么?
redis中的OPS 即operation per second 每秒操作次数。意味着每秒对Redis的持久化操作
-
代理端
像Twemproxy Codis 这些基于代理的Redis 分布式架构,所有客户端的请求都是通过代理端完成的,此架构最适合 做热点Key统计的,因为代理是所有Redis 客户端 和 服务端 的桥梁。
-
Redis 服务端
使用monitor 命令统计热点Key 是 很多开发和运维人员首先想到的,monitor 命令 可以监控到 Redis 执行的所有命令
ZBMAC-2f32839f6:redis-master yangyanping$ ./src/redis-cli
127.0.0.1:6379> set myname yangyanping
OK
127.0.0.1:6379> get myname
"yangyanping"
127.0.0.1:6379> set user:1 30
OK
127.0.0.1:6379> get user:1
"30"
monitor 命令执行后的结果
127.0.0.1:6379> monitor
OK
1622770113.161698 [0 127.0.0.1:54652] "COMMAND"
1622770129.910122 [0 127.0.0.1:54652] "set" "myname" "yangyanping"
1622770142.613021 [0 127.0.0.1:54652] "get" "myname"
1622770156.695975 [0 127.0.0.1:54652] "set" "user:1" "30"
1622770161.058709 [0 127.0.0.1:54652] "get" "user:1"
利用monitor 命令的结果 可以统计出一段时间内的热点Key
monitor 命令在高并发下会影响Redis 性能
-
机器
Redis 客户端使用TCP协议于服务器端进行通信,通信协议采用的是 RESP。如果站在机器的角度,可以通过对机器上所有 Redis 端口的TCP 数据包 进行抓取 完成热点Key 的统计。
Redis服务器与客户端通过RESP(Redis Serialization Protocol)协议通信。它是一种直观的文本协议,优势在于实现异常简单,解析性能极好
为何要设计这种浪费流量的文本协议?
Redis 的设计者认为数据库的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。Redis 将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情况下可以达到了 10w/s 的超高 QPS。
基于Redis客户端做探测
由于redis的命令每次都是从客户端发出,基于此我们可以在redis client的一些代码处进行统计计数,每个client做基于时间滑动窗口的统计,超过一定的阈值之后上报至server,然后统一由server下发至各个client,并且配置对应的过期时间。
这个方式看起来更优美,其实在一些应用场景中并不是那么合适,因为在client端这一侧的改造,会给运行的进程带来更大的内存开销,更直接的来说,对于Java和goLang这种自动内存管理的语言,会更加频繁的创建对象,从而触发gc导致接口响应耗时增加的问题,这个反而是不太容易预料到的事情。
最终可以通过各个公司的基建,做出对应的选择。
拆key
如何既能保证不出现热key问题,又能尽量的保证数据一致性呢?拆key也是一个好的解决方案。
我们在放入缓存时就将对应业务的缓存key拆分成多个不同的key。如下图所示,我们首先在更新缓存的一侧,将key拆成N份,比如一个key名字叫做"good_100",那我们就可以把它拆成四份,“good_100_copy1”、“good_100_copy2”、“good_100_copy3”、“good_100_copy4”,每次更新和新增时都需要去改动这N个key,这一步就是拆key。
对于service端来讲,我们就需要想办法尽量将自己访问的流量足够的均匀,如何给自己即将访问的热key上加入后缀。几种办法,根据本机的ip或mac地址做hash,之后的值与拆key的数量做取余,最终决定拼接成什么样的key后缀,从而打到哪台机器上;服务启动时的一个随机数对拆key的数量做取余。
一些整合的方案
目前市面上已经有了不少关于hotKey相对完整的应用级解决方案,其中京东在这方面有开源的hotkey工具,原理就是在client端做洞察,然后上报对应hotkey,server端检测到后,将对应hotkey下发到对应服务端做本地缓存,并且这个本地缓存在远程对应的key更新后,会同步更新,已经是目前较为成熟的自动探测热key、分布式一致性缓存解决方案
本地缓存的另外一种思路 配置中心
对于熟悉微服务配置中心的伙伴来讲,我们的思路可以向配置中心的一致性转变一下。拿nacos来举例,它是如何做到分布式的配置一致性的,并且相应速度很快?那我们可以将缓存类比配置,这样去做。
长轮询+本地化的配置。首先服务启动时会初始化全部的配置,然后定时启动长轮询去查询当前服务监听的配置有没有变更,如果有变更,长轮询的请求便会立刻返回,更新本地配置;如果没有变更,对于所有的业务代码都是使用本地的内存缓存配置。这样就能保证分布式的缓存配置时效性与一致性。
其他可以提前做的预案
上面的每一个方案都相对独立的去解决热key问题,那么如果我们真的在面临业务诉求时,其实会有很长的时间来考虑整体的方案设计。一些极端的秒杀场景带来的热key问题,如果我们预算充足,可以直接做服务的业务隔离、redis缓存集群的隔离,避免影响到正常业务的同时,也会可以临时采取更好的容灾、限流措施。