论获取缓存值的正确姿势

论获取缓存值的正确姿势

cache

时至今日,大家对缓存想必不在陌生。我们身边各种系统中或多或少的都存在缓存,自从有个缓存,我们可以减少很多计算压力,提高应用程序的QPS。

你将某些需要大量计算或查询的结果,设置过期时间后放入缓存。下次需要使用的时候,先去缓存处查询是否存在缓存,没有就直接计算/查询,并将结果塞入缓存中。

Object result = cache.get(CACHE_KEY);
if(result == null){
    //重新获取缓存
    result = xxxx(xxx);
    cache.put(CACHE_KEY,CACHE_TTL,result); 
}
return result;

Bingo~~,一切都在掌握之中,程序如此完美,可以支撑更大的访问压力了。

不过,这样的获取缓存的逻辑,真的没有问题吗?


高并发下暴露问题

你的程序一直正常运行,直到某一日,运营的同事急匆匆的跑来找到你,你的程序挂了,可能是XXX在大量抓你的数据。我们重启了应用也没用,没几秒程序又挂了。

机智的你通过简单的排查,得出数据库顶不住访问压力,顺利的将锅甩走。 不过仔细一想,我们不是有缓存吗,怎么缓存没起作用? 查看下缓存,一切正常,也没发现什么问题啊?

进过各种debug、查日志、测试环境模拟,花了整整一下午,你终于找到罪魁祸首,原因很简单,正是我们没有使用正确的姿势使用缓存~~~


问题分析

这里我们排除熔断、限流等外部措施,单纯讨论缓存问题。

假设你的应用需要访问某个资源(数据库/服务),其能支撑的最大QPS为100。为了提高应用QPS,我们加入缓存,并将缓存过期时间设置为X秒。此时,有个200并发的请求访问我们系统中某一路径,这些请求对应的都是同一个缓存KEY,但是这个键已经过期了。此时,则会瞬间产生200个线程访问下游资源,下游资源便有可能瞬间就奔溃了~~~

我们有什么更好的方法获取缓存吗?当然有,这里通过guava cache来看下google是怎么处理获取缓存的。


guava 和 guava cache

guava是一个google发布的一个开源java工具库,其中guava cacha提供了一个轻量级的本地缓存实现机制,通过guava cache,我们可以轻松实现本地缓存。其中,guava cacha对缓存不存在或者过期情况下,获取缓存值得过程称之为Loading。

直接上代码,看看guava cache是如何get一个缓存的。


        V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
            ...
            try {
                if(this.count != 0) {
                    LocalCache.ReferenceEntry ee = this.getEntry(key, hash);
                    if(ee != null) {
                        long cause1 = this.map.ticker.read();
                        Object value = this.getLiveValue(ee, cause1);
                        if(value != null) {
                            this.recordRead(ee, cause1);
                            this.statsCounter.recordHits(1);
                            Object valueReference1 = this.scheduleRefresh(ee, key, hash, value, cause1, loader);
                            return valueReference1;
                        }

                        LocalCache.ValueReference valueReference = ee.getValueReference();
                        if(valueReference.isLoading()) {
                            Object var9 = this.waitForLoadingValue(ee, key, valueReference);
                            return var9;
                        }
                    }
                }

                Object ee1 = this.lockedGetOrLoad(key, hash, loader);
                return ee1;
            } catch (ExecutionException var13) {
                ...
            } finally {
                ...
            }
        }

可见,核心逻辑主要在scheduleRefresh(...)和lockedGetOrLoad(...)中。

先看和lockedGetOrLoad,


        V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
            LocalCache.ValueReference valueReference = null;
            LocalCache.LoadingValueReference loadingValueReference = null;
            boolean createNewEntry = true;
            //先加锁
            this.lock();

            LocalCache.ReferenceEntry e;
            try {
                long now = this.map.ticker.read();
                this.preWriteCleanup(now);
                int newCount = this.count - 1;
                AtomicReferenceArray table = this.table;
                int index = hash & table.length() - 1;
                LocalCache.ReferenceEntry first = (LocalCache.ReferenceEntry)table.get(index);

                for(e = first; e != null; e = e.getNext()) {
                    Object entryKey = e.getKey();
                    if(e.getHash() == hash && entryKey != null && this.map.keyEquivalence.equivalent(key, entryKey)) {
                        valueReference = e.getValueReference();
                        //判断是否有其他线程正在执行loading动作
                        if(valueReference.isLoading()) {
                            createNewEntry = false;
                        } else {
                            Object value = valueReference.get();
                            if(value == null) { 
                                this.enqueueNotification(entryKey, hash, valueReference, 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, valueReference, RemovalCause.EXPIRED);
                            }

                            this.writeQueue.remove(e);
                            this.accessQueue.remove(e);
                            this.count = newCount;
                        }
                        break;
                    }
                }
                
                //创建一个LoadingValueReference
                if(createNewEntry) {
                    loadingValueReference = new LocalCache.LoadingValueReference();
                    if(e == null) {
                        e = this.newEntry(key, hash, first);
                        e.setValueReference(loadingValueReference);
                        table.set(index, e);
                    } else {
                        e.setValueReference(loadingValueReference);
                    }
                }
            } finally {
               ...
            }

            if(createNewEntry) {
                Object var9;
                try {
                    //没有其他线程在loading情况下,同步Loading获取值
                    synchronized(e) {
                        var9 = this.loadSync(key, hash, loadingValueReference, loader);
                    }
                } finally {
                    this.statsCounter.recordMisses(1);
                }

                return var9;
            } else {
                //等待其他线程返回值
                return this.waitForLoadingValue(e, key, valueReference);
            }
        }

可见正常情况下,guava会单线程处理回源动作,其他并发的线程等待处理线程Loading完成后直接返回其结果。这样也就避免了多线程同时对同一资源并发Loading的情况发生。

不过,这样虽然只有一个线程去执行loading动作,但是其他线程会等待loading线程接受后才能一同返回接口。此时,guava cache通过刷新策略,直接返回旧的缓存值,并生成一个线程去处理loading,处理完成后更新缓存值和过期时间。guava 称之为异步模式。

V scheduleRefresh(LocalCache.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()) {
            Object newValue = this.refresh(key, hash, loader, true);
            if(newValue != null) {
                return newValue;
            }
        }

        return oldValue;
    }

Refreshing is not quite the same as eviction. As specified in LoadingCache.refresh(K), refreshing a key loads a new value for the key, possibly asynchronously. The old value (if any) is still returned while the key is being refreshed, in contrast to eviction, which forces retrievals to wait until the value is loaded anew.

此外guava还提供了同步模式,相对于异步模式,唯一的区别是有一个请求线程去执行loading,其他线程返回过期值。


总结

看似简单的获取缓存值的业务逻辑没想到还暗藏玄机。当然,这里guava cache只是本地缓存,如果依葫芦画瓢用在redis等分布式缓存时,势必还要考虑更多的地方。

最后,如果喜欢本文,请点赞~~~~

posted @ 2016-10-08 08:55 二胡嘈子 阅读(...) 评论(...) 编辑 收藏