缓存设计

缓存穿透

缓存穿透原因

缓存穿透是指查询一个根本不存在的数据,缓存层不会命中,从而大量的查询存储层。

通常我们的设计如图下所示:

QQ20200626-141708@2x

缓存穿透会导致每次的请求都会执行db查询,增加数据库的压力,从而失去了缓存的意义。

造成这种情况出现基本原因有两个

  • 自身业务代码或者数据出现问题
  • 一些恶意攻击,爬虫等造成大量空命中
缓存穿透解决方案

解决方案 一 :

​ 对空对象进行缓存,也就是说,即使从db获取不到数据,也进行缓存,放到redis中。

​ 但是这种方案,如果是恶意攻击的话,不太适用,倘若对方生成上亿个key,查询不到难道都放redis中吗?

解决方案二:

​ 布隆过滤器:

​ 布隆过滤器是一个大型的位数组和几个不一样的无偏hash函数。所谓无偏就是能够把元素的hash值算的比较均匀。

向布隆过滤器添加key时,会使用多个hash函数对key进行hash运算,每个函数算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置,再把位数组的这几个位置都设置为1,就完成了add操作。

向布隆过滤器询问key是否存在时,同样会用多个hash函数对key进行运算,多个函数运算的整数索引值再取模位数组长度,最后得到key的多个所在位置,已知key所在的位置,取出数据判断是否都为1,只要有一个位置为0,则表示这个key是不存在的,但是如果都为1的话,这并不表示这个key就一定存在,只是极有可能存在。因为有可能是多个key产生了hash碰撞,导致其他的key对这个位置的数据设置为了1。

大概结构图长下面这个样子:

​ 布隆过滤器在对多个key进行hash运算时,存在hash碰撞的可能,如下所示,两个key的hash-3算法碰撞。位数组越小,产生的概率就会越大。

redis_boolean

对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都可以过滤掉,不让请求再往后端发送,当布隆过滤器说某个值存在时,这个值可能存在;当它说不存在时,那就肯定不存在。

代码维护较为复杂,但是缓存空间占用很少。

Guava 布隆过滤器

谷歌的Guava包里面有布隆过滤器实现

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>29.0-jre</version>
</dependency>

示例代码

public class GuavaBooleanFilterMain {
    public static void main(String[] args) {
        BloomFilter<String> bloomFilter = BloomFilter.create(
                Funnels.stringFunnel(Charset.defaultCharset()),
                1000,//预期的数据量
                0.001//预期的误差值,必须为正,且小于0
        );
        //添加指定的key
        bloomFilter.put("zhangsan");
        bloomFilter.put("lisi");

        System.out.println(bloomFilter.mightContain("zhangsan"));
        System.out.println(bloomFilter.mightContain("lisi"));
        System.out.println(bloomFilter.mightContain("wangwu"));
    }
}
-----输出结果如下
true
true
false
Redisson 布隆过滤器

Guava 的布隆过滤器只适合单机模式下的进程。

而大多数场景都是集群、分布式部署的,所以我们需要分布式的布隆过滤器,Redisson对这方面也有实现。

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.1</version>
        </dependency>

先创建JavaBean

public class SomeObject implements Serializable {
    private String key;
    private String value;
    public String getKey() {
        return key;
    }
    public String getValue() {
        return value;
    }
    public void setKey(String key) {
        this.key = key;
    }
    public void setValue(String value) {
        this.value = value;
    }
    public SomeObject(String key, String value) {
        this.key = key;
        this.value = value;
    }
}
单点Redis使用
   @RequestMapping("redis_filter")
    public String redisFilter() {
        RBloomFilter<SomeObject> bloomFilter = redissonClient.getBloomFilter("sample");
        // 初始化布隆过滤器
        // 预期插入数据 = 55000000
        // 预期错误概率 必须为正数,且小于0 = 0.03
        bloomFilter.tryInit(55000000L, 0.03);

        bloomFilter.add(new SomeObject("field1Value", "field2Value"));
        bloomFilter.add(new SomeObject("field5Value", "field8Value"));

        System.out.println("contains-> field1Value :"+bloomFilter.contains(new SomeObject("field1Value", "field2Value")));
        System.out.println("contains-> field1Value :"+bloomFilter.contains(new SomeObject("field5Value", "123")));
        System.out.println("count:" + bloomFilter.count());
        return "SUCCESS";
    }
-- 输出内容如下
  
contains-> field1Value :true
contains-> field1Value :false
count:4
集群Redis节点使用

该api 官方文档上说只支持redisson pro版本使用,我这里就没有再去测试了。

RClusteredBloomFilter<SomeObject> bloomFilter = redisson.getClusteredBloomFilter("sample");
// initialize bloom filter with 
// expectedInsertions = 255000000
// falseProbability = 0.03
bloomFilter.tryInit(255000000L, 0.03);
bloomFilter.add(new SomeObject("field1Value", "field2Value"));
bloomFilter.add(new SomeObject("field5Value", "field8Value"));
bloomFilter.contains(new SomeObject("field1Value", "field8Value"));

缓存失效

由于大批量缓存在同一时间失效,可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大,甚至挂掉,对于这种情况,我们可以在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。

比如说基础失效时间为3分钟,那么3minute + random(60) second ,为最终的失效时间。

缓存雪崩

缓存雪崩指的是缓存层支撑不住或者宕机后,流量会大量的打到后端的存储层。

由于缓存层承载着大量请求,有效保护了存储层,如果缓存层由于某些原因不能提供服务(比如大量并发请求、缓存设计不好、类似大量请求访问bigkey ; 导致缓存能支撑的并发急剧下降),于是大量请求都会打到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。

预防和解决缓存雪崩问题,可以从以下三个方面进行。

  • 保障缓存层服务的高可用性,比如使用Redis Sentinel 、 Redis Cluster。
  • 依赖隔离组件为后端接口限流并降级。
  • 提前演练。在项目上线前,演练缓存层宕机后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

热点缓存key重建优化

使用 “缓存+过期时间” 的策略既可以实现加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点 key,并发量非常大
  • 重建缓存不能在短时间完成,可能是一个复杂的计算、复杂的Sql、多次IO 等等

在缓存失效的瞬间,有大量线程来重建缓存,会造成后端负载加大,甚至可能会让应用崩溃。

要解决这个问题主要就是避免大量线程同时重建缓存。

可以利用互斥锁来解决,一般来说,使用JDK内置的互斥锁足够了,保障一个进程只有一个线程执行重建缓存。

如果有大量的集群节点的话,可以使用Redis的分布式锁来做,最终的目的是达到只有一个线程重建缓存。

性能优化

键值设计

key 设计

1、可读性和可管理性

以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id

share:article:1

2、简洁性

保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视

原始key: user:{uid}:friends:message:{mid}
简化key: u:{uid}:fr:m{mid}
value 设计

1、拒绝bigkey (防止网卡流量、慢查询)

  • 字符串类型 : 它的big体现在单个value值很大,一般认为超过10kb就是bigkey。

  • 非字符串类型 : 哈希、列表、集合、有序即可,它们的big体现在元素个数太多。

一般来说,String类型控制在10kb以内,hash、list、set、zset元素个数不要超过5000.

非字符串的bigkey,不要直接使用del删除,否则会引起阻塞情况的发生,应使用hcan、sscan、zscan方式渐进式删除。

同时要注意防止bigkey过期时间自动删除问题,如果没有设置异步过期的话,redis过期删除也会造成阻塞。

bigkey的危害
  • 导致redis阻塞
  • 网络拥塞
    • bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通千兆网卡的服务器来讲简直是灭顶之灾。
  • 过期删除
    • 有个bigkey,一旦设置了过期时间,当它过期后,会被删除,如果没有使用Redis 4.0的过期异步删除( lazyfree-lazy- expire yes ),就会存在阻塞Redis的可能性。
bigkey的产生
  • 社交类:粉丝列表,如果某些明星或者大V不精心设计下,必是bigkey。
  • 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。
  • 缓存类:将数据从数据库load出来,序列化放到Redis里,这个方式非常常用,但是有两个地方需要注意。
    • 是不是有必要把所有字段都缓存
    • 有没有相关关联的数据,可能为了图方便把相关数据都存一个key下,产生bigkey。
