寫在前面
在實際工作中,有一種非常普遍的併發場景:那就是讀多寫少的場景。在這種場景下,為了優化程式的效能,我們經常使用快取來提高應用的訪問效能。因為快取非常適合使用在讀多寫少的場景中。而在併發場景中,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框架哦(推薦使用)~~
推薦閱讀
mykit-data開源地址:
好了,今天就到這兒吧,我是冰河,大家有啥問題可以在下方留言,也可以加我微信:sun_shine_lyz,我拉你進群,一起交流技術,一起進階,一起牛逼~~