一、典型面试例题及思路分析
问题 1:什么是缓存雪崩?一般怎么解决?
缓存雪崩,即缓存同一时间大面积的失效(比如说大量的 key 设置了相同的过期时间,导致在缓存在同一时刻全部失效),造成瞬时 DB 请求量过大、压力骤增。
通常的解决方案:
(1) 将缓存过期时间分散开,比如说设置过期时间时再加上一个较小的随机值时间,使得每个 key 的过期时间,不会集中在同一时刻失效;
(2)采用限流算法,限制请求流量,业务有损;
(3)加锁访问,但是吞吐量会明显下降。
问题 2:什么是缓存穿透?一般怎么解决?
缓存穿透,是指访问一个不存在的 key,此时请求会穿透到 DB,如果我们查询的某个数据在缓存中一直不存在,那么每次查询都会穿透到 DB,流量大时 DB 压力很大甚至会挂掉。
通常的解决方案:
(1)采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,在查询的时候先去布隆过滤器去查询 key 是否存在,不存在的 key 就直接返回;
(2)查询不存在的 key 时 DB 返回结果为空,同时可以将该 key 在缓存中对应一个特殊的值,这样当下次再查询这个特殊的 key 时,就不再回查 DB;
(3)多级缓存。L1 为原始缓存(过期时间设置为短期),L2 为二级缓存(过期时间设置为长期)。L1 失效时可以继续访问 L2,避免同时失效。不过多了一级缓存,存储成本更高,而且多个缓存之间的数据一致性问题挑战也大。
点评:
缓存雪崩和缓存穿透都是缓存管理设计中比较常见的点,在应用中一般为了保证稳定性,会尽量让缓存数据的失效过程尽可能平滑。
缓存雪崩的原因通常有两种:一种是恶意攻击,这种恶意攻击正好碰到缓存失效,这种情景下方案 2 的限流算法效果更好,还可以在网络接入层、应用层进行拦截;二是特定业务批量插入缓存,这种情况方案 1 的随机过期时间方案更合适;
缓存穿透的原因通常有两种:一种是恶意攻击,可以用上述两种方案进行应对;另外一种是网站并发访问高,一个缓存失败可能出现多个进程同时查询 DB,通常情况下,这并不会有什么问题,但如果并发量很大,也可能造成回查 DB 压力过大。这种方式可以对缓存查询加锁,如果 KEY 不存在就加锁再回查 DB,然后将结果放入缓存,再解锁;其他进程发现有锁就等待,然后等解锁后返回数据或者回源查询。
问题 3:实现一个 LRU 缓存?
/ *
* 用双向链表实现,链表尾表示最近被访问的元素,越靠近链表头表示越早之前被访问的元素
*/
public class LRUCache {
private LinkedList<Node> cache;
private int capacity;
public LRUCache(int capacity) {
this.cache = new LinkedList<>();
this.capacity = capacity;
}
/**
* 查询一个元素,返回""表示没有找到
* @param key
* @return
*/
public String get(String key) {
Iterator<Node> iterator = cache.descendingIterator();
String result = ""; // 默认为空
while (iterator.hasNext()) {
Node node = iterator.next();
if (node.key == key) {
result = node.value;
iterator.remove();
put(key, result); // 把原来该位置的元素删除,并把新元素添加到链表尾部
break;
}
}
return result;
}
/**
* 插入一个元素
* @param key
* @param value
*/
public void put(String key, String value) {
//遍历查找是否有key的元素,如果有则删除
Iterator<Node> iterator = cache.iterator();
while (iterator.hasNext()) {
Node node = iterator.next();
if (node.key.equals(key)) {
iterator.remove();
break;
}
}
//缓存已满,删除一个最近最少访问的元素(位于链表头)
if (capacity == cache.size()) {
cache.removeFirst();
}
//添加元素至链表尾
Node addNode = new Node(key,value);
cache.add(addNode);
}
/**
* 缓存节点
*/
class Node {
String key;
String value;
public Node(String key, String value) {
this.key = key;
this.value = value;
}
}
}
点评:
LRU(Least Recently used,最近最少使用)算法是常用的缓存淘汰算法,其思想是如果数据最近一段时间没有被访问过,那么将来被访问的概率也很小。换言之,最后一次访问时间是 LRU 算法决定是否淘汰元素的唯一标准。
这里没有采用广为流传的复写 LinkedHashMap 的 removeEldestEntry () 方法的方案,一方面是因为 JDK 不同版本的 LinkedHashMap 的实现有一定差异;另一方面也需要看到面试官出这个题的目的,是考察候选人设计小型系统的基本能力,本质上是希望候选人用基本数据结构更原生地实现。同时基于这个题目可以衍生出很多问题,比如说用 LinkedList 实现有什么缺陷?比如说用 HashMap 怎么来实现?比如说如为了能够避免缓存超过容量上限,有什么方法优化等等;
二、总结
memcached 和 redis 都是属于分布式缓存,应用最广泛,相对来说也比较聚焦 。除了分布式缓存外,其他经常使用的还有本地缓存(比如说 google 的 Guava Cache),另外还有 level DB、Ehcache、Tair、EVCache 等等。除了这些各种各样的缓存外,缓存类经常考察的点还有高可用架构,以及衍生出的分布式理论等等。相关知识点可以小结如下:
三、扩展阅读及思考题
问:用ConcurrentHashMap来实现LRU?
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
class LRU<Key, Value> {
private int size;
private ConcurrentLinkedQueue<Key> linkedQueue;
private ConcurrentHashMap<Key, Value> hashMap;
public LRU(final int size) {
this.size = size;
this.linkedQueue = new ConcurrentLinkedQueue<Key>();
this.hashMap = new ConcurrentHashMap<Key, Value>(size);
}
public Value get(Key key) {
Value value = hashMap.get(key);
if (value != null) {
linkedQueue.remove(key);
linkedQueue.add(key);
}
return value;
}
public synchronized void put(final Key key, final Value value) {
if (hashMap.containsKey(key)) {
linkedQueue.remove(key);
}
while (linkedQueue.size() >= size) {
Key oldestKey = linkedQueue.poll();
if (oldestKey != null) {
hashMap.remove(oldestKey);
}
linkedQueue.add(key);
hashMap.put(key, value);
}
}
}
问:缓存与数据库双写不一致解决方案
保证最终一致性的解决方案是缓存设置过期时间。
方案一:先更新缓存,再更新数据库
不推荐。
先更新缓存若更新数据库失败,还需再更新缓存。
方案二:先更新数据库,再更新缓存
不推荐。
同时有请求A和请求B进行更新操作,请求A与B在不同线程,可能会出现:
- 请求A更新了数据库
- 请求B更新了数据库
- 请求B更新了缓存
- 请求A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
方案三:先删除缓存,再更新数据库
有点问题。
有一个请求A进行更新操作,另一个请求B进行查询操作,可能会出现:
(1)、单个数据库
请求A进行写操作,删除缓存
请求B查询发现缓存不存在
请求B去数据库查询得到旧值
请求B将旧值写入缓存
请求A将新值写入数据库
(2)、读写分离架构
请求A进行写操作,删除缓存
请求A将数据写入数据库了,
请求B查询缓存发现,缓存没有值
请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
请求B将旧值写入缓存
数据库完成主从同步,从库变为新值
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。* *
解决方案:延时双删策略
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
- 先淘汰缓存
- 再写数据库(这两步和原来一样)
- 休眠1秒
- 再次淘汰缓存
自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
对于MySQL读写分离架构,只是睡眠时间修改为在主从同步的延时时间基础上,加几百ms。
方案四:先更新数据库,再删除缓存
极端情况有问题。
有一个请求A进行更新操作,另一个请求B进行查询操作,可能会出现:
- 请求A查询数据库得到一个旧值
- 请求B将新值写入数据库
- 请求B删除缓存
- 请求A将查到的旧值写入缓存
步骤2的写数据库操作比步骤1的读数据库操作耗时更短,才有可能使得步骤3先于步骤4。可是,大家想想,数据库的读操作的速度远快于写操作的,因此步骤2耗时比步骤1更短,这一情形很难出现。
解决方案:延时双删策略
public void write(String key,Object data){
db.updateData(data);
redis.delKey(key);
Thread.sleep(1000);
redis.delKey(key);
}
- 先写数据库
- 再淘汰缓存
- 休眠1秒
- 再次淘汰缓存
方案三与方案四还存在问题
- 同步双删导致并发降低
- 比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况。
问题1解决方案
异步。
问题二解决方案
提供一个保障的重试机制。
方案一:消息队列方式
- 更新数据库数据
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
业务线代码侵入较大。
方案二:订阅binlong方式
- 更新数据库数据
- 数据库会将操作信息写入binlog日志当中
- 订阅程序提取出所需要的数据以及key
- 另起一段非业务代码,获得该信息
- 尝试删除缓存操作,发现删除失败
- 将这些信息发送至消息队列
- 重新从消息队列中获得该数据,重试操作。
订阅binlog程序在MySQL中有阿里开源的中间件叫canal。
如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试也可。
总结
根据数据实时性要求,以及系统并发量考虑。
实时性不强,则可以选择设定缓存过期时间,先删缓存再更新数据库或先更新数据库再删缓存方案都可行。
实时性较强的,又有大并发量可以考虑延迟双删策略。
至于其他如请求串行化,放入同一个队列中依次执行的,复杂没必要。