性能優化2.0,新增緩存后,程序的秒開率不升反降
一、前情提要
在上一篇文章中提到,有一個頁面加載速度很慢,是通過緩沖流優化的。
查詢的時候,會訪問后臺數據庫,查詢前20條數據,按道理來說,這應該很快才對。
追蹤代碼,看看啥問題,最后發現問題有三:
- 表中有一個BLOB大字段,存儲著一個PDF模板,也就是上圖中的運費模板。
- 查詢后會將這個PDF模板存儲到本地磁盤。
- 點擊線上顯示,會讀取本地的PDF模板,通過socket傳到服務器。
大字段批量查詢、批量文件落地、讀取大文件并進行網絡傳輸,不慢才怪,這一頓騷操作,5秒能加載完畢,已經燒高香了。
經過4次優化,將頁面的加載時間控制在了1秒以內,實打實的提升了程序的秒開率。
- 批量查詢時,不查詢BLOB大字段。
- 點擊運費查詢時,單獨查詢+觸發索引,實現“懶加載”。
- 異步存儲文件。
- 通過 緩沖流 -> 內存映射技術mmap -> sendFile零拷貝 讀取本地文件。
有一個小伙伴在評論中提到,還可以通過緩存繼續優化,確實是可以的,緩存也是復用優化的一種。
為了提高頁面的加載速度,使用了單條查詢 + 觸發索引,提高數據庫查詢速度。
歸根結底,還是查詢了數據庫,如果不查呢,訪問速度肯定會更快。
這個時候,就用到了緩存,將運費模板存到緩存中。
二、先了解一下,什么是緩存
緩存就是把訪問量較高的熱點數據從傳統的關系型數據庫中加載到內存中,當用戶再次訪問熱點數據時,是從內存中加載,減少了對數據庫的訪問量,解決了高并發場景下容易造成數據庫宕機的問題。
我理解的緩存的本質就是一個用空間換時間的一個思想。
提供“緩存”的目的是為了讓數據訪問的速度適應CPU的處理速度,其基于的原理是內存中“局部性原理”。
CPU 緩存的是內存數據,用于解決 CPU 處理速度和內存不匹配的問題,比如處理器和內存之間的高速緩存,操作系統在內存管理上,針對虛擬內存 為頁表項使用了一特殊的高速緩存TLB轉換檢測緩沖區,因為每個虛擬內存訪問會引起兩次物理訪問,一次取相關的頁表項,一次取數據,TLB引入來加速虛擬地址到物理地址的轉換。
1、緩存有哪些分類
- 操作系統磁盤緩存,減少磁盤機械操作
- 數據庫緩存,減少文件系統 I/O
- 應用程序緩存,減少對數據庫的查詢
- Web 服務器緩存,減少應用程序服務器請求
- 客戶端瀏覽器緩存,減少對網站的訪問
2、本地緩存與分布式緩存
本地緩存:在客戶端本地的物理內存中劃出一部分空間,來緩存客戶端回寫到服務器的數據。當本地回寫緩存達到緩存閾值時,將數據寫入到服務器中。
本地緩存是指程序級別的緩存組件,它的特點是本地緩存和應用程序會運行在同一個進程中,所以本地緩存的操作會非常快,因為在同一個進程內也意味著不會有網絡上的延遲和開銷。
本地緩存適用于單節點非集群的應用場景,它的優點是快,缺點是多程序無法共享緩存。
無法共享緩存可能會造成系統資源的浪費,每個系統都單獨維護了一份屬于自己的緩存,而同一份緩存有可能被多個系統單獨進行存儲,從而浪費了系統資源。
分布式緩存是指將應用系統和緩存組件進行分離的緩存機制,這樣多個應用系統就可以共享一套緩存數據了,它的特點是共享緩存服務和可集群部署,為緩存系統提供了高可用的運行環境,以及緩存共享的程序運行機制。
下面介紹一個小編最常用的本地緩存 Guava Cache。
三、Guava Cache本地緩存
1、Google Guava
Google Guava是一個Java編程庫,其中包含了許多高質量的工具類和方法。其中,Guava的緩存工具之一是LoadingCache。LoadingCache是一個帶有自動加載功能的緩存,可以自動加載緩存中不存在的數據。其實質是一個鍵值對(Key-Value Pair)的緩存,可以使用鍵來獲取相應的值。
Guava Cache 的架構設計靈感來源于 ConcurrentHashMap,它使用了多個 segments 方式的細粒度鎖,在保證線程安全的同時,支持了高并發的使用場景。Guava Cache 類似于 Map 集合的方式對鍵值對進行操作,只不過多了過期淘汰等處理邏輯。
Guava Cache對比ConcurrentHashMap優勢在哪?
- Guava Cache可以設置過期時間,提供數據過多時的淘汰機制。
- 線程安全,支持并發讀寫。
- 在緩存擊穿時,GuavaCache 可以使用 CacheLoader 的load 方法控制,對同一個key,只允許一個請求去讀源并回填緩存,其他請求阻塞等待。
2、Loadingcache數據結構
- Loadingcache含有多個Segment,每一個Segment中有若干個有效隊列。
- 多個Segment之間互不打擾,可以并發執行。
- 各個Segment的擴容只需要擴自己,與其它的Segment無關。
- 設置合適的初始化容量與并發水平參數,可以有效避免擴容,但是設置的太大了,耗費內存,設置的太小,緩存價值降低,需要根據業務需求進行權衡。
- Loadingcache數據結構和ConcurrentHashMap很相似,ReferenceEntry用于存放key-value。
- 每一個ReferenceEntry都會存放一個雙向鏈表,采用的是Entry替換的方式。
- 每次訪問某個元素就將元素移動到鏈表頭部,這樣鏈表尾部的元素就是最近最少使用的元素,替換的復雜度為o(1),但是訪問的復雜度還是O(n)。
- 隊列用于實現LRU緩存回收算法。
3、Loadingcache數據結構構建流程:
- 初始化CacheBuilder,指定參數(并發級別、過期時間、初始容量、緩存最大容量),使用build()方法創建LocalCache實例。
- 創建Segment數組,初始化每一個Segment。
- 為Segment屬性賦值。
- 初始化Segment中的table,即一個ReferenceEntry數組(每一個key-value就是一個ReferenceEntry)。
- 根據之前類變量的賦值情況,創建相應隊列,用于LRU緩存回收算法。
4、判斷緩存是否過期
- expireAfterWrite,在put時更新緩存時間戳,在get時如果發現當前時間與時間戳的差值大于過期時間戳,就會進行load操作。
- expireAfterAccess,在expireAfterWrite的基礎上,不管是寫還是讀都會記錄新的時間戳。
- refreshAfterWrite,調用get進行值的獲取的時候才會執行reload操作,這里的刷新操作可以通過異步調用load實現。
5、Loadingcache如何解決緩存穿透
緩存穿透是指在Loadingcache緩存中,由于某些原因,緩存的數據無法被正常訪問或處理,導致緩存失去了它的作用。
發生緩存穿透的原因有很多,比如數據量過大、數據更新頻繁、數據過期、數據權限限制、緩存性能瓶頸等原因,這里不過多糾結。
(1)expireAfterAcess和expireAfterWrite同步加載
設置為expireAfterAcess和expireAfterWrite時,在進行get的過程中,緩存失效的話,會進行load操作,load是一個同步加載的操作,如下圖:
如果發生了緩存穿透,當有大量并發請求訪問緩存時,會有一個線程去同步查詢DB,隨即通過reeatrantLock進入loading等待狀態,其它請求相同key的線程,一部分在waitforvalue,另一部分在reentantloack的阻塞隊列中,等待同步查詢完畢,所有請求都會獲得最新值。
(2)refreshAfterWrite同步加載
如果采用refresh的話,會通過scheduleRefresh方法進行load,也是一個線程同步獲取DB。
其它線程不會阻塞,性能比expireAfterWrite同步加載高,但是,可能返回新值、也可能返回舊值。
(3)refreshAfterWrite異步加載
當加載緩存的線程是異步加載的話,對于請求1,如果在異步結束前返回,就會返回舊值,反之是新值。
對于其他線程來說,不會被阻塞,直接返回,返回值可能是新值或者是舊值。
Loadingcache沒使用額外的線程去做定時清理和加載的功能,而是依賴于get()請求。
在查詢的時候,進行時間對比,如果使用refreshAfterWrite,在長時間沒有查詢時,查詢有可能會得到一個舊值,我們可以通過設置refreshAfterWrite(寫刷新,在get時可以同步或異步緩存的時間戳)為5s,將expireAfterWrite(寫過期,在put時更新緩存的時間戳)設為10s,當訪問頻繁的時候,會在每5秒都進行refresh,而當超過10s沒有訪問,下一次訪問必須load新值。
四、Redis中如何解決緩存穿透
如果發生了緩存穿透,可以針對要查詢的數據,在Redis中插入一條數據,添加一個約定好的默認值,比如defaultNull。
比如你想通過某個id查詢某某訂單,Redis中沒有,MySQL中也沒有,此時,就可以在Redis中插入一條,存為defaultNull,下次再查詢就有了,因為是提前約定好的,前端也明白是啥意思,一切OK,歲月靜好。
五、使用loadingCache優化頁面加載
1、引入pom
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
2、初始化LoadingCache
private static LoadingCache<String, String> loadCache;
public static void initLoadingCache() {
loadCache = CacheBuilder.newBuilder()
// 并發級別設置為 10,是指可以同時寫緩存的線程數
.concurrencyLevel(10)
// 寫刷新,在get時可以同步或異步緩存的時間戳
.refreshAfterWrite(5, TimeUnit.SECONDS)
// 寫過期,在put時更新緩存的時間戳
.expireAfterWrite(10, TimeUnit.SECONDS)
// 設置緩存容器的初始容量為 10
.initialCapacity(10)
// 設置緩存最大容量為 100,超過之后就會按照 LRU 算法移除緩存項
.maximumSize(100)
// 設置要統計緩存的命中率
.recordStats()
// 指定 CacheLoader,緩存不存在時,可自動加載緩存
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 自動加載緩存的業務
return "error";
}
}
);
}
3、優化5:通過LoadingCache緩存模板數據,在編輯模板后,更新緩存
查詢模板信息后,通過loadCache.put(uuid, pdf);加載到內存中,在編輯模板時,更新緩存過期時間,下次獲取模板PDF時,直接從LoadingCache緩存中取,降低數據庫訪問壓力,perfect!!!
這種情況是不適合緩存的,因為模板pdf本來就是一個BLOB大數據,你把它放內存里緩存了,你告訴我,能放幾個?內存扛得住嗎?