23,缓存使用

1,注意缓存雪崩问题
短时间内大量缓存失效的情况。这种情况一旦发生,可能就会在瞬间有大量的数据需要 回源到数据库查询,对数据库造成极大的压力,极限情况下甚至导致后端数据库直接崩溃。这就是我们常说的缓存失效,也叫作缓存雪崩 从广义上说,产生缓存雪崩的原因有两种:
  1. 第一种是,缓存系统本身不可用,导致大量请求直接回源到数据库;
  2. 第二种是,应用设计层面大量的Key在同一时间过期,导致大量的数据回源。

第一种原因,主要涉及缓存系统本身高可用的配置,不属于缓存设计层面的问题,所以主要确保大量Key不在同一时间被动过期.
解决方案:

  1. 在初始化缓存的时候,设置缓存的过期时间是30秒+10秒以内的随机延迟(扰动值)。这样,这些Key不会集中在30秒这个时刻过期,而是会分散在30~40秒之间过期

  2. 让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程30秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力.

    不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。

2,注意缓存击穿问题
在某些Key属于极端热点数据,且并发量很大的情况下,如果这个Key过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。这种情况,就是我们常说的缓存击穿或缓存并发问题。

解决方案: 加锁

String data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
	RLock locker = redissonClient.getLock("locker");
	//获取分布式锁
	if (locker.tryLock()) {
	try {
		data = stringRedisTemplate.opsForValue().get("hotsopt");
		//双重检查,因为可能已经有一个B线程过了第一次判断,在等锁,然后A线程已经把数据写入了Redis中
		if (StringUtils.isEmpty(data)) {
			//回源到数据库查询
			data = getExpensiveData();
			stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
		}
	} finally {
		//别忘记释放,另外注意写法,获取锁后整段代码try+finally,确保unlock万无一失
		locker.unlock();
	}
	}
} 
return data;
}
  1. 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;

  2. 方案二,不使用锁进行限制,而是使用类似Semaphore的工具限制并发数,比如限制为10,这样既限制了回源并发数不至于太大,又能使得一定量的线程可以同时回源。

3, 注意缓存穿透问题
**缓存穿透是指,缓存没有起到压力缓冲的作用; 而缓存击穿是指,缓存失效时瞬时的并发打到数据库。**

数据库中只保存有ID介于0(不含)和10000(包含)之间的用户,如果从数据库查询ID不在这个区间的用户,会得到空字符串,所以缓存中缓存的也是空字符串。如果使用ID=0去压接口的话,从缓存中查出了空字符串,认为是缓存中没有数据回源查询,其实相当
于每次都回源:如果这种漏洞被恶意利用的话,就会对数据库造成很大的性能压力。这就是缓存穿透

)
public String wrong(@RequestParam("id") int id) {
	String key = "user" + id;
	String data = stringRedisTemplate.opsForValue().get(key);
	//无法区分是无效用户还是缓存失效
	if (StringUtils.isEmpty(data)) {
		data = getCityFromDb(id);
		stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
	} 
	return data;
}

解决缓存穿透有以下两种方案。
方案一,对于不存在的数据,同样设置一个特殊的Value到缓存中,比如当数据库中查出的用户信息为空的时候,设置NODATA这样具有特殊含义的字符串到缓存中。这样下次请求缓存的时候还是可以命中缓存,即直接从缓存返回结果,不查询数据库:

public String right(@RequestParam("id") int id) {
	String key = "user" + id;
	String data = stringRedisTemplate.opsForValue().get(key);
	if (StringUtils.isEmpty(data)) {
	data = getCityFromDb(id);
		//校验从数据库返回的数据是否有效
	if (!StringUtils.isEmpty(data)) {
		stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
	} 
	else {
		//如果无效,直接在缓存中设置一个NODATA,这样下次查询时即使是无效用户还是可以命中缓存
		stringRedisTemplate.opsForValue().set(key, "NODATA", 30, TimeUnit.SECONDS);
		}
	} 
	return data;
}

但这种方式可能会把大量无效的数据加入缓存中,如果担心大量无效数据占满缓存的话还可以考虑方案二,即使用布隆过滤器做前置过滤。
布隆过滤器是一种概率型数据库结构,由一个很长的二进制向量和一系列随机映射函数组成。它的原理是,当一个元素被加入集合时,通过k个散列函数将这个元素映射成一个m位bit数组中的k个点,并置为1。检索时,我们只要看看这些点是不是都是1就(大概)知道集合中有没有它了。如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。
你可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:

  1. 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
  2. 对于极小概率的误判请求,才会最终让非法Key的请求走到缓存或数据库

对于方案二,我们需要同步所有可能存在的值并加入布隆过滤器,这是比较麻烦的地方。如果业务规则明确的话,你也可以考虑直接根据业务
规则判断值是否存在。
其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据
库。

4, 注意缓存数据同步策略
前面提到的3个案例,其实都属于缓存数据过期后的被动删除。在实际情况下,修改了原始数据后,考虑到缓存数据更新的及时性,我们可能 会采用主动更新缓存的策略。这些策略可能是:
  1. 先更新缓存,再更新数据库;
  2. 先更新数据库,再更新缓存;
  3. 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存;
  4. 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存。

