1. 程式人生 > 其它 >快取與分散式鎖的應用

快取與分散式鎖的應用

快取與分散式鎖的應用

目錄

1、快取應用

快取使用場景:

為了提升系統性能,我們一般會將部分資料放入快取中,加速訪問,而資料庫只承擔資料的落盤工作

那麼哪些資料適合放入快取呢?

  • 即時性,資料一致性要求不高的
  • 訪問量大且更新效率不高的

舉例:電商類應用、商品分類、商品列表等適合放入快取並加一個更新時間(由資料更新頻率來定),後臺釋出一個商品,買家需要5分鐘後才能看到新的商品一般是可以接受的

1、本地應用

    /**
     * 自定義快取	
     */
    private Map<String, Object> cache = new HashMap<>();
    /**
     * 獲取真實資料
     * 
     * @return
     */
    public NeedLockDataVO getDataVO() {
        NeedLockDataVO cacheDataVO = (NeedLockDataVO) cache.get("cacheDataVO");
        // 如果有,就返回
        if (cacheDataVO != null) {
            return cacheDataVO;
        }
        // 如果沒有,獲取到db中資料
        NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
        // put進快取
        return (NeedLockDataVO) cache.put("cacheDataVO", needLockDataVO);
    }

    private NeedLockDataVO doGetNeedLockDataVO() {
        // TODO 繁瑣業務邏輯程式碼
      	try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
        }
        return new NeedLockDataVO();
    }

我們這裡的快取元件利用了原生的Map。

在同一個專案,同一個JVM中,即本地儲存一個副本的,可以稱為本地快取。

在單個服務應用中,我們使用本地快取模式,快取元件和應用如果永遠只用同一個機器上部署,不會出現任何問題,並且效率很高。

但是我們思考下面一個場景:

如果在一個分散式的系統下,我們一個服務專案往往會部署十幾臺伺服器上,每一個伺服器都自帶一個自己的一個本地快取,這樣會出現什麼問題呢?

分散式場景下,單體應用自帶的快取僅對與自己所在伺服器上生效。假設我們有一個商品服務,同時部署在多個伺服器上,客戶端發起了查詢一個商品列表的請求,我們通過負載均衡找到第一臺伺服器,發現一號伺服器的本地快取中沒有,就從資料庫中查詢出來,放到了一號伺服器的本地快取中。第二個客戶端請求同樣是查詢這個商品列表,通過負載均衡,找到了第二臺伺服器,那麼此時第二臺伺服器的本地快取是不會有第一臺的快取資訊的。如此會引來一下問題:

問題:

  • 伺服器各顧各的,每一個請求進來,都得查一遍放入自己的快取中
  • 如果對資料進行修改,一般還要修改快取中的資料,假設我們通過負載均衡,只修改了一號伺服器快取資料,那麼以後負載均衡到其他伺服器上的請求所得到的資料,就會和一號伺服器的資料有所不同,就會出現一個嚴重的問題:資料一致性問題

那麼,我們分散式系統下,該如何使用快取,解決資料一致性的問題呢?

2、分散式場景中應用

我們引入一箇中間件redis,將快取控制交給第三方來處理,所有讀取|寫入快取的資料都交由redis,本地不再做快取

/**
     * StringRedisTemplate
     */
    @Resource
    StringRedisTemplate cache;

    /**
     * 獲取真實資料
     *
     * @return
     */
    public NeedLockDataVO getDataVO() {
        //  從redis中取出
        String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
        NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
        // 如果有,就返回
        if (cacheDataVO != null) {
            return cacheDataVO;
        }
        // 如果沒有,獲取到db中資料
        NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
        // 存入redis
        cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
        return needLockDataVO;
    }

    private NeedLockDataVO doGetNeedLockDataVO() {
        // TODO 繁瑣業務邏輯程式碼
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
        }
        return new NeedLockDataVO();
    }

接下來,我們利用jmeter壓測一下會發現,redis後期會頻繁報錯:OutOfDirectMemoryError

產生的原因:

Redis自動配置

@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {

}

SpringBoot2.0以後預設使用Lettuce作為操作redis的客戶端,它使用netty進行網路通訊lettuce的bug導致堆外記憶體溢位,netty如果沒有指定堆外記憶體,就會預設使用虛擬機器的-Xms的值,可以使用-Dio.netty.maxDirectMemory進行設定,時間久了堆外記憶體溢位問題肯定還會出現,治標不治本。

那我們該如何解決這個棘手的問題呢?

Jedis 替換SpringBoot預設使用的Lettuce

首先排除Lettuce包:

				<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
                <exclusions>
                    <exclusion>
                        <groupId>io.lettuce</groupId>
                        <artifactId>lettuce-core</artifactId>
                    </exclusion>
                </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

堆外記憶體溢位OutOfDirectMemoryError,完美解決!

我們通過加第三方快取解決了快取一致性的問題,可是我們設想一個場景,如果有兩臺伺服器,A先請求更新db,B在之後更新db,但是A請求的伺服器,網路出現問題導致延時,B請求的伺服器在A請求的伺服器之前首先操作快取,那麼我們按照常理來講,應該是從快取中查詢到最後一次更新的資料,這就引來了另一個問題:快取的最終一致性問題

那麼,我們對這個"快取最終一致性"問題又該如何解決呢?我們先從快取存在的安全問題引出來解決方案!

3、快取相關問題

3.1、快取穿透

查詢了一定不存在的資料-快取穿透

快取穿透:是指快取和資料庫中都不存在的資料,而使用者不斷髮起請求,如發起查詢id=“-1”的資料或id為特別大不存在的資料,這時候使用者很可能是攻擊者,攻擊會導致資料庫壓力過大

解決方案:

  • 介面層增加校驗(使用者鑑權、id作為基礎校驗,過濾id為-1的請求)
  • 快取中去不到的資料,也寫入快取中,以key-null形式儲存(快取時間設定短一些,設定太長會導致正常情況也無法使用),這樣可以防止用同一個id暴力攻擊
  • **布隆過濾器:具體看大佬的文章

3.2、快取雪崩

快取中不同資料大批量過期-快取穿透

快取雪崩:是指快取中資料大批量過期而且查詢量巨大,引起資料庫壓力巨大,甚至宕機,與快取擊穿不同,雪崩是查詢不同的資料都過期了

解決方案:

  • 隨機時間:在原有的過期時間的基礎上,加上一個隨機的時間,防止同一時間資料大批量過期
  • 永不過期快取:在情況允許的情況下,設定快取資料永不過期
  • redis高可用:預防redis宕機導致雪崩問題
  • 限流降級:通過加鎖和佇列的方式進行對資料庫的讀取和寫快取,例如:對某個key只允許一個執行緒查詢資料庫,其他執行緒等待
  • 資料預熱:在正式部署之前,先把資料訪問一遍加入快取,設定不同的過期時間,讓快取失效的時間點儘量均勻

3.3、快取擊穿

熱點key剛好失效-快取擊穿

快取擊穿:一個熱點key在某個時間點失效的情況下,有大批量執行緒去查詢該key,導致大批量執行緒去查詢資料庫,引起資料庫壓力巨大,甚至宕機

解決方案:

  • 互斥鎖:在併發的多個請求中,只有第一個請求執行緒能拿到鎖並執行資料庫查詢操作,等到第一個執行緒將資料寫入快取後,其他執行緒直接走快取
  • 分散式鎖:在分散式場景下,本地互斥鎖不能保證只有一個執行緒去查詢資料庫,也可以使用分散式鎖去避免擊穿問題
  • 熱點資料不過期:直接將快取設定為不過期,然後由定時任務去非同步載入資料,更新快取

關於快取擊穿,我們如何選定方案呢?

本質上我們是在併發場景很高的情況下,通過降低訪問資料庫的執行緒併發量,來達到避免快取擊穿的問題出現。

互斥鎖VS分散式鎖:

我們很多時候是通過叢集部署多個相同的服務,本地互斥鎖雖然不能嚴謹控制單個執行緒查詢資料庫,但是我們的目的是降低併發量,只要保證走到資料庫的請求能大大降低即可,所以還有另一個思路是 JVM 鎖,當然如果要保證快取最終一致性的場景,我們還是需要用到分散式鎖作為最終解決方案的!

JVM 鎖保證了在單臺伺服器上只有一個請求走到資料庫,通常來說已經足夠保證資料庫的壓力大大降低,同時在效能上比分散式鎖更好。
值得注意的是:無論是使用“分散式鎖”,還是“JVM 鎖”,加鎖時要按 key 維度去加鎖。

使用固定的key值加鎖,這樣會導致不同的 key 之間也會互相阻塞,造成效能嚴重損耗。

2、擊穿解決方案-鎖的應用

綜合上面的結果,我們的redis快取雖然提升了效能,但是在一些特殊場景下,仍會存在一些問題(快取擊穿與資料最終一致性)。

我們瞭解到分散式鎖是可以通過單個執行緒訪問資料庫資源,解決上面兩個問題的,那麼我們接下來討論一下“鎖”相關的應用。

1、本地鎖(包括JUC包下)

在我們引入解決方案之前,我們先看一個例子:

		/**
     * 獲取真實資料
     *
     * @return
     */
    public NeedLockDataVO getDataVO() {
        String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
        NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
        // 如果有,就返回
        if (cacheDataVO != null) {
            return cacheDataVO;
        }
        // 如果沒有,獲取到db中資料
        NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
        // 存入redis
        cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
        return needLockDataVO;
    }

    private NeedLockDataVO doGetNeedLockDataVO() {
				// 資料本地加鎖
        synchronized (this){
            // TODO 繁瑣業務邏輯程式碼
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
            }
            return new NeedLockDataVO();
        }
    }

假設我們例項,交由Spring來管理,this獲取的是同一個

不知道我們有沒有發現以下問題:

  1. 快取的讀取與存入均不在鎖內,即便是單體伺服器,併發情況下都會出現宕機風險問題。
  2. 加鎖是在本地,多個伺服器下,仍然會有多個執行緒去訪問資料庫,快取資料一致性仍然得不到解決

我們對“1”的問題,只需要在進入鎖之後查一遍快取即可。

程式碼片段更改如下:

private NeedLockDataVO doGetNeedLockDataVO() {
        synchronized (this){
          	// 再次查詢快取,預防宕機風險
            String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
            NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
            // 如果有,就返回
            if (cacheDataVO != null) {
                return cacheDataVO;
            }
            // TODO 繁瑣業務邏輯程式碼
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
            }
            return new NeedLockDataVO();
        }
    }

我們對“2”的問題,如何做一個分散式的鎖來解決當前的隱患問題呢?

2、分散式鎖

什麼是?

當多個程序不在同一個系統中,用分散式鎖控制多個程序對資源的訪問。

分散式解決方案

針對分散式鎖的實現,目前比較常用的有以下幾種方案:

  1. 基於資料庫實現分散式鎖
  2. 基於快取(redis,memcached,tair)實現分散式鎖
  3. 基於Zookeeper實現分散式鎖

我們著重討論一下基於快取的分散式鎖演進實現:

階段一

  public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
		//1:佔分布式鎖。去redis佔坑
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");

    if (lock){
        //加鎖成功。。。執行業務
        Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();

        stringRedisTemplate.delete("lock");//刪除鎖
        return dataFromDB;

    }else {
        //加鎖失敗。。。。重試
        //休眠一百毫秒重試
        return getCatalogJsonFromDbWithRedisLock();//自旋方式
    }
}

問題:

  • 如果我們現在在獲取到鎖以後,執行業務出現了異常,會導致鎖沒有釋放,造成死鎖

原因:加鎖和解鎖過程互不影響,不會整體回滾,沒有對出現異常後鎖做處理

