(三)Redis &分散式鎖

1 Redis使用中的常見問題和解決辦法

  1.1 快取穿透

    定義:快取系統都是按照key去快取查詢,如果不存在對應的value,就應該去DB查找。一些惡意的請求會故意查詢不存在的key,請求量很大,就會對DB造成很大的壓力,甚至壓垮資料庫。

    解決方案:對查詢結果為空的情況也進行快取,TTL設置短一點。

1.2 快取雪崩

    定義:在某個時間點,快取中的key集體發生過期失效致使大量查詢資料庫的請求都落在DB上,導致DB負載過高、壓力暴增,甚至可能壓垮資料庫

    解決方案:該問題產生的原因在於大量的key在某個時間點或者某個時間段失效導致的,為了更好的避免這種問題的發生,一般更好的做法是為這些key設置不同的、隨機的TTL(過期失效時間),從而錯開快取中的key的失效時間點。

  1.3 快取擊穿

    定義:快取中某個頻繁被訪問的key(也稱為熱點key),在不停的扛著前端的高並發請求,當這個key突然在某個瞬間過期失效時,持續的高並發請求就擊穿快取,直接請求資料庫,導致資料庫壓力在某一瞬間暴增。

    解決方案:出現這個問題的原因在於熱點的key過期失效了,而在實際情況中,既然這個key可以被作為熱點頻繁訪問,那麼就應該設置這個Key永不過期,這樣前端的高並發請求將幾乎永遠不會落在資料庫上。

 

2 高並發問題&Redis解決方案  

 

在傳統的單體Java應用中,為了解決多執行緒的並發安全問題,最常見的做法是在核心的業務邏輯程式碼中加鎖操作進行同步控制如synchronized關鍵字,然而在微服務、分散式系統架構時代,這種做法是行不通的,因為synchronized關鍵字是跟單一服務節點所在的JVM相關聯的,而分散式系統架構下的服務一般是部署在不同的節點(伺服器)下,從而當出現高並發請求時,Synchronized同步操作將顯得力不從心。因而我們需要尋找一種高效的解決方案,這種方案既要保證單一節點核心業務程式碼的同步控制,也要保證當擴展到多個節點部署時同樣能實現核心邏輯程式碼的同步控制,由此分散式鎖應運而生。它的出現主要是為了解決分散式系統中高並發請求時並發訪問共享資源導致並發安全問題,目前關於分散式鎖的實現有許多種,典型的包括基於資料庫級別的樂觀鎖和悲觀鎖,以及關於Redis的原子操作實現分散式鎖和基於ZooKeeper實現分散式鎖等。

 

在Redis的知識體系和底層基礎架構中,其實並沒有直接提供所謂的分散式鎖組件,而是間接的藉助其原子操作來實現。之所以原子操作可以實現分散式鎖的功能,主要是得益於Redis的單執行緒機制,即不管外層應用系統並發了N個執行緒,當每個執行緒都需要Redis的某個原子操作時,是需要進行排隊等待的,原因在於其底層系統架構中,同一時刻、同一個部署節點中只有一個執行緒執行某種原子操作。

 

3 Redis缺陷&Redisson解決方案

3.1 分析

Redis的原子操作實現的分散式鎖具有一定的缺陷,包括:

(1)執行Redis的原子操作EXPIRE時,需要設置Key的過期時間TTL,不同的業務場景設置的過期時間是不同的,但是如果設置不當,將很有可能影響系統和Redis服務的性能。

(2)採用Redis的原子操作SETNX獲取分散式鎖時,不具備可重入性,即當高並發產生多執行緒時,同一時刻只有一個執行緒可以獲取到鎖,從而操作共享資源,而其他的執行緒將獲取鎖失敗,而且是永遠失敗下去,而有一些業務需要要求執行緒”可重入”,則需要在應用程式里添加while(true){}的程式碼塊,即不斷的循環等待獲取分散式鎖,這種方式既不優雅,又很可能造成應用系統性能卡頓。典型的場景如商城有些秒殺活動中,商家為了飢餓營銷會故意將庫存量設置為很小,但是沒貨時又及時補貨,因此這種情況下就要求用戶秒殺過程中的分散式鎖的獲取具有重入性。但顯然Redis不適合這類場景。

(3)在執行Redis的原子操作SETNX之後EXPIRE操作之前,如果此時Redis的服務節點發生宕機,由於Key沒有及時被釋放而導致最終很有可能出現死鎖,即永遠不會有其他的執行緒能夠獲取到鎖。

Redisson分散式鎖的出現能夠很好的解決以上問題,Redisson提供了多種分散式鎖供開發者使用,包括可重入鎖、一次性鎖、聯鎖、讀寫鎖等等,每一種分散式鎖實現方式和使用的場景各不相同,而應用較多的當屬Redission的可重入鎖和一次性鎖。可重入鎖指的是當前執行緒如果沒有獲取到對共享資源的鎖,將會在允許的時間範圍內等待獲取,超時則放棄等待,主要通過調用tryLock()方法時進行指定。一次性鎖指的是當前執行緒獲取分散式鎖時,如果成功則執行後續對共享資源的操作,否則將永遠失敗下去,其主要通過調用lock()方法獲取鎖。

 

