redis缓存设计与性能优化笔记

157 阅读8分钟

缓存穿透

  缓存穿透指的是根本不存在的数据,在查询的时候,大量请求直接打在数据库上。
造成缓存穿透的原因有两个
1、自身业务代码或者数据出现问题
2、一些恶意攻击

缓存穿透解决方案

1、缓存空对象

String get(String key){
    //从缓存中获取数据
    String cacheValue = cache.get(key);
    
    //缓存为空
    if(StringUtils.isBank(cacheValue)){
        //从存储中获取
        String storageValue = storage.get(key);
        cache.set(key, storageValue);
        //如果存储数据为空,需要设置一个过期时间(300秒)
        if(storageValue == null){
            cache.expire(key, 60*5);
        }
        return storageValue;
    }else{
        //缓存为非空
        return cachceValue;
    }
}

2、布隆过滤器

  对于恶意攻击,向服务器请求大量不存在的数据造成缓存穿透,还可以使用布隆过滤器先做一次过滤。当布隆过滤器说某个值存在时,这个值可能不存在,但他说不存在时,那肯定不存在。
  这种方法适用于数据命中不高,数据相对固定,实时性低(通常数据集比较大)的应用场景,代码维护较为复杂,但是缓存空间占用很少。
可以使用redisson实现布隆过滤器,引入依赖

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

伪代码示例

public class void main(String[] args){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6379");
    //构造redisson
    RedissonClient redisson = Redisson.create(config);
    
    RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
    //初始化布隆过滤器:预计元素100000000,误差率为3%,根据这个两个参数会计算出底层bit数组大小
    bloomFilter.tryInit(预计元素100000000L, 0.03);
    //将duay插入到布隆过滤器中
    bloomFilter.add("duay");
    
    //判断下面号码是否存在布隆过滤器中
    System.out.println(bloomFilter.contains("zhangsan"));
    System.out.println(bloomFilter.contains("lisi"));
    System.out.println(bloomFilter.contains("wangwu"));
}

缓存失效(击穿)

   由于大批量缓存在同一时间失效,可能导致大量请求同时穿透缓存到达数据库。
  对于缓存失效。我们在批量增加缓存的时候最好将这一批数据缓存时间设置为一个时间段内的不同时间。
伪代码示例

String get(String key){
    //从缓存中获取数据
    String cacheValue = cache.get(key);
    //缓存为空
    if(StringUtils.isBank(cacheValue)){
        //从存储中获取
        String storage = storage.get(key);
        cachce.set(key, storageValue);
        //设置一个过期时间(300-600之间的一个随机数)
        int expireTime = new Random().nextInt(300) + 300;
        if(storageValue == null){
            cache.expire(key, expireTime);
        }
    }
}

缓存雪崩

  缓存层支撑不住或宕机后,大量数据打到数据库
可从三方面入手
1、保证缓存高可用,比如redis sentinel或redis cluster
2、依赖隔离组件为后端限流熔断并降级,如sentinel或cluster
3、提前演练,在项目上线之前做一些预案设定

热点缓存key重建优化

  本来商品是一个冷门商品,平时都没什么人访问,但是突然一个事件,导致这个商品变成热门商品。
  开发人员使用"缓存+过期时间"的策略皆可以加速数据读写,又可以保证数据定期更新。但是如果有两个问题如果同时出现,可能会对应用造成致命危害:
1、当前key是一个热点key,并发量非常大。
2、重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂sql、多次IO,多个依赖等。
  在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用奔溃。
  我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可
伪代码示例
冷数据突然变成热数据

String get(String key){
    //从redis中获取数据
    String value = redis.get(key);
    //如果value为空,刚开始重构缓存
    if(value == null){
        //只允许一个线程重建缓存,使用nx,并设置过期时间ex
        String mutexKey = "mutext:key:"+key;
        if(redis.set(mutexKey, "1", "ex 180", "nx")){
            //从数据源获取数据
            value = db.get(key);
            //回写redis,并设置过期时间
            redis.setnx(key, timeout, value);
            //删除key_mutex
            redis.delete(mutexKey);
        }else{//其他线程休息50毫秒后重试
            Thread.sleep(50);
            get(key);
        }
    }
    return value;
}

缓存和数据库双写不一致

在大并发下,同时操作数据库与缓存会存在数据不一致性问题
  1、线程一向数据库中写数据,然后更新到缓存中。
  2、线程二向数据库中写数据,然后更新到缓存中。
  3、正常情况下,先线程一执行完,然后线程二执行完,但是出现了问题,导致线程一更新完数据库出现网络抖动,造成线程二把数据库更新了,然后线程一的缓存才更新,现在数据库的数据存的是线程二的,而缓存的数据存的是线程一的数据。这时候就出现了数据双写不一致的问题。
解决方案
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
2、就算并发高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致们可以通过读写锁保证并发读写或写写的时候排好队,读读的时候相当于无锁。
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
总结:
  以上是我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没有必要加缓存了,可以直接操作数据库。放入缓存的数据应该是针对实时性、一致性要求不是很高的数据。不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性。

开发规范与性能优化

一 、键值设计

1、key名设计

(1)【建议】:可读性和可管理性
以业务名(或数据库名)为前缀(防止key冲突),用冒号分割,比如业务名:表名:id
trade:order:1
(2)【建议】:简洁性
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视。
例如:
user:{uid}:friends:messages:{mid}简化为:u:{uid}:fr:m:{mid}
(3)【强制】:不要包含特殊字符
反例:包含空格、换行、单双引号以及其他转义字符

value设计

(1)【强制】:拒绝bigkey(防止网卡流量,慢查询)
  在redis中,一个字符串最大512MB,一个二级数据结构(例如hash,list,set,zset)可以存储大约40亿个(2^32-1)个元素,但实际如果下面两种情况,我们就会认为它是bigkey
1、字符串类型:它的big体现在单个value值很大,一般认为超过10kb就是bigkey。
2、非字符串类型:哈希、列表、集合、有序集合,他们的big体现在元素个数太多。
bigkey的危害
1、导致redis阻塞
2、网络拥塞,造成带宽不够了
3、过期删除
bigkey的产生
一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模不清楚造成的,例如:
1、社交类:粉丝列表,如果某明星或者大v不精心设计下,必是bigkey
2、统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必然是bigkey
3、缓存类:将数据从数据库load出来序列化放到redis里,这个方式非常常用,但有两个地方需要注意,第一,是不是有必要吧所有字段都缓存。第二,有没有相关关联的数据,有的同学为了方便,把所有的相关数据都存在一个key下,产生bigkey。
优化bigkey
1、拆
  big hash:可以将数据分段存储,比如一个大的key,假设存了1百万的用户数据,可以拆分成200个key,每个key下面存放5000个用户数据
  假设有几千万的用户数据,一般把活跃用户放在缓存里面
2、如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量也是一样,尽量使用优雅的方式来处理。
【推荐】:选用合适的数据类型。
  例如实体类(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)
反例:

set user:1:name tom
set user:1:age 19
set user:1:favor football

正例:

hmset user:1 name tom age 19 favor football

3、【推荐】:控制key的生命周期,redis不是垃圾桶
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)
一般首页商品,秒杀商品都是会在缓存中找到的。
加微信群一块讨论学习技术:Day9884125