如何优化bigkey
  • 拆成多个key,可以参考 ConcurrentHashMap的分段式设计。
    • Biglist --> list-1、list-2、list-3...list-N
  • 如果bigkey不可避免,也要考虑一下有没有必要每次都把所有元素取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量使用优雅的方式来处理。
  • 选择适合的数据类型
    • 比如对象类型,应放到hash数据类型里。 例如 : " hmset user:1 name zhangsan age 20"
  • 控制key的生命周期
    • 使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)。

命令使用

  • O(N) 命令关注N的数量
    • 例如hgetall、lrange、smembers、zrange、sinter等并发不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替。
  • 禁用命令
    • 禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。
  • 合理使用select
    • redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。
  • 使用批量操作提高效率
    • 原生命令:mget、mset...
    • 非原生命令:可以使用pipeline管道提高效率。
  • Redis事务功能较弱,不建议过多使用,可以用lua替代

客户端使用

避免多个应用使用一个Redis实例

使用带有连接池的数据库,可以有效控制连接,同时提高效率,比如JedisPool。

客户端参数调优

  • maxActive:最大连接数
    • 这个参数考虑的因素比较多:
    • 业务希望Redis的并发量
    • 客户端执行命令时间
    • Redis资源: 应用个数 * maxActive 是不能超过 redis的最大连接数 maxclients。
    • 资源开销:虽然希望控制空闲连接(连接池此刻可用连接),但是不希望因为连接池的频繁释放和创建连接造成不必要的开销。
    • 假设:一次redis命令时间的平均耗时约为1ms,那么一个连接的QPS大约是1000。业务期望的QPS是50000,那么理论上需要的资源池大小是 50000 / 1000 = 50 个连接。但事实上这只是个理论值,还要考虑到预留一些资源,通常我们会为maxActive设置的比理论值大一些。
    • maxActive 并不是设置越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于Redis这种单线程的服务,一个大命令的阻塞即使设置再大资源池仍然会无济于事。
  • maxIdle
    • maxIdle实际上才是业务需要的最大连接数,maxActive是为了给出余量,所以maxIdle不要设置过小,否则会有创建新连接的开销。
    • 连接池的最佳性能是maxActive = maxIdle,这样就避免连接池伸缩带来的性能干扰。
    • 但是如果并发量不大或者maxActive设置过高,会导致不必要的连接资源浪费,一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxActive可以再放大一倍。
  • minIdle
    • minIdle (至少需要保持的空闲连接数),在使用连接的过程中,如果连接数超过了minIdle,那么会继续建立连接,如果超过了maxIdle,当超过的连接执行完业务后悔慢慢被移出连接池并释放掉。
    • 如果系统启动完,马上就会有很多的请求过来,那么可以给redis连接池做预热,比如快速的创建一些redis连接,执行简单命令,类似ping() , 快速的将连接池里的空闲连接提升到minIdle的数量。

高并发下建议客户端添加熔断功能(netflix、hystrix)

设置合理的密码,如有必要可以使用SSL加密访问

Redis 清除策略

Redis对于过期键有三种清除策略

  • 被动删除:当读/写一个已经过期的key时,会出发惰性删除策略,直接删除掉这个过期key

  • 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期主动淘汰一批已过期的key

  • 当前已用内存超过maxmemory限定时,触发主动清理策略

当Redis运行在主从模式时,只有主节点才会执行被动和主动这两种过期删除策略,然后把删除操作" del key " 同步到从节点。

第三种策略的情况如下:

当前已用内存超过maxmemory限定时,会触发主动清理策略

根据自身业务类型,选好maxmemory-policy ( 最大内存淘汰策略 ),设置好过期时间。如果不设置最大内存,当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap ) ,会让Redis的性能急剧下降。

默认策略是volatile-lru,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题。

其他策略如下:

  • allkeys-lru: 根据lru算法删除键,不管数据有没有设置超时时间,直到腾出足够空间位置。

  • allkeys-random : 随机删除所有键,直到腾出足够空间为止。

  • volatile-random : 随机删除过期键,直到腾出足够空间为止。

  • Volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。

  • noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息 "(error)

    OOM command not allowed when used memory",此时Redis只响应读操作。