寫在前面
在實際作業中,有一種非常普遍的并發場景:那就是讀多寫少的場景,在這種場景下,為了優化程式的性能,我們經常使用快取來提高應用的訪問性能,因為快取非常適合使用在讀多寫少的場景中,而在并發場景中,Java SDK中提供了ReadWriteLock來滿足讀多寫少的場景,本文我們就來說說使用ReadWriteLock如何實作一個通用的快取中心,
本文涉及的知識點有:

文章已收錄到:
https://github.com/sunshinelyz/technology-binghe
https://gitee.com/binghe001/technology-binghe
讀寫鎖
說起讀寫鎖,相信小伙伴們并不陌生,總體來說,讀寫鎖需要遵循以下原則:
- 一個共享變數允許同時被多個讀執行緒讀取到,
- 一個共享變數在同一時刻只能被一個寫執行緒進行寫操作,
- 一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作,
這里,需要小伙伴們注意的是:讀寫鎖和互斥鎖的一個重要的區別就是:讀寫鎖允許多個執行緒同時讀共享變數,而互斥鎖不允許,所以,在高并發場景下,讀寫鎖的性能要高于互斥鎖,但是,讀寫鎖的寫操作是互斥的,也就是說,使用讀寫鎖時,一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作,
讀寫鎖支持公平模式和非公平模式,具體是在ReentrantReadWriteLock的構造方法中傳遞一個boolean型別的變數來控制,
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
另外,需要注意的一點是:在讀寫鎖中,讀鎖呼叫newCondition()會拋出UnsupportedOperationException例外,也就是說:讀鎖不支持條件變數,
快取實作
這里,我們使用ReadWriteLock快速實作一個快取的通用工具類,總體代碼如下所示,
public class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 讀鎖
private final Lock r = rwl.readLock();
// 寫鎖
private final Lock w = rwl.writeLock();
// 讀快取
public V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 寫快取
public V put(K key, V value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
}
可以看到,在ReadWriteLockCache中,我們定義了兩個泛型型別,K代表快取的Key,V代表快取的value,在ReadWriteLockCache類的內部,我們使用Map來快取相應的資料,小伙伴都都知道HashMap并不是執行緒安全的類,所以,這里使用了讀寫鎖來保證執行緒的安全性,例如,我們在get()方法中使用了讀鎖,get()方法可以被多個執行緒同時執行讀操作;put()方法內部使用寫鎖,也就是說,put()方法在同一時刻只能有一個執行緒對快取進行寫操作,
這里需要注意的是:無論是讀鎖還是寫鎖,鎖的釋放操作都需要放到finally{}代碼塊中,
在以往的經驗中,有兩種向快取中加載資料的方式,一種是:專案啟動時,將資料全量加載到快取中,一種是在專案運行期間,按需加載所需要的快取資料,

接下來,我們就分別來看看全量加載快取和按需加載快取的方式,
全量加載快取
全量加載快取相對來說比較簡單,就是在專案啟動的時候,將資料一次性加載到快取中,這種情況適用于快取資料量不大,資料變動不頻繁的場景,例如:可以快取一些系統中的資料字典等資訊,整個快取加載的大體流程如下所示,

將資料全量加載到快取后,后續就可以直接從快取中讀取相應的資料了,
全量加載快取的代碼實作比較簡單,這里,我就直接使用如下代碼進行演示,
public class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 讀鎖
private final Lock r = rwl.readLock();
// 寫鎖
private final Lock w = rwl.writeLock();
public ReadWriteLockCache(){
//查詢資料庫
List<Field<K, V>> list = .....;
if(!CollectionUtils.isEmpty(list)){
list.parallelStream().forEach((f) ->{
m.put(f.getK(), f.getV);
});
}
}
// 讀快取
public V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 寫快取
public V put(K key, V value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
}
按需加載快取
按需加載快取也可以叫作懶加載,就是說:需要加載的時候才會將資料加載到快取,具體來說:就是程式啟動的時候,不會將資料加載到快取,當運行時,需要查詢某些資料,首先檢測快取中是否存在需要的資料,如果存在,則直接讀取快取中的資料,如果不存在,則到資料庫中查詢資料,并將資料寫入快取,后續的讀取操作,因為快取中已經存在了相應的資料,直接回傳快取的資料即可,

