共计 3919 个字符,预计需要花费 10 分钟才能阅读完成。
一、前言
在咱们日常的开发中,无不都是应用数据库来进行数据的存储,因为个别的零碎工作中通常不会存在高并发的状况,所以这样看起来并没有什么问题,可是一旦波及大数据量的需要,比方一些商品抢购的情景,或者是主页访问量霎时较大的时候,繁多应用数据库来保留数据的零碎会因为面向磁盘,磁盘读 / 写速度比较慢的问题而存在重大的性能弊病,一瞬间成千上万的申请到来,须要零碎在极短的工夫内实现成千上万次的读 / 写操作,这个时候往往不是数据库可能接受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的重大生产问题。
为了克服上述的问题,我的项目通常会引入 NoSQL 技术,这是一种基于内存的数据库,并且提供肯定的长久化性能。
redis 技术就是 NoSQL 技术中的一种,然而引入 redis 又有可能呈现缓存穿透,缓存击穿,缓存雪崩等问题。本文就对这三种问题进行较深刻分析。
二、初意识
缓存穿透:key 对应的数据在数据源并不存在,每次针对此 key 的申请从缓存获取不到,申请都会到数据源,从而可能压垮数据源。比方用一个不存在的用户 id 获取用户信息,不管缓存还是数据库都没有,若黑客利用此破绽进行攻打可能压垮数据库。
缓存击穿:key 对应的数据存在,但在 redis 中过期,此时若有大量并发申请过去,这些申请发现缓存过期个别都会从后端 DB 加载数据并回设到缓存,这个时候大并发的申请可能会霎时把后端 DB 压垮。
缓存雪崩:当缓存服务器重启或者大量缓存集中在某一个时间段生效,这样在生效的时候,也会给后端系统 (比方 DB) 带来很大压力。
三、缓存穿透解决方案
一个肯定不存在缓存及查问不到的数据,因为缓存是不命中时被动写的,并且出于容错思考,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次申请都要到存储层去查问,失去了缓存的意义。
有很多种办法能够无效地解决缓存穿透问题,最常见的则是采纳布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个肯定不存在的数据会被 这个 bitmap 拦挡掉,从而防止了对底层存储系统的查问压力。另外也有一个更为简略粗犷的办法(咱们采纳的就是这种),如果一个查问返回的数据为空(不论是数据不存在,还是系统故障),咱们依然把这个空后果进行缓存,但它的过期工夫会很短,最长不超过五分钟。
粗犷形式伪代码:
// 伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String cacheValue = CacheHelper.Get(cacheKey);
if (cacheValue != null) {return cacheValue;}
cacheValue = CacheHelper.Get(cacheKey);
if (cacheValue != null) {return cacheValue;} else {
// 数据库查问不到,为空
cacheValue = GetProductListFromDB();
if (cacheValue == null) {
// 如果发现为空,设置个默认值,也缓存起来
cacheValue = string.Empty;
}
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
return cacheValue;
}
}
四、缓存击穿解决方案
key 可能会在某些工夫点被超高并发地拜访,是一种十分“热点”的数据。这个时候,须要思考一个问题:缓存被“击穿”的问题。
应用互斥锁(mutex key)
业界比拟罕用的做法,是应用 mutex。简略地来说,就是在缓存生效的时候(判断拿进去的值为空),不是立刻去 load db,而是先应用缓存工具的某些带胜利操作返回值的操作(比方 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回胜利时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的办法。
SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,能够利用它来实现锁的成果。
public String get(key) {
String value = redis.get(key);
if (value == null) { // 代表缓存值过期
// 设置 3min 的超时,避免 del 操作失败的时候,下次缓存过期始终不能 load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { // 代表设置胜利
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { // 这个时候代表同时候的其余线程曾经 load db 并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); // 重试
}
} else {return value;}
}
memcache 代码:
if (memcache.get(key) == null) {
// 3 min timeout to avoid mutex holder crash
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {sleep(50);
retry();}
}
其它计划:待各位补充。
五、缓存雪崩解决方案
与缓存击穿的区别在于这里针对很多 key 缓存,前者则是某一个 key。
缓存失常从 Redis 中获取,示意图如下:
缓存生效霎时示意图如下:
缓存生效时的雪崩效应对底层零碎的冲击十分可怕!大多数零碎设计者思考用加锁或者队列的形式保障来保障不会有大量的线程对数据库一次性进行读写,从而防止生效时大量的并发申请落到底层存储系统上。还有一个简略计划就时讲缓存生效工夫扩散开,比方咱们能够在原有的生效工夫根底上减少一个随机值,比方 1 - 5 分钟随机,这样每一个缓存的过期工夫的反复率就会升高,就很难引发个体生效的事件。
加锁排队,伪代码如下:
// 伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String lockKey = cacheKey;
String cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {return cacheValue;} else {synchronized(lockKey) {cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {return cacheValue;} else {
// 这里个别是 sql 查问数据
cacheValue = GetProductListFromDB();
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
}
}
return cacheValue;
}
}
加锁排队只是为了加重数据库的压力,并没有进步零碎吞吐量。假如在高并发下,缓存重建期间 key 是锁着的,这是过去 1000 个申请 999 个都在阻塞的。同样会导致用户期待超时,这是个治标不治本的办法!
留神:加锁排队的解决形式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因而,在真正的高并发场景下很少应用!
随机值伪代码:
// 伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
// 缓存标记
String cacheSign = cacheKey + "_sign";
String sign = CacheHelper.Get(cacheSign);
// 获取缓存值
String cacheValue = CacheHelper.Get(cacheKey);
if (sign != null) {return cacheValue; // 未过期,间接返回} else {CacheHelper.Add(cacheSign, "1", cacheTime);
ThreadPool.QueueUserWorkItem((arg) -> {
// 这里个别是 sql 查问数据
cacheValue = GetProductListFromDB();
// 日期设缓存工夫的 2 倍,用于脏读
CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
解释阐明:
缓存标记:记录缓存数据是否过期,如果过期会触发告诉另外的线程在后盾去更新理论 key 的缓存;
缓存数据:它的过期工夫比缓存标记的工夫缩短 1 倍,例:标记缓存工夫 30 分钟,数据缓存设置为 60 分钟。这样,当缓存标记 key 过期后,理论缓存还能把旧数据返回给调用端,直到另外的线程在后盾更新实现后,才会返回新缓存。
对于缓存解体的解决办法,这里提出了三种计划:应用锁或队列、设置过期标记更新缓存、为 key 设置不同的缓存生效工夫,还有一种被称为“二级缓存”的解决办法。
六、小结
针对业务零碎,永远都是具体情况具体分析,没有最好,只有最合适。
于缓存其它问题,缓存满了和数据失落等问题,大伙可自行学习。最初也提一下三个词 LRU、RDB、AOF,通常咱们采纳 LRU 策略解决溢出,Redis 的 RDB 和 AOF 长久化策略来保障肯定状况下的数据安全。