經過一輪壓測,覺得光用redis緩存已經達到一定瓶頸,便想着引入本地緩存試試,眾多選擇下最終定了guava緩存。以下簡要談談項目中使用的guava緩存。
緩存是什么
1、Cache是高速緩沖存儲器 一種特殊的存儲器子系統,其中復制了頻繁使用的數據以利於快速訪問
2、凡是位於速度相差較大的兩種硬件/軟件之間的,用於協調兩者數據傳輸速度差異的結構,均可稱之為 Cache
為什么要用緩存
為了系統的高並發,高性能,提高訪問速度
緩存分類
根據緩存應用的耦合度,可以分為local cache(本地緩存)和remote cache(分布式緩存):
- 本地緩存:指的是在應用中的緩存組件,其最大的優點是應用和cache在同一個進程內部,請求緩存非常快速,沒有過多的網絡開銷等,在單應用不需要集群支持或者集群情況下各節點無需互相通知的場景下使用本地緩存較為合適;同時,它的缺點也是因為緩存跟應用程序耦合,多個應用程序無法直接共享緩存,各應用或集群的各節點都需要維護自己的單獨緩存,對內存是一種浪費。(編程直接實現緩存(沒用過)、Ehcache(沒用過)、Guava Cache)
- 分布式緩存:指的是與應用分離的緩存組件或服務,其最大的優點是自身就是一個獨立的應用,與本地應用隔離,多個應用可直接共享緩存。(memcached(沒用過)、redis)
guava緩存測試
public static void main(String[] args) throws ExecutionException { CacheLoader<String, String> loader = new CacheLoader<String, String> () { public String load(String key) throws Exception { Thread.sleep(1000); //休眠1s,模擬加載數據 System.out.println(key + " is loaded from a cacheLoader!"); return key + "'s value"; } }; LoadingCache<String,String> loadingCache = CacheBuilder.newBuilder() .maximumSize(3) .build(loader);//在構建時指定自動加載器
//maximumSize為最大存儲,超過將把之前的緩存刪掉
loadingCache.get("key1"); loadingCache.get("key2"); LoadingCache<String, String> apiValidRulecache = CacheBuilder .newBuilder().initialCapacity(10) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { Thread.sleep(1000); //休眠1s,模擬加載數據 System.out.println(key + " is loaded from a cacheLoader!"); return key + "'s value"; } });
//說明:設置過期時間有expireAfterAccess(讀之后)和expireAfterWrite(寫之后)兩種方式
apiValidRulecache.get("key3"); apiValidRulecache.get("key4"); apiValidRulecache.get("key3"); System.out.println(apiValidRulecache.get("key1")); System.out.println(apiValidRulecache.get("key2")); System.out.println(apiValidRulecache.get("key3")); System.out.println(apiValidRulecache.get("key4")); Cache<String,String> cache = CacheBuilder.newBuilder() .maximumSize(3) .recordStats() //開啟統計信息開關 .build(); cache.put("key1","value1"); cache.put("key2","value2"); cache.put("key3","value3"); cache.put("key4","value4"); cache.getIfPresent("key1"); cache.getIfPresent("key2"); cache.getIfPresent("key3"); cache.getIfPresent("key4"); cache.getIfPresent("key5"); cache.getIfPresent("key6"); System.out.println(cache.stats()); //獲取統計信息 }
測試結果:
guava緩存使用
其中一個例子為,產品接口的限額信息獲取。由於與產品方約定好修改內容十分鍾生效,所以以下設置為十分鍾。
/** 創建guava緩存,緩存所有產品時段限額配置 */
private LoadingCache<String, Map<String, String>> proTpsLimitcache = CacheBuilder
.newBuilder().initialCapacity(10)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Map<String, String>>() {
@Override
public Map<String, String> load(String key) throws Exception {
return saveProTpsLimit(key);
} });
//load緩存數據的方法
private Map<String, String> saveProTpsLimit(String key) {
Map<String, String> info = redisService.getAllTpsLimit(key);
if (info == null) {
info = new HashMap<>();
}
return info;
}
// 獲取緩存中product所有的時段限額信息
Map<String, String> proAllTpsLimit = proTpsLimitcache.get(product);
從我的使用來看其實是不涉及緩存更新策略的,而我的應用又是集群部署的,所以我的數據確實會存在數據不一致性。那么會不會有影響呢?實際上是會的,但是我這個業務上允許十分鍾生效,沒有那么的實時性,所以影響性有限。不過為了以后其他業務還是了解下緩存數據一致性策略。
緩存一致性策略
一致性當然涉及的就是數據庫和緩存數據的順序問題,根據排序有以下幾種可能(數據庫只涉及更新,緩存涉及更新和刪除):
- 先更新緩存再更新數據庫
- 先更新數據庫再更新緩存
- 先更新數據庫再刪除緩存(有效)
- 先刪除緩存再更新數據庫
先更新緩存再更新數據庫
(1)線程A更新了緩存
(2)線程B讀取數據庫老數據更新了緩存
(3)線程A更新了數據庫
(4)線程B更新了數據庫
還是出現了不一致現象,沒用。
先更新數據庫再更新緩存
這套方案,大家是普遍反對的。為什么呢?有如下兩點原因。
- 原因一(線程安全角度)
同時有請求A和請求B進行更新操作,那么會出現
(1)線程A更新了數據庫
(2)線程B更新了數據庫
(3)線程B更新了緩存
(4)線程A更新了緩存
這就出現請求A更新緩存應該比請求B更新緩存早才對,但是因為網絡等原因,B卻比A更早更新了緩存。這就導致了臟數據,因此不考慮。
- 原因二(業務場景角度)
有如下兩點:
(1)如果你是一個寫數據庫場景比較多,而讀數據場景比較少的業務需求,采用這種方案就會導致,數據壓根還沒讀到,緩存就被頻繁的更新,浪費性能。
(2)如果你寫入數據庫的值,並不是直接寫入緩存的,而是要經過一系列復雜的計算再寫入緩存。那么,每次寫入數據庫后,都再次計算寫入緩存的值,無疑是浪費性能的。顯然,刪除緩存更為適合。
先刪除緩存再更新數據庫
(1)請求A進行寫操作,刪除緩存
(2)請求B查詢發現緩存不存在
(3)請求B去數據庫查詢得到舊值
(4)請求B將舊值寫入緩存
(5)請求A將新值寫入數據庫
先更新數據庫再刪除緩存
Cache-Aside pattern中指出:
-
失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功后,放到緩存中。
-
命中:應用程序從cache中取數據,取到后返回。
-
更新:先把數據存到數據庫中,成功后,再讓緩存失效。
這種情況不存在並發問題么?
不是的。假設這會有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那么會有如下情形產生
(1)緩存剛好失效
(2)請求A查詢數據庫,得一個舊值
(3)請求B將新值寫入數據庫
(4)請求B刪除緩存
(5)請求A將查到的舊值寫入緩存
ok,如果發生上述情況,確實是會發生臟數據。
然而,發生這種情況的概率又有多少呢?
發生上述情況有一個先天性條件,就是步驟(3)的寫數據庫操作比步驟(2)的讀數據庫操作耗時更短,才有可能使得步驟(4)先於步驟(5)。可是,大家想想,數據庫的讀操作的速度遠快於寫操作的(不然做讀寫分離干嘛,做讀寫分離的意義就是因為讀操作比較快,耗資源少),因此步驟(3)耗時比步驟(2)更短,這一情形很難出現。
假設,有人非要抬杠,有強迫症,一定要解決怎么辦?
如何解決上述並發問題?
首先,給緩存設置有效時間是一種方案。其次,采用策略(2)里給出的異步延時刪除策略,保證讀請求完成以后,再進行刪除操作
還有其他造成不一致的原因么?
有的,這也是緩存更新策略(2)和緩存更新策略(3)都存在的一個問題,如果刪緩存失敗了怎么辦,那不是會有不一致的情況出現么。比如一個寫數據請求,然后寫入數據庫了,刪緩存失敗了,這會就出現不一致的情況了。這也是緩存更新策略(2)里留下的最后一個疑問。
如何解決?
提供一個保障的重試機制即可,這里給出兩套方案。
方案一:
如下圖所示
流程如下所示
(1)更新數據庫數據;
(2)緩存因為種種問題刪除失敗
(3)將需要刪除的key發送至消息隊列
(4)自己消費消息,獲得需要刪除的key
(5)繼續重試刪除操作,直到成功
然而,該方案有一個缺點,對業務線代碼造成大量的侵入。於是有了方案二,在方案二中,啟動一個訂閱程序去訂閱數據庫的binlog,獲得需要操作的數據。在應用程序中,另起一段程序,獲得這個訂閱程序傳來的信息,進行刪除緩存操作。
方案二:
流程如下圖所示:
(1)更新數據庫數據
(2)數據庫會將操作信息寫入binlog日志當中
(3)訂閱程序提取出所需要的數據以及key
(4)另起一段非業務代碼,獲得該信息
(5)嘗試刪除緩存操作,發現刪除失敗
(6)將這些信息發送至消息隊列
(7)重新從消息隊列中獲得該數據,重試操作。