Redis缓存的设计

一、Redis的缓存设计不合理会存在的问题

Redis作为缓存,但是缓存设计的不合理就会有以下的问题:

  • 缓存失效 
  • 缓存穿透
  • 缓存雪崩

缓存失效

由于大批量的缓存在同一个时间点失效,可能造成大量请求同时穿透缓存直达数据库,可能造成数据库的压力瞬间增大,甚至数据库挂掉的情况。

例如:热点缓存在初始化的时候,会有拿出很多的数据,为保证数据的最新特性,一般都会设置一个超时时间;但是当这个超时时间到的时候,数据缓存就会全部失效,造成所有请求压力全部作用到数据库上。

解决方法在缓存初始化的时候,超时时间设置的不一样。

伪代码,如下:

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

缓存穿透  

缓存穿透是指查询一个根本不存在的数据,缓存层不会命中,大量的请求全部落到数据库存储层上,严重时造成数据库挂掉。

通常是出于容错的考虑,如果从存储层查询不到的不到数据,则不写入到缓存层。

造成缓存穿透的原因主要有两个:

(1)自身业务代码或数据出现问题;

(2)一些恶意攻击、爬虫等造成大量空命中;

解决方法

方法一:将空对象缓存到Redis,并设置超时时间;但是若黑客制造了上千万个key,那存储到redis就会占用很大的空间。

伪代码如下:

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

方式二布隆过滤器

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

  布隆过滤器的底层实际上一个大型的二进制数组(BitArray,即里面只能放0和1,它的里面存储的不是真正的值而是0或1;真正的数据值经过hash函数计算后,得到一个数字n,那么就设置 BitArray[n] = 1 (即数据为n的下标存储的值变为1)。为了防止 hash冲突,可以使用多个 hash函数经过多次计算得到。因为hash冲突的存在,所以说某个值存在时,它可能不存在;当它不存在时,那肯定就不存在。

  布隆过滤器占用的空间很少,效率也高。

(1)用guvua包自带的布隆过滤器,引入依赖

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>22.0</version>
</dependency>

(2)示例伪代码

import com.google.common.hash.BloomFilter;

//初始化布隆过滤器
//1000:期望存入的数据个数,0.001:期望的误差率
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf‐8")), 1000, 0.001);

//把所有数据存入布隆过滤器
void init(){
	for (String key: keys) {
	    bloomFilter.put(key);
	}
}

String get(String key) {
	// 从布隆过滤器这一级缓存判断下key是否存在
	Boolean exist = bloomFilter.mightContain(key);
	if(!exist){
	        return "";
	}
	
	// 从缓存中获取数据
	String cacheValue = cache.get(key);
	// 缓存为空
	if (StringUtils.isBlank(cacheValue)) {
		// 从存储中获取
		String storageValue = storage.get(key);
		cache.set(key, storageValue);
		
		// 如果存储数据为空, 需要设置一个过期时间(300秒)
		if (storageValue == null) {
			cache.expire(key, 60 * 5);
		}
		return storageValue;
	} else {
		// 缓存非空
		return cacheValue;
	}
}

 注意此处使用的是单机版的布隆过滤器,实际上 Redisson 也实现了布隆过滤器。

缓存雪崩

缓存雪崩指的是缓存层支撑不住或挂掉后,流量全部作用到存储层上,造成存储层也给挂掉。

解决方法

(1)保证缓存层服务的高可用,使用 Redis的哨兵或Redis的集群方式;

(2)依赖隔离组件为后端限流和降级。使用 springCloud 的组件 Hystrix 来限流降级;

热点缓存key重建

Redis的缓存层中没有数据,但是在同一时刻获取该数据的请求多达几十W的QPS,造成这么多的请求全部作用到数据库上。

例如:某一个突发的新闻,但是没有缓存到Redis,但是同一个时刻查看该新闻的人多达几十万,造成这么多的请求全部作用到数据库上,导致数据库挂掉。

解决方法:并发量较大的时候,可以让一个请求去数据库查询,其他请求等待。(使用分布式锁实现

伪代码如下:

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

  

二、Redis使用的规范

1、键值的设计

(1)key的命名

  • 建议】以业务名或数据库名为前缀,用冒号分割;  trade:order:1 
  • 建议】保证语义的前提下,控制 key 的长度(key比较多时占用的内存比较大)
user:{uid}:friends:messages:{mid} 
简化为
u:{uid}:fr:m:{mid}

(2)value 的设计

  • 强制】拒绝 bigkey (防止网卡流量、查询慢)

在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际中如果下面两种情况,我就会认为它是bigkey。

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

  b. 非字符串类型:哈希(hash)、列表(list)、集合(set)、有序集合(zset),它们的big体现在元素个数太多,不要超过5000个。

问题:假如出现了非字符串类型的 bigkey,我们怎么去处理呢?

解答:非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞)

BigKey的危害

  • 导致Redis的阻塞;
  • 造成网络阻塞;

  bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例也造成影响,其后果不堪设想。

  有个bigkey,它安分守己(只执行简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,当它过期后,会被删除,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazyexpire yes),就会存在阻塞Redis的可能性。

  • 建议】选择适合的数据类型。

例如:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)

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

正例:
hmset user:1 name tom age 19 favor football

2、命令的使用

  • 【推荐】遍历的需求可以使用hscan、sscan、zscan,来代替 hgetall、lrange、smembers、zrange、sinter等。
  • 【推荐】禁用命令;通过redis的rename机制禁掉命令:keys、flushall、flushdb等。
  • 【推荐】使用批量操作提高效率;但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
原生命令:例如mget、mset。
非原生命令:可以使用pipeline提高效率。

注意两者不同:
 a. 原生是原子操作,pipeline是非原子操作;
 b. pipeline可以打包不同的命令,原生做不到;
 c. pipeline需要客户端和服务端同时支持。

  • 【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代。

3、客户端使用

  • 【推荐】避免多个应用使用一个Redis实例。正例:不相干的业务拆分,公共数据做服务化。
  • 【推荐】使用带有连接池的数据库,可以有效控制连接,同时提高效率。

  连接池参数说明

参数名 含义 默认值 使用建议
maxTotal 资源池中最大连接数 8  
maxIdle 资源池允许最大空闲
的连接数
8  
minIdle 资源池确保最少空闲
的连接数
0  
blockWhenExhausted
当资源池用尽后,调用者是否要等待。只有当为true时,下面的maxWaitMillis才会生效
true 建议使用默认值
maxWaitMillis 当资源池连接用尽后,调用者的最大等待时间(单位为毫秒)
-1:表示永不超时 不建议使用默认值
testOnBorrow 向资源池借用连接时是否做连接有效性检测(ping),无效连接会被移除
false 业务量很大时候建议
设置为false(多一次
ping的开销)。
testOnReturn 向资源池归还连接时是否做连接有效性检测(ping),无效连接会被移除
false 业务量很大时候建议
设置为false(多一次
ping的开销)。
jmxEnabled 是否开启jmx监控,可用于监控

true 建议开启,但应用本身也有开启


(1)maxTotal
:最大连接数,早期的版本叫 maxActive; 

设置 maxTotal 的值时,需要考虑以下场景:

  • 业务希望Redis并发量;
  • 客户端执行命令时间;
  • Redis资源:例如 nodes(例如应用个数) * maxTotal 是不能超过redis的配置文件中最大连接数 maxclients。
  • 资源开销:例如虽然希望控制空闲连接(连接池此刻可马上使用的连接),但是不希望因为连接池的频繁释放创建连接造成不必靠开销,例如:

  假设一次Redis命令时间的平均耗时约为1ms,那么一个连接的QPS大约是1000;业务期望的QPS是50000,那么理论上需要的资源池大小是50000 / 1000 = 50个。但事实上这是个理论值,还要考虑到要比理论值预留一些资源,通常来讲maxTotal可以比理论值大一些。但这个值不是越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于Redis这种高QPS的服务器,一个大命令的阻塞即使设置再大资源池仍然会无济于事。
(2)maxIdle 

  maxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出余量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销。
连接池的最佳性能是maxTotal = maxIdle(maxTotal设置过大就不能一样),这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。

(3)minIdle

  minIdle(最小空闲连接数),与其说是最小空闲连接数,不如说是"至少需要保持的空闲连接数",在使用连接的过程中,如果连接数超过了minIdle,那么继续建立连接,如果超过了maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉。

 Redis连接池创建的过程

假如Redis的连接池设置参数:maxTotal=100,maxIdle=50,minIdle=10;

(1)业务服务启动之后连接池是不会去创建连接的,接着业务系统要去连接池里面拿连接操作Redis,这才会 new Jedis操作,用完之后就会把该连接放入到连接池里面;

(2)当并发量比较大变为 70 的时候,这个时候连接池里面创建了 70个连接;

(3)过来一会,Redis的并发量变得比较小了,就会慢慢的去释放连接池中多余的 70 - 50 = 20个连接;默认释放到 maxIdle=50 就可以了;

  如果系统启动完马上就会有很多的请求过来,那么我们可以给redis连接池做预热,比如快速的创建一些redis连接,执行简单命令,类似ping(),快速的将连接池里的空闲连接提升到minIdle的数量。要根据实际系统的QPS和调用redis客户端的规模整体评估每个节点所使用的连接池大小。

连接池预热实例代码:

List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());

for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
	Jedis jedis = null;
	try {
		jedis = pool.getResource();
		minIdleJedisList.add(jedis);
		jedis.ping();
	} 
	catch (Exception e) {
		logger.error(e.getMessage(), e);
	} 
	finally {
	    //注意,这里不能马上close将连接还回连接池,否则最后连接池里只会建立1个连接。。
	    //jedis.close();
	}
 }
 
 //统一将预热的连接还回连接池
 for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
	 Jedis jedis = null;
	 try {
		 jedis = minIdleJedisList.get(i);
		 //将连接归还回连接池
		 jedis.close();
	 } 
	 catch (Exception e) {
		logger.error(e.getMessage(), e);
	 } 
	 finally {
	 }
 }

Redis的过期键的三种清除策略

  • 被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key;
  • 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期主动淘汰一批已过期的key;
  • 当前已经使用内存超过 maxmemory 限定时,会触发主动清理策略

  Redis一定要根据实际情况设置 maxmemory, 因为若不设置 maxmemory 就会一直使用物理内存,物理内存使用完之后就会去使用磁盘,那么Redis的性能就会急剧的下降。  

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

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

默认策略是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只响应读操作。


原文地址:https://www.cnblogs.com/yufeng218/p/13817958.html