這種查詢快取的方式適用于大多數快取資料的場景,
我們可以使用如下代碼來表示按需查詢快取的業務,
class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
V get(K key) {
V v = null;
//讀快取
r.lock();
try {
v = m.get(key);
} finally{
r.unlock();
}
//快取中存在,回傳
if(v != null) {
return v;
}
//快取中不存在,查詢資料庫
w.lock();
try {
//再次驗證快取中是否存在資料
v = m.get(key);
if(v == null){
//查詢資料庫
v=從資料庫中查詢出來的資料
m.put(key, v);
}
} finally{
w.unlock();
}
return v;
}
}
這里,在get()方法中,首先從快取中讀取資料,此時,我們對查詢快取的操作添加了讀鎖,查詢回傳后,進行解鎖操作,判斷快取中回傳的資料是否為空,不為空,則直接回傳資料;如果為空,則獲取寫鎖,之后再次從快取中讀取資料,如果快取中不存在資料,則查詢資料庫,將結果資料寫入快取,釋放寫鎖,最侄訓傳結果資料,
這里,有小伙伴可能會問:為啥程式都已經添加寫鎖了,在寫鎖內部為啥還要查詢一次快取呢?
這是因為在高并發的場景下,可能會存在多個執行緒來競爭寫鎖的現象,例如:第一次執行get()方法時,快取中的資料為空,如果此時有三個執行緒同時呼叫get()方法,同時運行到 w.lock()代碼處,由于寫鎖的排他性,此時只有一個執行緒會獲取到寫鎖,其他兩個執行緒則阻塞在w.lock()處,獲取到寫鎖的執行緒繼續往下執行查詢資料庫,將資料寫入快取,之后釋放寫鎖,
此時,另外兩個執行緒競爭寫鎖,某個執行緒會獲取到鎖,繼續往下執行,如果在w.lock()后沒有v = m.get(key); 再次查詢快取的資料,則這個執行緒會直接查詢資料庫,將資料寫入快取后釋放寫鎖,最后一個執行緒同樣會按照這個流程執行,
這里,實際上第一個執行緒已經查詢過資料庫,并且將資料寫入快取了,其他兩個執行緒就沒必要再次查詢資料庫了,直接從快取中查詢出相應的資料即可,所以,在w.lock()后添加v = m.get(key); 再次查詢快取的資料,能夠有效的減少高并發場景下重復查詢資料庫的問題,提升系統的性能,
讀寫鎖的升降級
關于鎖的升降級,小伙伴們需要注意的是:在ReadWriteLock中,鎖是不支持升級的,因為讀鎖還未釋放時,此時獲取寫鎖,就會導致寫鎖永久等待,相應的執行緒也會被阻塞而無法喚醒,
雖然不支持鎖升級,但是ReadWriteLock支持鎖降級,例如,我們來看看官方的ReentrantReadWriteLock示例,如下所示,
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}}
資料同步問題
首先,這里說的資料同步指的是資料源和資料快取之間的資料同步,說的再直接一點,就是資料庫和快取之間的資料同步,
這里,我們可以采取三種方案來解決資料同步的問題,如下圖所示

超時機制
這個比較好理解,就是在向快取寫入資料的時候,給一個超時時間,當快取超時后,快取的資料會自動從快取中移除,此時程式再次訪問快取時,由于快取中不存在相應的資料,查詢資料庫得到資料后,再將資料寫入快取,
采用這種方案需要注意快取的穿透問題,有關快取穿透、擊穿、雪崩的知識,小伙伴們可以參見《【高并發】面試官:講講什么是快取穿透?擊穿?雪崩?如何解決?》
定時更新快取
這種方案是超時機制的增強版,在向快取中寫入資料的時候,同樣給一個超時時間,與超時機制不同的是,在程式后臺單獨啟動一個執行緒,定時查詢資料庫中的資料,然后將資料寫入快取中,這樣能夠在一定程度上避免快取的穿透問題,
實時更新快取
這種方案能夠做到資料庫中的資料與快取的資料是實時同步的,可以使用阿里開源的Canal框架實作MySQL資料庫與快取資料的實時同步,也可以使用我個人開源的mykit-data框架哦(推薦使用)~~
推薦閱讀
- 【高并發】面試官:講講什么是快取穿透?擊穿?雪崩?如何解決?
- 兩行代碼修復了決議MySQL8.x binlog錯位的問題!!
mykit-data開源地址:
- https://github.com/sunshinelyz/mykit-data
- https://gitee.com/binghe001/mykit-data
好了,今天就到這兒吧,我是冰河,大家有啥問題可以在下方留言,也可以加我微信:sun_shine_lyz,我拉你進群,一起交流技術,一起進階,一起牛逼~~
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/271938.html
標籤:其他
上一篇:CodeReview的原則
