缓存有很多种,如CPU 缓存、磁盘缓存、浏览器缓存等;本文主要针对后端系统的缓存,也就是将程序或系统经常要使用的对象存在内存中,以便在使用时可以快速调用,避免加载数据或者创建重复的实例,以达到减少系统开销,提高系统效率的目的。
我觉得操作系统里面讲高速缓存(CPU Cache)的一句话特别好:
为什么要使用CPU Cache?
因为CPU 和内存访问性能的差距非常大,为了弥补两者之间的性能差异,充分利用 CPU,现代 CPU 中引入了高速缓存(CPU Cache)。
因为我们一般都会把数据存放在关系型数据库中,不管数据库的性能有多么好,受限于磁盘IO性能和远程网络的原因,一个简单的查询也要消耗毫秒级的时间,这样我们的 QPS 就会被数据库的性能所限制。
而内存的性能高于磁盘,也没有远程网络的花费。如果恰好这些数据的数据量不大,不经常变动,而且访问频繁。那么就非常适合引入缓存。
总结一下,缓存使用场景:数据不常变更且查询比较频繁是最好的场景,如果查询量不够大或者数据变动太频繁,缓存使用的意义不大,甚至可能适得其反。
常用的缓存可以分为内部缓存和外部缓存。
内部缓存是指存放在运行实例内部并使用实例内存的缓存,这种缓存可以使用代码直接访问。
外部缓存是指存放在运行实例外部的缓存,通常是通过网络获取,反序列化后进行访问。
一般来说对于不需要实例间同步的,都更加推荐内部缓存,因为内部缓存有访问方便,性能好的特点;需要实例间同步的数据可以使用外部缓存。
内部缓存有容量的限制,毕竟还是在 JVM 里的,存放的数据总量不能超出内存容量。
本地缓存的优点:
本地缓存的缺点:
对于字典型的数据,在项目启动的时候加载到 Map 中,程序就可以使用了,也很容易更新。
Map<String, String> configs = Maps.newHashMap(); public void reloadConfigs(){ configs = loadConfigFromDB(); } public String getConfigs(String key){ return configs.getOrDefault(key, "null"); }
但是JDK自带的Map做缓存存在一定的问题:
1、hashmap的容量理论上是无上限的(内存无限的情况下),如果缓存太大会严重的占用内存,甚至导致OutOfMemory异常,因此应该限制缓存的容量,容量达到上限后采取合适的淘汰策略。
有准备面试的童鞋应该知道,这就是面试中常见的实现LRU缓存。
实现方式一:可以通过继承LinkedHashMap重写removeEldestEntry()方法实现。
class LRUCache extends LinkedHashMap<Integer, Integer>{ private int capacity; public LRUCache(int capacity) { super(capacity, 0.75F, true); this.capacity = capacity; } public int get(int key) { return super.getOrDefault(key, -1); } public void put(int key, int value) { super.put(key, value); } @Override protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) { return size() > capacity; }}
实现方式二,通过HashMap加双向链表的方式实现。
public class LRUCache { class DLinkedNode { int key; int value; DLinkedNode prev; DLinkedNode next; public DLinkedNode() {} public DLinkedNode(int key, int value) {key = key; value = value;} } private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>(); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; // 使用伪头部和伪尾部节点 head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.prev = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 如果 key 存在,先通过哈希表定位,再移到头部 moveToHead(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node == null) { // 如果 key 不存在,创建一个新的节点 DLinkedNode newNode = new DLinkedNode(key, value); // 添加进哈希表 cache.put(key, newNode); // 添加至双向链表的头部 addToHead(newNode); ++size; if (size > capacity) { // 如果超出容量,删除双向链表的尾部节点 DLinkedNode tail = removeTail(); // 删除哈希表中对应的项 cache.remove(tail.key); --size; } } else { // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 node.value = value; moveToHead(node); } } private void addToHead(DLinkedNode node) { node.prev = head; node.next = head.next; head.next.prev = node; head.next = node; } private void removeNode(DLinkedNode node) { node.prev.next = node.next; node.next.prev = node.prev; } private void moveToHead(DLinkedNode node) { removeNode(node); addToHead(node); } private DLinkedNode removeTail() { DLinkedNode res = tail.prev; removeNode(res); return res; }}
2、无法设置过期时间。这一点是非常致命的,我们在大多数场景都希望缓存可以在合理的时间后自动失效。
3、并发安全。HashMap、LinkedHashMap都是非并发安全的集合类型。所以应该使用ConcurrentHashMap。
使用ConcurrentHashMap、ReentrantLock 和
ScheduledThreadPoolExecutor可以实现带过期时间线程安全的缓存设计。
4、清除数据时的回调通知。
由于代码篇幅过长,见附录1
如果需要缓存有强大的性能,或者对缓存有更多的控制,可以使用 Guava 里的 Cache 组件。
它是 Guava 中的缓存工具包,是非常简单易用且功能强大的 JVM 内缓存。
1、支持多种缓存过期策略:基于容量回收、定时回收、基于引用回收
2、自动加载:
3、显示清除缓存:个别、批量、全量清除
4、移除监听器
5、统计功能:缓存命中率,加载新值的平均时间,缓存项被回收的总数
LoadingCache<String, String> configs = CacheBuilder.newBuilder() .maximumSize(1000) // 设置最大大小 .expireAfterWrite(10, TimeUnit.MINUTES) // 设置过期时间, 10分钟 .build( new CacheLoader<String, String>() { // 加载缓存内容 public String load(String key) throws Exception { return getConfigFromDB(key); } public Map<String, String> loadAll() throws Exception { return loadConfigFromDB(); } }); //CacheLoader.loadAll// 获取某个key的值try { return configs.get(key);} catch (ExecutionException e) { throw new OtherException(e.getCause()); } // 显式的放入缓存 configs.put(key, value) // 个别清除缓存 configs.invalidate(key) // 批量清除缓存 configs.invalidateAll(keys) // 清除所有缓存项 configs.invalidateAll()
1、创建(加载)cache
两种方法 CacheLoader和Callable。
LoadingCache userCache = CacheBuilder.newBuilder().maximumSize(100). expireAfterAccess(30, TimeUnit.MINUTES) .build(new CacheLoader() { @Override public Object load(Object name) throws Exception { return getIdFromDBByName(name); } });
Cache<String, String> cache = CacheBuilder.newBuilder().maximumSize(100). expireAfterAccess(30, TimeUnit.MINUTES) .build();String resultVal = cache.get("hello", new Callable<String>() { @Override public String call() throws Exception { return "hello" + "heitao"; }});
以上两种,都实现了一种逻辑:先取缓存——娶不到再执行load 或者call方法来娶。
简单比较,CacheLoader 是按key统一加载,所有娶不到的统一执行一种load逻辑;而callable方法允许在get的时候根据指定key值执行对应的call方法。
在使用缓存前,拍拍自己的四两胸肌,问自己一个问题:有没有【默认方法】来加载或计算与键关联的值?如果有的话,你应当使用CacheLoader。如果没有,或者想要覆盖默认的加载运算。你应该在调用get时传入一个Callable实例。
2、添加、插入key
get : 要么返回已经缓存的值,要么使用CacheLoader向缓存原子地加载新值;
getUnchecked:CacheLoader 会抛异常,定义的CacheLoader没有声明任何检查型异常,则可以 getUnchecked 查找缓存;反之不能;
getAll :方法用来执行批量查询;
put : 向缓存显式插入值,Cache.asMap()也能修改值,但不具原子性;
getIfPresent :该方法只是简单的把Guava Cache当作Map的替代品,不执行load方法;
3、清除key
guava cache 自带 清除机制,但仍旧可以手动清除:
个别清除:Cache.invalidate(key)
批量清除:Cache.invalidateAll(keys)
清除所有缓存项:Cache.invalidateAll()
4 refresh和expire刷新机制
expireAfterAccess(30, TimeUnit.MINUTES ) // 30min内没有被读或写就会被回收 ;
expireAfterWrite(30, TimeUnit.MINUTES ) // 30min内没有没有更新就会被回收 :
refreshAfterAccess(30, TimeUnit.MINUTES) //上一次更新操作30min后再刷新;
注意:和redis的惰性回收机制一样
粉丝福利, 免费领取C/C++ 开发学习资料包、技术视频/代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发,音视频开发,Qt开发,游戏开发,Linux内核等进阶学习资料和最佳学习路线)↓↓↓↓有需要的朋友可以进企鹅裙927239107领取哦~↓↓
30min内没有被读或写就会被回收” 不等于 “30min内会被回收” ,因为真正的过期/刷新操作是在key被读或写时发生的。
只有发生“取值”操作,才会执行load,然而为了防止“缓存穿透”,在多线程的环境下,任何时刻只允许一个线程操作执行load操作 。
但在执行load操作这个步骤,expire 与 refresh 的线程机制不同:
expire 在load 阶段——同步机制:当前线程load未完成,其他线程呈阻塞状态,待当前线程load完成,其他线程均需进行”获得锁--获得值--释放锁“的过程。这种方法会让性能有一定的损耗。
refresh 在load阶段——异步机制 :当前线程load未完成,其他线程仍可以取原来的值,等当前线程load完成后,下次某线程再取值时,会判断系统时间间隔是否时超过设定refresh时间,来决定是否设定新值。所以,refresh机制的特点是,设定30分钟刷新,30min后并不一定就是立马就能保证取到新值
expire与refresh 都能控制key得回收,究竟如何选择?
答案是,两个一起来!
只要refresh得时间小于expire时间,就能保证多线程在load取值时不阻塞,也能保证refresh时间到期后,取旧值向新值得平滑过渡,当然,仍旧不能解决取到旧值得问题。
5 监听
在guava cache中移除key可以设置相应得监听操作,以便key被移除时做一些额外操作。缓存项被移除时,RemovalListener会获取移除通知[RemovalNotification],其中包含移除原因[RemovalCause]、键和值。监听有同步监听和异步监听两种 :
同步监听:
默认情况下,监听器方法是被同步调用的(在移除缓存的那个线程中执行),执行清理key的操作与执行监听是单线程模式,当然监听器中抛出的任何异常都不会影响其正常使用,顶多把异常写到日记了。
@Test public void testCacheRemovedNotification(){ CacheLoader<String, String> loader = CacheLoader.from(String::toUpperCase); RemovalListener<String, String> listener = notification -> { if (notification.wasEvicted()){ RemovalCause cause = notification.getCause(); System.out.println("remove case is : " + cause.toString()); System.out.println("key: "+notification.getKey()+" value:" + notification.getValue()); } }; LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumSize(4) .removalListener(listener).build(loader); cache.getUnchecked("maomao"); cache.getUnchecked("wangwang"); cache.getUnchecked("guava"); cache.getUnchecked("cache"); cache.getUnchecked("hello"); }
异步监听:
假如在同步监听模式下,监听方法中的逻辑特别复杂,执行效率慢,那此时如果有大量的key进行清理,会使整个缓存性能变得很低下,所以此时适合用异步监听,移除key与监听key的移除分属2个线程。
@Test public void testCacheRemovedNotification(){ CacheLoader<String, String> loader = CacheLoader.from(String::toUpperCase); RemovalListener<String, String> listener = notification -> { if (notification.wasEvicted()){ RemovalCause cause = notification.getCause(); System.out.println("remove case is : " + cause.toString()); System.out.println("key: "+notification.getKey()+" value:" + notification.getValue()); } }; LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumSize(4) .removalListener(RemovalListeners.asynchronous(listener, Executors.newSingleThreadExecutor())).build(loader); cache.getUnchecked("maomao"); cache.getUnchecked("wangwang"); cache.getUnchecked("guava"); cache.getUnchecked("cache"); cache.getUnchecked("hello"); }
6 统计
guava cache还有一些其他特性,比如weight 按权重回收资源,统计等,这里列出统计。CacheBuilder.recordStats()用来开启Guava Cache的统计功能。统计打开后Cache.stats()方法返回如下统计信息:
Redis / Memcached 都是使用内存作为存储,所以性能上要比数据库要好很多,再加上Redis 还支持很多种数据结构,使用起来也挺方便,所以作为很多人的首选。
Redis 确实不错,不过即便是使用内存,也还是需要通过网络来访问,所以网络的性能决定了 Reids 的性能;
在一些性能测试中,在万兆网卡的情况下,对于 Key 和 Value 都是长度为 20 Byte 的字符串的 get 和 set 是每秒10w左右的,如果 Key 或者 Value 的长度更大或者使用数据结构,这个会更慢一些;
作为一般的系统来使用已经绰绰有余了,从目前来看,Redis 确实很适合来做系统中的缓存。
如果考虑多实例或者分布式,可以考虑下面的方式:
指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库。
解决方案:
指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库。
在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。
解决方案:
缓存一致性要求数据更新的同时缓存数据也能够实时更新。
解决方案:
要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。
指的是为了满足业务要求添加了大量缓存节点,但是性能不但没有好转反而下降了的现象。
产生原因:缓存系统通常采用 hash 函数将 key 映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响。
解决方案:
private static ScheduledExecutorService swapExpiredPool = new ScheduledThreadPoolExecutor(10); private ReentrantLock lock = new ReentrantLock(); private ConcurrentHashMap<String, Node> cache = new ConcurrentHashMap<>(1024); /** * 让过期时间最小的数据排在队列前,在清除过期数据时 * ,只需查看缓存最近的过期数据,而不用扫描全部缓存 * * @see Node#compareTo(Node) * @see SwapExpiredNodeWork#run() */ private PriorityQueue<Node> expireQueue = new PriorityQueue<>(1024); public LocalCache() { //使用默认的线程池,每5秒清除一次过期数据 //线程池和调用频率 最好是交给调用者去设置。 swapExpiredPool.scheduleWithFixedDelay( new SwapExpiredNodeWork(), 5, 5, TimeUnit.SECONDS); } public Object set(String key, Object value, long ttl) { Assert.isTrue(StringUtils.hasLength(key), "key can't be empty"); Assert.isTrue(ttl > 0, "ttl must greater than 0"); long expireTime = System.currentTimeMillis() + ttl; Node newNode = new Node(key, value, expireTime); lock.lock(); try { Node old = cache.put(key, newNode); expireQueue.add(newNode); //如果该key存在数据,还要从过期时间队列删除 if (old != null) { expireQueue.remove(old); return old.value; } return null; } finally { lock.unlock(); } } /** * 拿到的数据可能是已经过期的数据,可以再次判断一下 * if(n.expireTime<System.currentTimeMillis()){ * return null; * } * 也可以直接返回整个节点Node ,交给调用者去取舍 * <p> * <p> * 无法判断不存在该key,还是该key存的是一个null值,如果需要区分这两种情况 * 可以定义一个全局标识,标识key不存在 * public static final NOT_EXIST = new Object(); * 返回值时 * return n==null?NOT_EXIST:n.value; */ public Object get(String key) { Node n = cache.get(key); return n == null ? null : n.value; } /** * 删除KEY,并返回该key对应的数据 */ public Object remove(String key) { lock.lock(); try { Node n = cache.remove(key); if (n == null) { return null; } else { expireQueue.remove(n); return n.value; } } finally { lock.unlock(); } } /** * 删除已经过期的数据 */ private class SwapExpiredNodeWork implements Runnable { @Override public void run() { long now = System.currentTimeMillis(); while (true) { lock.lock(); try { Node node = expireQueue.peek(); //没有数据了,或者数据都是没有过期的了 if (node == null || node.expireTime > now) { return; } cache.remove(node.key); expireQueue.poll(); } finally { lock.unlock(); } } } } private static class Node implements Comparable<Node> { private String key; private Object value; private long expireTime; public Node(String key, Object value, long expireTime) { this.value = value; this.key = key; this.expireTime = expireTime; } /** * @see SwapExpiredNodeWork */ @Override public int compareTo(Node o) { long r = this.expireTime - o.expireTime; if (r > 0) { return 1; } if (r < 0) { return -1; } return 0; } }