Redis原理之BigKey和热点Key

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缓存集群的隔离,避免影响到正常业务的同时,也会可以临时采取更好的容灾、限流措施。 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值