Guava Cache基礎應用
對於Guava Cache本身就不多做介紹了,一個非常好用的本地cache lib,可以完全取代自己手動維護ConcurrentHashMap。
背景
目前需要開發一個介面I,對效能要求有非常高的要求,TP99.9在20ms以內。初步開發後發現耗時完全無法滿足,mysql稍微波動就超時了。
主要耗時在DB讀取,請求一次介面會讀取幾次配置表Entry表。而Entry表的資訊更新又不頻繁,對實時性要求不高,所以想到了對DB做一個cache,理論上就可以大幅度提升介面效能了。
DB表結構(這裡的程式碼都是為了演示,不過原理、流程和實際生產環境基本是一致的)
CREATE TABLE `entry` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` int(11) NOT NULL, `value` varchar(50) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `unique_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
介面中的查詢是根據name進行select操作,這次的目的就是設計一個cache類,將DB查詢cache化。
基礎使用
首先,自然而然的想到了最基本的guava cache的使用,如下:
@Slf4j @Component public class EntryCache { @Autowired EntryMapper entryMapper; /** * guava cache 快取實體 */ LoadingCache<String, Entry> cache = CacheBuilder.newBuilder() // 快取重新整理時間 .refreshAfterWrite(10, TimeUnit.MINUTES) // 設定快取個數 .maximumSize(500) .build(new CacheLoader<String, Entry>() { @Override // 當本地快取命沒有中時,呼叫load方法獲取結果並將結果快取 public Entry load(String appKey) { return getEntryFromDB(appKey); } // 資料庫進行查詢 private Entry getEntryFromDB(String name) { log.info("load entry info from db!entry:{}", name); return entryMapper.selectByName(name); } }); /** * 對外暴露的方法 * 從快取中取entry,沒取到就走資料庫 */ public Entry getEntry(String name) throws ExecutionException { return cache.get(name); } }
這裡用了refreshAfterWrite,和expireAfterWrite區別是expireAfterWrite到期會直接刪除快取,如果同時多個併發請求過來,這些請求都會重新去讀取DB來重新整理快取。DB速度較慢,會造成執行緒短暫的阻塞(相對於讀cache)。
而refreshAfterWrite,則不會刪除cache,而是隻有一個請求執行緒會去真實的讀取DB,其他請求直接返回老值。這樣可以避免同時過期時大量請求被阻塞,提升效能。
但是還有一個問題,那就是更新執行緒還是會被阻塞,這樣在快取key集體過期時,可能還會使響應時間變得不滿足要求。
後臺執行緒重新整理
就像上面所說,只要重新整理快取,就必然有執行緒被阻塞,這個是無法避免的。
雖然無法避免執行緒阻塞,但是我們可以避免阻塞使用者執行緒,讓使用者無感知即可。
所以,我們可以把重新整理執行緒放到後臺執行。當key過期時,有新使用者執行緒讀取cache時,開啟一個新執行緒去load DB的資料,其他使用者執行緒就可以直接返回老的值,這樣就解決了這個問題。
程式碼修改如下:
@Slf4j @Component public class EntryCache { @Autowired EntryMapper entryMapper; ListeningExecutorService backgroundRefreshPools = MoreExecutors.listeningDecorator(new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>())); /** * guava cache 快取實體 */ LoadingCache<String, Entry> cache = CacheBuilder.newBuilder() // 快取重新整理時間 .refreshAfterWrite(10, TimeUnit.MINUTES) // 設定快取個數 .maximumSize(500) .build(new CacheLoader<String, Entry>() { @Override // 當本地快取命沒有中時,呼叫load方法獲取結果並將結果快取 public Entry load(String appKey) { return getEntryFromDB(appKey); } @Override // 重新整理時,開啟一個新執行緒非同步重新整理,老請求直接返回舊值,防止耗時過長 public ListenableFuture<Entry> reload(String key, Entry oldValue) throws Exception { return backgroundRefreshPools.submit(() -> getEntryFromDB(key)); } // 資料庫進行查詢 private Entry getEntryFromDB(String name) { log.info("load entry info from db!entry:{}", name); return entryMapper.selectByName(name); } }); /** * 對外暴露的方法 * 從快取中取entry,沒取到就走資料庫 */ public Entry getEntry(String name) throws ExecutionException { return cache.get(name); } /** * 銷燬時關閉執行緒池 */ @PreDestroy public void destroy(){ try { backgroundRefreshPools.shutdown(); } catch (Exception e){ log.error("thread pool showdown error!e:{}",e.getMessage()); } } }
改動就是新添加了一個backgroundRefreshPools執行緒池,重寫了一個reload方法。
ListeningExecutorService是guava的concurrent包裡的類,負責一些執行緒池相關的工作,感興趣的可以自己去了解一下。
在reload方法裡提交一個新的執行緒,就可以用這個執行緒來重新整理cache了。
如果重新整理cache沒有完成的時候有其他執行緒來請求該key,則會直接返回老值。
同時, 千萬不要忘記銷燬執行緒池 。
初始化問題
上面兩步達到了不阻塞重新整理cache的功能,但是這個前提是這些cache已經存在。
專案剛剛啟動的時候,所有的cache都是不存在的,這個時候如果大批量請求過來,同樣會被阻塞,因為沒有老的值供返回,都得等待cache的第一次load完畢。
解決這個問題的方法就是在專案啟動的過程中,將所有的cache預先load過來,這樣使用者請求剛到伺服器時就會直接讀cache,不用等待。
@Slf4j @Component public class EntryCache { @Autowired EntryMapper entryMapper; ListeningExecutorService backgroundRefreshPools = MoreExecutors.listeningDecorator(new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>())); /** * guava cache 快取實體 */ LoadingCache<String, Entry> cache = CacheBuilder.newBuilder() // 快取重新整理時間 .refreshAfterWrite(10, TimeUnit.MINUTES) // 設定快取個數 .maximumSize(500) .build(new CacheLoader<String, Entry>() { @Override // 當本地快取命沒有中時,呼叫load方法獲取結果並將結果快取 public Entry load(String appKey) { return getEntryFromDB(appKey); } @Override // 重新整理時,開啟一個新執行緒非同步重新整理,老請求直接返回舊值,防止耗時過長 public ListenableFuture<Entry> reload(String key, Entry oldValue) throws Exception { return backgroundRefreshPools.submit(() -> getEntryFromDB(key)); } // 資料庫進行查詢 private Entry getEntryFromDB(String name) { log.info("load entry info from db!entry:{}", name); return entryMapper.selectByName(name); } }); /** * 對外暴露的方法 * 從快取中取entry,沒取到就走資料庫 */ public Entry getEntry(String name) throws ExecutionException { return cache.get(name); } /** * 銷燬時關閉執行緒池 */ @PreDestroy public void destroy(){ try { backgroundRefreshPools.shutdown(); } catch (Exception e){ log.error("thread pool showdown error!e:{}",e.getMessage()); } } @PostConstruct public void initCache() { log.info("init entry cache start!"); //讀取所有記錄 List<Entry> list = entryMapper.selectAll(); if (CollectionUtils.isEmpty(list)) { return; } for (Entry entry : list) { try { this.getEntry(entry.getName()); } catch (Exception e) { log.error("init cache error!,e:{}", e.getMessage()); } } log.info("init entry cache end!"); } }
結果
讓我們用資料看看這個cache類的表現:
200QPS,TP99.9是9ms,完美達標。
可以看出來,合理的使用快取對介面效能還是有很大提升的。