缓存设计问题&guava cache源码

64 阅读10分钟

秋招面试的问题,给作为菜鸟应届生的我难住了,回答的面试官并不满意。题目如下:

背景:高并发,并且用户的消息很少变动。

ConcurrentMap<String, Object> cache = new ConcurrentHashMap<>();  
  
public Object getUserConfig(String key){  
    Object value = cache.get(key);  
  
    if (value == null){  
        value = getInDataBase(key); // 这是一个高耗时的数据库读取操作  
        cache.put(key, value);  
    }  
  
    return value;  
}
  1. 该代码会导致哪些问题?如何优化?
  2. ConcurrentHashMap 在这里真的合适吗?如果是你你会选择什么组件,更加适合该场景?

经过一晚上的查询,和同门讨论,我觉得有以下几点,欢迎大家补充和指正

问题

  1. concurrentHashMap 没有淘汰机制,最坏的情况会将所有数据加载到内存,可能造成缓存溢出;
  2. 数据库操作没有同步机制,大量的请求会造成缓存击穿;
  3. 用户消息很少变动,那么写入写出缓存都是幂等的,是否一定要用到大量针对 cache的锁同步机制?

这里我们参考 guava cache的本地实现。

guava 本地缓存实现

可以使用 guava 的本地缓存方案,其既是线程安全的缓存,架构设计类似于ConcurrentHashMap,在简单场景中可以通过HashMap实现简单数据缓存;也提供了缓存淘汰策略、缓存过期策略等。 guava cache 可以通过CacheBuilder进行创建:

Cache<Integer, String> cache = CacheBuilder.newBuilder() 
    //设置并发级别设备核心数,并发级别是指可以同时写缓存的线程数 
    .concurrencyLevel(Runtime.getRuntime().availableProcessors()) 
    //设置缓存容器的初始容量为10 
    .initialCapacity(10) 
    //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项 
    .maximumSize(100) 
    //是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除 
    .recordStats() 
    //设置写缓存后n秒钟过期 
    .expireAfterWrite(60, TimeUnit.SECONDS) 
    //设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite 
    //.expireAfterAccess(17, TimeUnit.SECONDS) 
    //只阻塞当前数据加载线程,其他线程返回旧值 
    //.refreshAfterWrite(13, TimeUnit.SECONDS) 
    //设置缓存的移除通知 
    .removalListener(notification -> { 
        System.out.println(notification.getKey() + " " + notification.getValue() + " 被移除,原因:" + notification.getCause()); 
    }) 
    //build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存 
    .build();
    
public String getUserConfig(int key){  
    try {  
        String data = cache.get(key, new Callable<String>() {  
            public String call() throws Exception {  
                // 线程休眠两秒,模拟数据库高耗时操作  
                String value = UUID.randomUUID().toString();  
                TimeUnit.SECONDS.sleep(2);  
                return value;  
            }  
        });  
    return data;  
    } catch (ExecutionException e) {  
        throw new RuntimeException(e.getMessage());  
    }  
}

让我们来看看get源码(guava 33.4.8-jre),我就不用文字表述了,直接加注释

// valueLoader 即为当 localcache中缓存未命中时,通过valueload加载数据
// final确保valueloader在方法内部不会被重新赋值,java要求匿名内部类使用的局部变量必须是final或effective final
public V get(K key, final Callable<? extends V> valueLoader) throws ExecutionException {  
    // 判断valueloader是否为空
    Preconditions.checkNotNull(valueLoader);
    // 根据key获取value
    return this.localCache.get(key, new CacheLoader<Object, V>() {  
        public V load(Object key) throws Exception {  
            return valueLoader.call();  
        }  
    });  
}

接下来我们进到this.localcache 的get方法中

// 调用该方法时可以安全的忽略返回值
@CanIgnoreReturnValue  
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {  
    // 类似于concurrentHashMap 根据键 key定位缓存段,同时 checkNotNull确保key不为null
    int hash = this.hash(Preconditions.checkNotNull(key));  
    // 通过 segmentFor找到对应的缓存段,并调用get方法调用该缓存段上的 get方法
    // 即其将单个 segment视为一个对象,进行并发安全性控制等操作
    return this.segmentFor(hash).get(key, hash, loader);  
}