解決方案:

  • 為鎖指定過期時間,到期自動解鎖

階段二

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
  	//1:佔分布式鎖。去redis佔坑
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");

    if (lock){
        //加鎖成功。。。執行業務
        //2:設定過期時間
        stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
        Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();

        stringRedisTemplate.delete("lock");//刪除鎖
        return dataFromDB;

    }else {

        //加鎖失敗。。。。重試
        //休眠一百毫秒重試
        return getCatalogJsonFromDbWithRedisLock();//自旋方式

    }

問題:

  • 同樣是如果因為異常原因,導致過期時間沒有設定執行,造成死鎖

原因:加鎖和設定過期時間側操作不是原子性

解決方案:

我們可以使用SET key value [EX seconds],保證加鎖和過期時間設定的原子性

階段三

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
        //1:佔分布式鎖。去redis佔坑
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111",30, TimeUnit.SECONDS);
        if (lock){
            //加鎖成功。。。執行業務
            //2:設定過期時間
//            stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();

            stringRedisTemplate.delete("lock");//刪除鎖
            return dataFromDB;
        }else {

            //加鎖失敗。。。。重試
            //休眠一百毫秒重試
            return getCatalogJsonFromDbWithRedisLock();//自旋方式

        }
    }

問題:

  • 業務超時發現鎖已經到期自動刪除了,沒鎖可以刪除了,怎麼辦?
  • 業務用時很長,鎖自動過期後,我們把別人的鎖刪除了,怎麼辦?其他的執行緒又進來怎麼辦?

原因:基於效能和網路的綜合原因,我們不能保證超時時間永遠小於過期時間,業務超時時間過長,會導鎖混亂,甚至達不到加鎖的目的。

解決方案:

我們要保證刪除鎖的時候,我們不可以刪除別人的鎖

階段四

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
        //1:佔分布式鎖。去redis佔坑
        String uuid= UUID.randomUUID().toString();
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
        if (lock){
            //加鎖成功。。。執行業務
            //2:設定過期時間
//            stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
//獲取值對比和對比成功刪除一定要是一個原子操作
            String lockValue = stringRedisTemplate.opsForValue().get("lock");
            if (uuid.equals(lockValue)){
                stringRedisTemplate.delete("lock");//刪除鎖
            }
            return dataFromDB;
        }else {
            //加鎖失敗。。。。重試
            //休眠一百毫秒重試
            return getCatalogJsonFromDbWithRedisLock();//自旋方式
        }
    }

問題:

  • 我們在做 uuid.equals(lockValue) 之後,由於網路原因導致超時,還沒有刪除鎖之前,其他執行緒更改了鎖,導致我們雖然覺得是自己的值,刪除的還是別人的鎖,又會有很多執行緒進來搶佔鎖。
  • 業務用時很長,鎖自動過期後,我們把別人的鎖刪除了,怎麼辦?其他的執行緒又進來怎麼辦?

原因:刪除鎖沒能保證原子性

解決方案:

保證刪除鎖的時候的原子性

階段五

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {

        //1:佔分布式鎖。去redis佔坑
        String uuid= UUID.randomUUID().toString();
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);

        if (lock){
            //加鎖成功。。。執行業務
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                    "then\n" +
                    "    return redis.call(\"del\",KEYS[1])\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";
            //原子刪除鎖
            Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
            return dataFromDB;
        }else {
						//加鎖失敗。。。。重試
            //休眠一百毫秒重試
            return getCatalogJsonFromDbWithRedisLock();//自旋方式
        }
    }

問題:

  • 仍然沒有解決鎖過期了的問題

原因:業務超時,還沒有刪除鎖,鎖就過期了,咋辦?

解決方案:

加長鎖時間