那么,我们应该选择哪种更新策略呢?我来和你逐一分析下这4种策略:

  1. “先更新缓存再更新数据库”策略不可行。数据库设计复杂,压力集中,数据库因为超时等原因更新操作失败的可能性较大,此外还会涉及事务,很可能因为数据库更新失败,导致缓存和数据库的数据不一致。
  2. “先更新数据库再更新缓存”策略不可行。一是,如果线程A和B先后完成数据库更新,但更新缓存时却是B和A的顺序,那很可能会把旧数据
    更新到缓存中引起数据不一致;二是,我们不确定缓存中的数据是否会被访问,不一定要把所有数据都更新到缓存中去。
  3. “先删除缓存再更新数据库,访问的时候按需加载数据到缓存”策略也不可行。在并发的情况下,很可能删除缓存后还没来得及更新数据库,
    就有另一个线程先读取了旧值到缓存中,如果并发量很大的话这个概率也会很大。
  4. “先更新数据库再删除缓存,访问的时候按需加载数据到缓存”策略是最好的。虽然在极端情况下,这种策略也可能出现数据不一致的问题,
    但概率非常低,基本可以忽略。

举一个“极端情况”的例子,比如更新数据的时间节点恰好是缓存失效的瞬间,这时A先读取到了旧值,随后在B操作数据库完成更新并且删除了缓存之后,A再把旧值加入缓存。
需要注意的是,更新数据库后删除缓存的操作可能失败,如果失败则考虑把任务加入延迟队列进行延迟重试,确保数据可以删除,缓存可以及时更新。因为删除操作是幂等的,所以即使重复删问题也不是太大,这又是删除比更新好的一个原因。

因此,针对缓存更新更推荐的方式是,缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存
中的数据即可。

  1. 在聊到缓存并发问题时,我们说到热点Key回源会对数据库产生的压力问题,如果Key特别热的话,可能缓存系统也无法承受,毕竟所有
    的访问都集中打到了一台缓存服务器。如果我们使用Redis来做缓存,那可以把一个热点Key的缓存查询压力,分散到多个Redis节点上
    吗?
  2. 大Key也是数据缓存容易出现的一个问题。如果一个Key的Value特别大,那么可能会对Redis产生巨大的性能影响,因为Redis是单线程
    模型,对大Key进行查询或删除等操作,可能会引起Redis阻塞甚至是高可用切换。你知道怎么查询Redis中的大Key,以及如何在设计上
    实现大Key的拆分吗?

分型一个场景:假如在一个非常热点的数据,数据更新不是很频繁,但是查询非常的频繁,要保证基本保证100%的缓存命中率,该怎么处理?
我们的做法是,空间换效率,同一个key保留2份,1个不带后缀,1个带后缀,不带的后缀的有ttl,带后缀的没有,先查询不带后缀的,查询不到,做两件事情:1、后台程序查询DB更新缓存;2查询带后缀返回给调用方。这样可以尽可能的避免缓存击穿而引起的数据库挂了。

第二个问题:
1:单个key存储的value很大key分为2种类型:
第一:该key需要每次都整存整取
可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响;

第二:该对象每次只需要存取部分数据
可以像第一种做法一样,分拆成几个key-value, 也可以将这个存储在一个hash中,每个field代表一个具体的属性,使用hget,hmget来获取部分的value,使用hset,hmset来更新部分属性。

2、一个集群存储了上亿的key
如果key的个数过多会带来更多的内存空间占用,
第一:key本身的占用(每个key 都会有一个Category前缀)
第二:集群模式中,服务端需要建立一些slot2key的映射关系,这其中的指针占用在key多的情况下也是浪费巨大空间
这两个方面在key个数上亿的时候消耗内存十分明显(Redis 3.2及以下版本均存在这个问题,4.0有优化);
所以减少key的个数可以减少内存消耗,可以参考的方案是转Hash结构存储,即原先是直接使用Redis String 的结构存储,现在将
多个key存储在一个Hash结构中,具体场景参考如下:
一: key 本身就有很强的相关性,比如多个key 代表一个对象,每个key是对象的一个属性,这种可直接按照特定对象的特征来设
置一个新Key——Hash结构, 原先的key则作为这个新Hash 的field。
二: key 本身没有相关性,预估一下总量,预分一个固定的桶数量
比如现在预估key 的总数为 2亿,按照一个hash存储 100个field来算,需要 2亿 / 100 = 200W 个桶 (200W 个key占用的空间很
少,2亿可能有将近 20G )
现在按照200W 固定桶分就是先计算出桶的序号 hash(123456789) % 200W , 这里最好保证这个 hash算法的值是个正数,否则
需要调整下模除的规则;
这样算出三个key 的桶分别是 1 , 2, 2。 所以存储的时候调用API hset(key, field, value),读取的时候使用 hget (key,
field)
注意两个地方:1,hash 取模对负数的处理; 2,预分桶的时候, 一个hash 中存储的值最好不要超过 512 ,100 左右较为合适

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值