3.2 一次性鎖與可重入鎖的實現

(1)依賴:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.3</version>
</dependency>

(2)application.properties配置:

redisson.host.config=redis://127.0.0.1:6379

(3)客戶端實例注入:

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;

/**
 * 自定義注入配置操作Redisson的客戶端實例
 * @author huaiheng
 **/
@Configuration
public class RedissonConfig {

    @Autowired
    private Environment environment;

    @Bean
    public RedissonClient config(){
        Config config = new Config();
        config.useSingleServer().setAddress(environment.getProperty("redisson.host.config")).setKeepAlive(true);
        return Redisson.create(config);
    }

}

(4)一次性鎖 & 可重入鎖

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.concurrent.TimeUnit;

/**
 * 可重入鎖與一次性鎖框架
 *
 * @author huaiheng
 */
public class RedissonLock {

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 針對用戶級別的一次性鎖
     */
    public void lockOnlyOnce(String UserId) throws Exception {
        final String lockName = "redissionOneLock-" + UserId;
        RLock rLock = redissonClient.getLock(lockName);
        try {
            // 上鎖後,不管何種情況,10s後主動釋放
            rLock.lock(10, TimeUnit.SECONDS);
            /** 
             資源操作程式碼部分
             **/
        } catch (Exception e) {
            throw e;
        } finally {
            if (rLock != null) {
                rLock.unlock();
            }
        }
    }

    /**
     * 針對用戶級別的可重入鎖
     */
    public void repeatLock(String UserId) throws Exception {
        final String lockName = "redissionOneLock-" + UserId;
        RLock rLock = redissonClient.getLock(lockName);
        try {
            // 嘗試獲取鎖,時間最長為100s,如果獲取到鎖,則不管何種情況,最長10s必須釋放
            rLock.tryLock(100, 10, TimeUnit.SECONDS);
            /** 
             資源操作程式碼部分
             **/
        } catch (Exception e) {
            throw e;
        } finally {
            if (rLock != null) {
                rLock.unlock();
            }
        }
    }
}

 

4 其他解決方案

除了Redis和Redisson提供的分散式鎖的功能外,最常見的還包括資料庫級別的分布鎖機制,資料庫的分散式鎖主要包括樂觀鎖和悲觀鎖,在這裡僅做原理和應用上的分析和講解:

  • 樂觀鎖:樂觀鎖是一種很佛系的實現方式,它總是認為不會產生並發問題,因為每次從資料庫中獲取數據時總認為不會有其他執行緒對數據進行修改,因此不會上鎖,但是在更新數據時其會判斷其他執行緒在之前是否對數據進行了修改(通常採用版本號version機制進行實現),如果沒有進行修改則本次修改生效並更新版本號,如果已經被修改則本次修改無效,從而避免了多並發執行緒訪問共享數據時出現數據不一致的現象,這種實現方法對應的核心SQL的偽程式碼寫法如下所示:
 update table set key=value, version=version+1 where id=#{id} and version=#{version};
  • 悲觀鎖:悲觀鎖是一種消極的處理方式,它總是假設事情的發生在最壞的情況,即每次並發執行緒在獲取數據的時候認為其他執行緒對數據進行了修改,因而每次在獲取數據時都會上鎖,而其他執行緒訪問該數據時就會發生阻塞的現象,最終只有當前執行緒釋放了該共享資源的鎖,其他執行緒才能獲取到鎖,並對共享資源進行操作。資料庫中的行鎖、表鎖、讀鎖、寫鎖都是這種方式,java中的synchronized和ReentrantLock也是悲觀鎖的思想。

應用場景分析:

  • 樂觀鎖:由於採用version版本號機制實現,因而在高並發產生多執行緒時,同一時刻只有一個執行緒能夠獲取到鎖並成功操作共享資源,而其他執行緒將獲取失敗並且是永遠失敗下去,從這個角度來看,這種方式雖然可以控制並發執行緒對共享資源的訪問,但是卻犧牲了系統的吞吐性能,因此適用於讀多寫少的場景。
  • 悲觀鎖:由於建立在資料庫底層搜索引擎的基礎之上,並採用select… for update方式對共享資源加鎖,因而當產生高並發多執行緒請求,特別是讀請求時,將對資料庫的性能帶來更嚴重的影響,特別是同一時刻產生的多執行緒中將只有一個執行緒能夠獲取到鎖,而其他執行緒將處於阻塞的狀態,直到該執行緒釋放了鎖,從這一角度來看,基於數據級別的悲觀鎖適用於並發量不大的情況,特別是讀請求數據量不大的情況,因此適用於讀少寫多的場景。
Tags: