共计 3653 个字符,预计需要花费 10 分钟才能阅读完成。
灵魂拷问
- 缓存能大幅度提高零碎性能,也能大幅度提高零碎瘫痪几率
- 怎么样避免缓存零碎被穿透?
- 缓存的雪崩是不是能够完全避免?
前几篇文章咱们介绍了缓存的劣势以及数据一致性的问题,在一个面临高并发零碎中,缓存简直成了每个架构师应答高流量的首冲解决方案,然而,一个好的缓存零碎,除了和数据库一致性问题之外,还存在着其余问题,给整体的零碎设计引入了额定的复杂性。而这些复杂性问题的解决方案也间接了影响零碎的稳定性,最常见的比方缓存的命中率问题,在一个高并发零碎中,外围性能的缓存命中率个别要放弃在 90% 以上甚至更高,如果低于这个命中率,整个零碎可能就面临着随时被峰值流量击垮的可能,这个时候咱们就须要优化缓存的应用形式了。
据说你还不会缓存?
谈了千百遍的缓存数据的一致性问题
如果依照传统的缓存和 DB 的流程,一个申请到来的时候,首先会查问缓存中是否存在,如果缓存中不存在则去查问对应的数据库。如果零碎每秒的申请量为 10000,而缓存的命中率为 60%,则每秒穿透到数据库的申请数为 4000,对于关系型数据库 mysql 来说,每秒 4000 的申请量对于分了一主三从的 Mysql 数据库架构来说也曾经足够大了,再加上主从的同步提早等诸多因素,这个时候你的 mysql 曾经行走在 down 机边缘了。
缓存的最终目标,是在保障申请低提早的状况下,尽最大努力提高零碎的吞吐量
那缓存零碎可能会影响零碎解体的起因有那些呢?
缓存穿透
缓存穿透是指:当一个申请到来的时候,在缓存中没有查找到对应的数据(缓存未命中),业务零碎不得不从数据库(这里其实能够抽象的成为后端系统)中加载数据
产生缓存穿透的起因依据场景分为两种:
申请的数据在缓存和数据中都不存在
当数据在缓存和数据库都不存在的时候,如果依照个别的缓存设计,每次申请都会到数据库查问一次,而后返回不存在,这种场景下,缓存零碎简直没有起任何作用。在失常的业务零碎中,产生这种状况的概率比拟小,就算偶然产生,也不会对数据库造成基本上的压力。
最可怕的是呈现一些异常情况,比方零碎中有死循环的查问或者被黑客攻击的时候,尤其是后者,他会成心伪造大量的申请来读取不存在的数据而造成数据库的 down 机,最典型的场景为:如果零碎的用户 id 是间断递增的 int 型,黑客很容易伪造用户 id 来模仿大量的申请。
申请的数据在缓存中不存在,在数据库中存在
这种场景个别属于业务的失常需要,因为缓存零碎的容量个别是有限度的,比方咱们最罕用的 Redis 做为缓存,就受到服务器内存大小的限度,所以所有的业务数据不可能都放入缓存零碎中,依据互联网数据的二八规定,咱们能够优先把拜访最频繁的热点数据放入缓存零碎,这样就能利用缓存的劣势来抗住次要的流量起源,而残余的非热点数据,就算是有穿透数据库的可能性,也不会对数据库造成致命压力。
换句话说,每个零碎产生缓存穿透是不可避免的,而咱们须要做的是尽量避免大量的申请产生穿透,那怎么解决缓存穿透问题呢?解决缓存的穿透问题实质上是要解决怎么样拦挡申请的问题,个别状况下会有以下几种计划:
回写空值
当申请的数据在数据库中不存在的时候,缓存零碎能够把对应的 key 写入一个空值,这样当下次同样的申请就不会间接穿透数据库,而间接返回缓存中的空值了。这种计划是最简略粗犷的,然而要留神几点:
- 当有大量的空值被写入缓存零碎中,同样会占用内存,不过实践上不会太多,齐全取决于 key 的数量。而且依据缓存淘汰策略,可能会淘汰失常的数据缓存项
- 空值的过期工夫应该短一些,比方失常的数据缓存过期工夫可能为 2 小时,能够思考空值的过期工夫为 10 分钟,这样做一是为了尽快开释服务器的内存空间,二是如果业务产生相应的实在数据,能够让缓存的空值疾速生效,尽快做到缓存和数据库统一。
// 获取用户信息
public static UserInfo GetUserInfo(int userId)
{
// 从缓存读取用户信息
var userInfo = GetUserInfoFromCache(userId);
if (userInfo == null)
{
// 回写空值到缓存,并设置缓存过期工夫为 10 分钟
CacheSystem.Set(userId, null,10);
}
return userInfo;
}
布隆过滤器
布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个肯定不存在的数据会被这个 bitmap 拦挡掉,从而防止了对底层存储系统的查问压力
布隆过滤器有几个很大的劣势
- 占用内存十分小
- 对于判断一个数据不存在百分百正确
具体能够参见之前的文章或者百度脑补一下布隆过滤器:
优雅疾速的统计千万级别 uv
因为布隆过滤器基于 hash 算法,所以在工夫复杂度上是 O(1), 在应答高并发的场景下十分适合,不过应用布隆过滤器要求零碎在产生数据的时候须要在布隆过滤器同时也写入数据,而且布隆过滤器也不反对删除数据,因为多个数据可能会重用同一个地位。
缓存雪崩
缓存雪崩是指缓存中数据大批量同时过期,造成查询数据库数据量微小,引起数据库压力过大导致系统解体。
与缓存穿透景象不同,缓存穿透是指缓存中不存在数据而造成会对数据库造成大量查问,而缓存雪崩是因为缓存中存在数据,然而同时大量过期造成。然而实质上是一样的,都是对数据库造成了大量的申请。
无论是穿透还是雪崩都面临着同样的数据会有多个线程同时申请,同时查询数据库,同时回写缓存的一致性问题。举例来说,当多个线程同时申请用户 id 为 1 的用户,这个时候缓存正好生效,那这多个线程同时会查询数据库,而后同时会回写缓存,最可怕的是,这个回写的过程中,另外一个线程更新了数据库,就造成了数据不统一,这个问题在之前的文章中着重讲过,大家肯定要留神。
同样的数据会被多个线程产生多个申请是产生雪崩的一个起因,针对这种状况的解决方案是把多个线程的申请程序化,使其只有一个线程会产生对数据库的查问操作,比方最常见的锁机制(分布式锁机制),当初最常见的分布式锁是用 redis 来实现,然而 redis 实现分布式锁也有肯定的坑,能够参见之前的文章 (如果应用的是 Actor 模型的话会在无锁的模式下更优雅的实现申请程序化)
redis 做分布式锁可能不那么简略
多个缓存 key 同时生效的场景是产生雪崩的次要起因,针对这样的场景个别能够利用以下几种计划来解决
设置不同过期工夫
给缓存的每个 key 设置不同的过期工夫是最简略的避免缓存雪崩的伎俩,整体思路是给每个缓存的 key 在零碎设置的过期工夫之上加一个随机值,或者罗唆是间接随机一个值,无效的均衡 key 批量过期时间段,消掉单位之间内过期 key 数量的峰值。
public static int SetUserInfo(int userId)
{
// 读取用户信息
var userInfo = GetUserInfoFromDB(userId);
if (userInfo != null)
{
// 回写到缓存,并设置缓存过期工夫为随机工夫
var cacheExpire = new Random().Next(1, 100);
CacheSystem.Set(userId, userInfo, cacheExpire);
return cacheExpire;
}
return 0;
}
后盾独自线程更新
这种场景下,能够把缓存设置为永不过期,缓存的更新不是由业务线程来更新,而是由专门的线程去负责。当缓存的 key 有更新时候,业务方向 mq 发送一个音讯,更新缓存的线程会监听这个 mq 来实时响应以便更新缓存中对应的数据。不过这种形式要思考到缓存淘汰的场景,当一个缓存的 key 被淘汰之后,其实也能够向 mq 发送一个音讯,以达到更新线程从新回写 key 的操作。
缓存的可用性和扩展性
和数据库一样,缓存零碎的设计同样须要思考高可用和扩展性。尽管缓存零碎自身的性能曾经比拟高了,然而对于一些非凡的高并发的热点数据,还是会遇到单机的瓶颈。举个栗子:如果某个明星出轨了,这个信息数据会缓存在某个缓存服务器的节点上,大量的申请会达到这个服务器节点,当达到肯定水平的时候同样会产生 down 机的状况。相似于数据库的主从架构,缓存零碎也能够复制多分缓存正本到其余服务器上,这样就能够将利用的申请扩散到多个缓存服务器上,缓解因为热点数据呈现的单点问题。
和数据库主从一样,缓存的多个正本也面临着数据的一致性问题,同步提早问题,还有主从服务器雷同 key 的过期工夫问题。
至于缓存零碎的扩展性同样的情理,也能够利用“分片”的准则,利用一致性哈希算法将不同的申请路由到不同的缓存服务器节点,来达到程度扩大的要求,这一点和利用的程度扩大情理一样。
写在最初
通过以上能够看出,无论是应用服务器的高可用架构还是数据库的高可用架构,还是缓存的高可用其实情理都是相似的,当咱们把握了其中一种就很容易的扩大到任何场景中。如果这篇文章对你有多帮忙,请分享给身边的敌人,最初欢送大家留言写下你们在日常开发中用到的其余对于缓存高可用,可扩展性,以及避免穿透和雪崩的计划,让咱们一起提高!!
更多精彩文章
- 分布式大并发系列
- 架构设计系列
- 趣学算法和数据结构系列
- 设计模式系列