1. 缓存雪崩、缓存穿透和缓存穿透
1.1 缓存雪崩
1.1.1 什么是缓存雪崩?
缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
1.1.2 有哪些解决办法?
- 不同的过期时间:在缓存的时候给过期时间加上一个随机值,这样就会大幅度的减少缓存在同一时间过期。
- 加锁排队(限流降级):在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待。
- 数据预热:数据预热意味着就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生的大并发访问前手动触发加载缓存不同的键。
- 做二级缓存,或者双缓存策略:Cache1 为原始缓存,Cache2 为拷贝缓存,Cache1 失效时,可以访问 Cache2,Cache1 缓存失效时间设置为短期,Cache2 设置为长期。
Redis 高可用:Redis 有可能挂掉,多增加几台redis实例,(一主多从或者多主多从),这样一台挂掉之后其他的还可以继续工作,其实就是建造的。
1.2 缓存穿透
1.2.1 什么是缓存穿透?
缓存穿透说简单点就是请求的 key 根本不存在,导致请求直接到了数据库上,根本没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key(如
id = -1
) 发起大量请求,导致大量请求落到数据库。一般 3000 个并发请求就能打死大部分数据库了。1.2.2 有哪些解决办法?
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
缓存无效 key:如果缓存和数据库都查不到某个 key 的数据,就直接将其写一个到 redis 中去并设置过期时间。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点,比如 1 分钟。
- 布隆过滤器:利用布隆过滤器这个高效的数据结构和算法判断你这个 Key 是否在数据库中存在,不存在的话就直接 return 就好了,存在就去查 DB 刷新 KV 然后再 return。布隆过滤器是将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。通过它我们可以非常方便地判断一个给定数据是否存在与海量数据中。
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,我会先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。
1.3 缓存击穿
1.3.1 什么是缓存击穿?
缓存击穿和缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效打崩了 DB,而缓存击穿是指一个 Key 非常热点,在不停地扛着大并发,大并发几种对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库。这种情况和缓存雪崩的不同之处,在于雪崩是大量缓存赶巧儿一起过期,击穿只是单个超热键失效。
归纳起来:造成缓存击穿的原因有两个。
(1)一个“冷门”键,突然被大量用户请求访问。
(2)一个“热门”键,在缓存中时间恰好过期,这时有大量用户来进行访问。
1.3.2 有哪些解决办法?
对于缓存击穿的问题,我们常用的解决方案是加锁。密钥过期的时候,当需要查询数据库的时候加上一把锁,这时只能让第一个请求进行查询数据库,然后把从数据库中查询到的值存储到缓存中,对于其余的相同的键,可以直接从缓存中获取即可。
如果我们是在单机环境下:直接使用常用的锁即可(如:Lock,Synchronized等),在分布式环境下我们可以使用分布式锁,如:基于数据库,基于 Redis 或者 zookeeper 的分布式锁。
使用互斥锁:业界比较常用的做法,是使用 mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的方法。
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;
}
提前使用互斥锁(mutex key):在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。
- 永远不过期:这里的“永远不过期”包含两层意思:
- 从 Redis 上看,确实没有设置过期时间,这就保证了,不会出现热点 key 过期问题,也就是“物理”不过期。
- 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期.
- 从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
- 资源保护:采用 netflix 的 hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。
针对业务系统,永远都是具体情况具体分析,上面这四种解决方案,没有最佳只有最合适:
解决方案 | 优点 | 缺点 |
---|---|---|
简单分布式锁 | 1. 思路简单。2. 保证一致性。 | 1. 代码复杂度增大。2. 存在死锁的风险。3. 存在线程池阻塞的风险。 |
加另外一个过期时间 | 1. 保证一致性 | 同上 |
永不过期 | 1. 异步构建缓存,不会阻塞线程池 | 1. 不保证一致性。2. 代码复杂度增大。3. 占用一定的内存空间(每个value都要维护一个timekey)。 |
资源隔离组件 | 1. hystrix技术成熟,有效保证后端。2. hystrix监控功能强大。 | 1. 部分访问存在降级策略。 |
2. 如何解决 Redis 的并发竞争问题?
这个也是线上非常常见的一个问题,就是多客户端同时并发写一个 key,可能本来应该先到的数据后到了,导致数据版本错了;或者是多客户端同时获取一个 key,修改值之后再写回去,只要顺序错了,数据就错了。Redis 自己就有天然解决这个问题的 CAS 类的乐观锁方案。
某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。
你要写入缓存的数据,都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。
每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。
3. 如何保证缓存与数据库的一致性?
从设计思路来说,有Cache Aside和Read/Write Through两种模式,前者是把缓存责任交给应用层,后者是将缓存的责任,放置到服务提供方。两种模式各有优缺点,从透明性考虑,服务方比较合适;如果从性能极致来说,业务方会更有优势,毕竟可以减去服务RPC的损耗。
3.1 设置缓存过期时间
每次放入缓存的时候,设置一个过期时间,比如5
分钟,以后的操作只修改数据库,不操作缓存,等待缓存超时后从数据库重新读取。如果对于一致性要求不是很高的情况,可以采用这种方案。但是如果数据更新的特别频繁,不一致性的问题就很大了。
所以针对缓存一致性要求不是很高的场景,那么只设置超时时间就可以了。
3.2 先删除缓存,再更新数据库
直接删除缓存,再更新数据库的问题:如果线程1先删除缓存,然后正在更新数据库的时候线程2读取缓存,缓存不存在,而去数据库中读取到的是旧值,然后把旧值写入缓存,这时缓存不一致发生。
解决方案:延时双删
延时双删的方案的思路是,为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再 sleep 一段时间(这个时间要对业务读写缓存的时间做出评估,sleep 时间大于读写缓存的时间即可),然后再次删除缓存。
延时双删的流程如下:
- 线程1删除缓存,然后去更新数据库。
- 线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存。
- 线程1根据估算的时间 sleep,由于 sleep 的时间大于线程2
读数据+写缓存
的时间,所以缓存被再次删除。 - 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值。
3.3 先更新数据库,再删除缓存
直接更新数据库,再删除缓存的问题:如果更新数据库成功之后,删除缓存失败或者还没有来得及删除,那么其他线程从缓存中读取到的就是旧值,还是会发生不一致。
解决方案1:消息队列
先更新数据库,成功后往消息队列发消息,消费到消息后再删除缓存,借助消息队列的重试机制来实现,达到最终一致性的效果。
这种解决方案会引入更多的问题:
- 引入消息中间件之后,问题更复杂了,怎么保证消息不丢失更麻烦。
- 就算更新数据库和删除缓存都没有发生问题,消息的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的。
解决方案2:进阶版消息队列
为了解决缓存一致性的问题单独引入一个消息队列,太复杂了。其实,一般大公司本身都会有监听 binlog 消息的消息队列存在,主要是为了做一些核对的工作。这样,我们可以借助监听 binlog 的消息队列来做删除缓存的操作。这样做的好处是,不用你自己引入,侵入到你的业务代码中,中间件帮你做了解耦。同时,中间件的这个东西本身就保证了高可用。
当然,这样消息延迟的问题依然存在,但是相比单纯引入消息队列的做法更好一点。而且,如果并发不是特别高的话,这种做法的实时性和一致性都还算可以接受的。
为什么都是删除缓存,而不是更新缓存?我们以先更新数据库,再删除缓存来举例。如果是更新的话,那就是先更新数据库,再更新缓存。 举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能在1小时内只被读取了1次,那么这1000次的更新有必要吗?反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。
4. 如何解决 Redis 的热点 key 问题?
热点 key 问题就是瞬间有几十万上百万,甚至更大的请求去访问 Redis 上某个固定的 key,从而压垮缓存服务的情情况。
谈谈redis的热key问题如何解决
5. Redis 可以做消息队列吗?
嗯…可以是可以,但我觉得用它来做消息队列不合适。Redis本身没有支持AMQP规范,消息队列该有的能力缺胳膊少腿,消息可靠性不强。因为总有人拿Redis做消息队列。Redis的作者都看不下去了,赶紧出了个Disque来专事专做,虽然没大红大紫,但至少明确告诉了我们,Redis,别拿来做消息队列!
6. Redis 在分布式锁中的应用
锁是计算机领域一个非常常见的概念,分布式锁也依赖存储组件,针对请求量的不同,可以选择Etcd、MySQL、Redis等。前两者可靠性更强,Redis性能更高。
7. Redis 在限流场景中的应用
在微服务架构下,限频器也需要分布式化。无论是哪种算法,都可以结合Redis来实现。这里我比较熟悉的是基于Redis的分布式令牌桶。很显然,Redis负责管理令牌,微服务需要进行函数操作,就向Redis申请令牌,如果Redis当前还有令牌,就发放给它。拿到令牌,才能进行下一步操作。另一方面,令牌不光要消耗,还需要补充,出于性能考虑,可以使用懒生成的方式:使用令牌时,顺便生成令牌。这样子还有个好处:令牌的获取,和令牌的生成,都可以在一个Lua脚本中,保证了原子性。