階段6

 public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
        //1:佔分布式鎖。去redis佔坑
        String uuid= UUID.randomUUID().toString();
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
        if (lock){
            //加鎖成功。。。執行業務
            Map<String, List<Catelog2Vo>> dataFromDB;
            try {
              dataFromDB = getDataFromDB();
            }finally {
                String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                        "then\n" +
                        "    return redis.call(\"del\",KEYS[1])\n" +
                        "else\n" +
                        "    return 0\n" +
                        "end";

                //原子刪除鎖
                Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
            }
            return dataFromDB;
        }else {
            //加鎖失敗。。。。重試
            //休眠一百毫秒重試
            return getCatalogJsonFromDbWithRedisLock();//自旋方式
        }
    }

分散式鎖總結:

  • 設定足夠長的過期時間
  • 加鎖和過期時間必須是原子性操作
  • 刪除鎖也必須是原子性操作

3、Redisson——分散式中的JUC

在我們瞭解了分散式鎖的演進過程後,能解決一般的場景問題,但是遇到一些複雜的場景,我們需要更高階的分散式鎖,怎麼辦呢?Redis為我們提供了一站式解決方案——Redisson(Distributed locks with Redis)

Rediosson是什麼:Redisson是一個在Redis的基礎上實現的Java駐記憶體資料網格

Redisson官方地址

Redisson(中|英文)文件連結

所有用法,我們均可翻閱Redisson文件

4、Redisson 開始

配置-參考中文文件

/**
 * @author lishanbiao
 * @Date 2021/11/22
 */
@Configuration
public class MyRedissonConfig {
    @Bean(destroyMethod="shutdown")
    RedissonClient redisson() throws IOException {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

測試

		@Autowired
    private Redisson redisson;

		/**
     * hello world
     */
    @RequestMapping("/delete")
    // @RequiresPermissions("coupon:coupon:delete")
    public String helloWorld(@RequestBody Long[] ids) {
        // 獲取鎖
        RLock lock = redisson.getLock("my-lock");

        // 加鎖 阻塞式等待……
        lock.lock();
        try {
            System.out.println("加鎖成功,執行業務……" + Thread.currentThread().getId());
            Thread.sleep(3000);
        } catch (Exception e) {

        } finally {
            // 解鎖
            System.out.println("釋放鎖……" + Thread.currentThread().getId());
            lock.unlock();
        }
        return "hello";
    }

思考:程式刪除之前終端,會不會有死鎖問題呢?

測試會發現,並不會(自己動手實踐)。

原因:

翻看文件會發現,業務超長執行期間,Redisson內部提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,不斷的延長鎖的有效期。預設情況下,看門狗的檢查鎖的超時時間是30秒鐘,也可以通過修改Config.lockWatchdogTimeout來另行指定。

值得注意的是:如果為鎖指定了時間,則會關閉看門狗功能,業務超長後,刪除鎖的程式就會報錯

讀寫鎖

這些我只寫一些特性(具體請翻閱Redisson文件):

只要有寫鎖的存在都必須等待

  • 讀 + 讀:相當於無鎖
  • 讀 + 寫:寫等待讀鎖,讀完後執行
  • 寫 + 讀:讀等待
  • 寫 + 寫:寫等待

雙寫模式——寫資料庫的同時,去更新快取

失效模式——寫資料庫的同時去刪除快取,等待下一次讀取

我們根據上面兩張圖可以看出:

無論我們哪種模式,都會存在資料不一致的問題,但是我們可以怎麼辦?

  • 如果使用者緯度資料(使用者不可能一會兒加單,一會兒刪單),併發機率非常小,不用考慮這個問題,加上過期時間,只需要隔一段時間主動查詢資料庫即可
  • 如果是目錄,商品介紹等基礎資料,對業務產生不了大影響,允許快取的不一致,若想要考慮可以使用:cananl訂閱的方式
  • 快取資料+過期時間:可以保證大部分的需求
  • 通過加鎖併發讀寫,適合於寫少讀多的特點

總結:

  • 我們放入快取的資料不應該是實時性、一致性要求超高的資料
  • 不應該過度設計,增加系統的複雜性
  • 遇到實時性要求高的資料,我們應該查資料庫,即使慢一些

本人只做彙總,以上所有來自各個大佬們