接下来我们进入segment的get方法中

// LocalCache级别变量,用于获取时间戳
final Ticker ticker;
// segment级别变量,segment中 key-value数量
volatile int count;

@CanIgnoreReturnValue  
// 核心加载方法,键 key,键的哈希 hash,自定义加载器 loader
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {  
    // 判空操作
    Preconditions.checkNotNull(key);  
    Preconditions.checkNotNull(loader);  
  
    try {  
        if (this.count != 0) { 
            // 如果当前 segment不为空,执行 getEntry查找对应条目,这里就不提供源码了
            // getEntry方法就是先获取桶的第一个对象,然后通过 for循环和 getNext方法进行遍历,并进行 hash匹配,如果成功匹配则返回,否则返回 null
            // getEntry方法还提供了当获得的 entry为 null时,自动清理逻辑
            ReferenceEntry<K, V> e = this.getEntry(key, hash);  
            if (e != null) {
                // 缓存命中,先进行值的有效性检查
                // 从 ticker中获取当前时间,后续用该值判断 value是否过期
                // ticker调用的 System.nanoTime(),封装成对象更灵活
                long now = this.map.ticker.read(); 
                // getLiveValue先判断 key和 value是否为 null,为 null则调用清理方法进行清理
                // 然后再调用 tryExpireEntries方法检查是否过期,该方法需要先获取锁,未获取锁直接返回不等待……
                V value = this.getLiveValue(e, now);  
                if (value != null) {
                    // 记录操作,记录一次命中,同时触发可能的缓存刷新策略
                    this.recordRead(e, now);  
                    this.statsCounter.recordHits(1);  
                    Object var17 = this.scheduleRefresh(e, key, hash, value, now, loader);  
                    return var17;  
                }  
                // 缓存命中,存在 key,但是 value为 null,isLoading检查是否有其他线程正在异步加载,如果有,等待,避免重复加载,避免缓存穿透
                // ValueReference提供对缓存值的间接访问,主要用于对值的加载状态管理、生命周期控制等
                ValueReference<K, V> valueReference = e.getValueReference();  
                if (valueReference.isLoading()) {  
                    // 该等待机制通过 Future.get() 实现,而非重试或者订阅通知
                    Object var9 = this.waitForLoadingValue(e, key, valueReference);  
                    return var9;  
                }  
            }  
        }  
        // 未命中,则调用 lockedGetOrLoad进行加锁的加载操作,避免缓存穿透
        Object var15 = this.lockedGetOrLoad(key, hash, loader);  
        return var15;  
    } catch (ExecutionException var13) {
        // 异常处理
        Throwable cause = var13.getCause();  
        if (cause instanceof Error) {  
            throw new ExecutionError((Error)cause);  
        } else if (cause instanceof RuntimeException) {  
            throw new UncheckedExecutionException(cause);  
        } else {  
            throw var13;  
        }  
    } finally {  
        // 清理工作
        this.postReadCleanup();  
    }  
}

那么接下来,聚焦于以下三部分:

scheduleRefresh 缓存刷新策略

V scheduleRefresh(ReferenceEntry<K, V> entry, K key, int hash, V oldValue, long now, CacheLoader<? super K, V> loader) {  
    // 判断缓存是否启用了刷新功能,距离上次写入时间是否超过刷新间隔,确保值当前不在加载中
    if (this.map.refreshes() && now - entry.getWriteTime() > this.map.refreshNanos && !entry.getValueReference().isLoading()) { 
        // 执行 refresh加载方法
        V newValue = this.refresh(key, hash, loader, true);  
        if (newValue != null) {  
            return newValue;  
        }  
    }  
  
    return oldValue;  
}  
  
@CanIgnoreReturnValue  
// @Nullable表示返回值可能为 null
@Nullable V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime) { 
    // LoadingValueReference 是 Guava Cache 实现高并发缓存加载的核心组件,在数据真正加载完成前,负责协调和管理所有并发请求。
    // 检查并创建该 key加载中的标记,原子操作,只有一个线程可以抢到锁,返回 LoadingValueReference 对象,进行异步刷新
    // 其他线程未抢到锁返回 null,直接 return,同时 LoadingValueReference会持有对旧值的引用,未获得锁的线程仍旧使用旧值
    LoadingValueReference<K, V> loadingValueReference = this.insertLoadingValueReference(key, hash, checkTime);  
    if (loadingValueReference == null) {  
        return null;  
    } else {  
        // 调用 loadAsync进行异步加载,返回一个 ListenableFuture对象
        ListenableFuture<V> result = this.loadAsync(key, hash, loadingValueReference, loader); 
        if (result.isDone()) {  
            try {  
                // 如果获取成功,使用 Uninterruptibles.getUninterruptibly 获取结果
                return Uninterruptibles.getUninterruptibly(result);  
            } catch (Throwable var8) {  
                // 异常时静默处理,返回 null
            }  
        }  
  
        return null;  
    }  
}

ListenableFuture<V> loadAsync(K key, int hash, LoadingValueReference<K, V> loadingValueReference, CacheLoader<? super K, V> loader) {  
    // LoadingValueReference.loadFuture方法启动异步加载
    // 通过自定义的匿名内部类中的 call方法进行缓存刷新
    ListenableFuture<V> loadingFuture = loadingValueReference.loadFuture(key, loader);  
    // 通过完成监听器实现回调机制
    loadingFuture.addListener(() -> {  
        try {  
            // 处理加载结果等相关信息
            this.getAndRecordStats(key, hash, loadingValueReference, loadingFuture);  
        } catch (Throwable var6) {  
            LocalCache.logger.log(Level.WARNING, "Exception thrown during refresh", var6);  
            loadingValueReference.setException(var6);  
        }  
  
    }, MoreExecutors.directExecutor());  
    return loadingFuture;  
}

waitForLoadingValue 等待加载

V waitForLoadingValue(ReferenceEntry<K, V> e, K key, ValueReference<K, V> valueReference) throws ExecutionException {  
    if (!valueReference.isLoading()) {  
        // 如果当前 key不在加载中状态,那么程序出现错误,抛出异常
        throw new AssertionError();  
    } else {  
        // 检查当前线程是否已持有该 ReferenceEntry的锁,防止递归加载,如果持有了还等待加载就会形成死锁
        Preconditions.checkState(!Thread.holdsLock(e), "Recursive load of: %s", key);  
  
        Object var7;  
        try {  
            // 调用 waitForValue
            V value = valueReference.waitForValue();  
            if (value == null) {  
                throw new CacheLoader.InvalidCacheLoadException("CacheLoader returned null for key " + key + ".");  
            }  
  
            long now = this.map.ticker.read();  
            this.recordRead(e, now);  
            var7 = value;  
        } finally {  
            this.statsCounter.recordMisses(1);  
        }  
  
        return var7;  
    }  
}

public V waitForValue() throws ExecutionException {  
    // 工具类
    return Uninterruptibles.getUninterruptibly(this.futureValue);  
}

@CanIgnoreReturnValue  
@ParametricNullness  
// 以不可中断的方式获取 Future执行结果
public static <V> V getUninterruptibly(Future<V> future) throws ExecutionException { 
    // 用于中断状态跟踪
    boolean interrupted = false;  
  
    try {  
        while(true) {  
            // 循环等待结果
            // 这种循环等待的状态不会浪费cpu资源,因为 future.get()是阻塞调用,本地线程阻塞等待
            // 而 Redisson是分布式环境,需要考虑网络延迟,服务端压力,客户端压力,所以采用订阅通知机制
            try {  
                Object var2 = future.get();  
                return var2;  
            } catch (InterruptedException var6) {
                // 如果捕获到 InterruptedException,设置 interrupted = true并继续循环
                interrupted = true;  
            }  
        }  
    } finally {  
        if (interrupted) {  
            // 在方法结束前,如果曾经被中断过,恢复当前线程的中断状态
            Thread.currentThread().interrupt();  
        }  
  
    }  
}

lockedGetOrLoad 加锁获取

V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {  
    // 缓存值引用
    ValueReference<K, V> valueReference = null;  
    // 值加载引用
    LoadingValueReference<K, V> loadingValueReference = null; 
    // 标记是否需要创建新 key-value对象
    boolean createNewEntry = true; 
    // 获取分段锁,确保同一 segment内的操作线程安全
    this.lock();  
  
    ReferenceEntry e;  
    try {  
        long now = this.map.ticker.read();  
        // 写操作前清理过期的 key-value
        this.preWriteCleanup(now);  
        int newCount = this.count - 1;  
        // 获取哈希表和相关索引 table,同时计算哈希桶索引 index,获取链表头节点 first
        AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;  
        int index = hash & table.length() - 1;  
        ReferenceEntry<K, V> first = (ReferenceEntry)table.get(index);  
        
        // 通过 for循环遍历哈希桶的链表,寻找匹配的键,guava cache没有使用红黑树优化
        for(e = first; e != null; e = e.getNext()) {  
            K entryKey = e.getKey();  
            if (e.getHash() == hash && entryKey != null && this.map.keyEquivalence.equivalent(key, entryKey)) {  
                // 当哈希相等,键非空,并且键值都相等时,执行以下逻辑
                // 获取该键值的值引用
                valueReference = e.getValueReference();  
                if (valueReference.isLoading()) {
                    // 如果该值正在被其他线程加载,则不需要创建 key-value对象,等待现有加载完成
                    createNewEntry = false;  
                } else {  
                    // 检查已存在的值
                    V value = valueReference.get();  
                    if (value == null) {
                        // 值已被垃圾回收
                        this.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.COLLECTED);  
                    } else {  
                        if (!this.map.isExpired(e, now)) {
                            // 缓存命中,且值未过期,则记录相关信息,直接返回缓存值
                            this.recordLockedRead(e, now);  
                            this.statsCounter.recordHits(1);  
                            Object var16 = value;  
                            return var16;  
                        }  
  
                        // 值已过期
                        this.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.EXPIRED);  
                    }  
  
                    // 清理无效键值对并更新计数器
                    this.writeQueue.remove(e);  
                    this.accessQueue.remove(e);  
                    this.count = newCount;  
                }  
                // 找到匹配项,退出循环
                break;  
            }  
        }  
  
        // 判断是否需要创建新加载的键值对象
        if (createNewEntry) {  
            // 创建加载标记
            loadingValueReference = new LoadingValueReference();  
            if (e == null) {  
                // 哈希桶中不存在该键,创建全新条目,并将该对象状态设置成加载中
                e = this.newEntry(key, hash, first);  
                e.setValueReference(loadingValueReference);  
                table.set(index, e);  
            } else {  
                // 替换现有的无效条目的引用
                e.setValueReference(loadingValueReference);  
            }  
        }  
    } finally {  
        // 释放分段锁,写操作后清理
        this.unlock();  
        this.postWriteCleanup();  
    }  
  
    // 根据是否创建新键值对象选择不同的路径
    if (createNewEntry) {  
        Object var9;  
        try {  
            synchronized(e) {  
                // 对键值对象加锁,防止同一对象并发加载
                var9 = this.loadSync(key, hash, loadingValueReference, loader);  
            }  
        } finally {  
                // 记录未命中统计
                this.statsCounter.recordMisses(1);  
        }  
  
        return var9;  
    } else {  
        // 其他线程正在加载等待其结果
        return this.waitForLoadingValue(e, key, valueReference);  
    }  
}

总结

一方面,建议就用现成的 cache策略,然后我们再讨论 guava cache是如何解决缓存溢出,缓存击穿以及并发安全性的。

  1. 缓存溢出:可以自行配置容量,当超出容量时默认使用 LRU(最近最少使用)算法进行淘汰;同时支持过期时间,避免不常用数据长时间占用缓存空间。
  2. 缓存击穿:当数据过期或者缓存未命中时,通过悲观锁机制,有且仅有一个线程负责执行数据库读取操作;如果数据过期,则其他线程暂时使用旧值进行计算;如果是正在加载,则其他线程执行等待加载操作,即通过 future.get()方法获取数据,然后通过 while循环轮询 future.get()的结果。
  3. 并发安全性:类似于 concurrentHashMap,操作时对桶进行加锁。

guava cache.png

因为前提条件中提到,用户的消息很少变动,那么在高并发场景下,即使对缓存的操作不是原子性的,乱序操作导致的结果也是具有幂等性的,无非就是多写入了几次而已,那么用 hashmap也是一个可以选择的方式。