大數據技術棧速覽之:Redis

Redis是幹嘛用的?

Redis可以用作持久化的存儲嗎?

那Redis怎麼進行持久化操作呢?

Redis的內存淘汰機制有哪些?

除了分佈式鎖、消息隊列...我們還可以用Redis做哪些事情?分別利用了Redis的哪個指令?

-----------------------------------

Redis閒談:構建知識圖譜

REmote DIctionary Server(Redis) 是一個由Salvatore Sanfilippo寫的key-value存儲系統。

Redis是一個開源的使用ANSI 、C語言編寫、遵守BSD協議、支持網絡、可基於內存亦可持久化的日誌型、Key-Value數據庫,並提供多種語言的API。

Redis優點

速度快

Redis單機qps(每秒的併發)可以達到110000次/s,寫的速度是81000次/s。那麼,Redis爲什麼這麼快呢?

  • 絕大部分請求是純粹的內存操作,非常快速;

  • 使用了很多查找操作都特別快的數據結構進行數據存儲,Redis中的數據結構是專門設計的。如HashMap,查找、插入的時間複雜度都是O(1);

  • 採用單線程,避免了不必要的上下文切換和競爭條件,也不存在多進程或者多線程導致的切換而消耗CPU,不用去考慮各種鎖的問題,不存在加鎖、釋放鎖操作,沒有因爲可能出現死鎖而導致的性能消耗;

  • 用到了非阻塞I/O多路複用機制。

豐富的數據類型

Redis有5種常用的數據類型:String、List、Hash、set、zset,每種數據類型都有自己的用處。

原子性,支持事務

Redis支持事務,並且它的所有操作都是原子性的,同時Redis還支持對幾個操作合併後的原子性執行。

豐富的特性

Redis具有豐富的特性,比如可以用作分佈式鎖;可以持久化數據;可以用作消息隊列、排行榜、計數器;還支持publish/subscribe、通知、key過期等等。當我們要用中間件來解決實際問題的時候,Redis總能發揮出自己的用處。

Redis和Memcache對比

1) 存儲方式

  • Memcache把數據全部存在內存之中,斷電後會掛掉,無法做到數據的持久化,且數據不能超過內存大小。

  • Redis有一部分數據存在硬盤上,可以做到數據的持久性。

2) 數據支持類型

  • Memcache對數據類型支持相對簡單,只支持String類型的數據結構。

  • Redis有豐富的數據類型,包括:String、List、Hash、Set、Zset。

3) 使用的底層模型

  • 它們之間底層實現方式以及與客戶端之間通信的應用協議不一樣。

  • Redis直接自己構建了VM機制 ,因爲一般的系統調用系統函數,會浪費一定的時間去移動和請求。

4) 存儲值大小

  • Redis最大可以存儲1GB,而memcache只有1MB。

Redis存在的問題及解決方案

緩存數據庫的雙寫一致性的問題

問題:一致性的問題是分佈式系統中很常見的問題。一致性一般分爲兩種:強一致性和最終一致性,當我們要滿足強一致性的時候,Redis也無法做到完美無瑕,因爲數據庫和緩存雙寫,肯定會出現不一致的情況,Redis只能保證最終一致性。

解決:我們如何保證最終一致性呢?

  • 第一種方式是給緩存設置一定的過期時間,在緩存過期之後會自動查詢數據庫,保證數據庫和緩存的一致性。

  • 如果不設置過期時間的話,我們首先要選取正確的更新策略:先更新數據庫再刪除緩存。但我們刪除緩存的時候也可能出現某些問題,所以需要將要刪除的緩存的key放到消息隊列中去,不斷重試,直到刪除成功爲止。

緩存雪崩問題

問題: 我們應該都在電影裏看到過雪崩,開始很平靜,然後一瞬間就開始崩塌,具有很強的毀滅性。這裏也是一樣的,我們執行代碼的時候將很多緩存的實效時間設定成一樣,接着這些緩存在同一時間都會實效,然後都會重新訪問數據庫更新數據,這樣會導致數據庫連接數過多、壓力過大而崩潰。

解決:

  • 設置緩存過期時間的時候加一個隨機值。

  • 設置雙緩存,緩存1設置緩存時間,緩存2不設置,1過期後直接返回緩存2,並且啓動一個進程去更新緩存1和2。

緩存穿透問題

問題: 緩存穿透是指一些非正常用戶(黑客)故意去請求緩存中不存在的數據,導致所有的請求都集中到到數據庫上,從而導致數據庫連接異常。

解決:

  • 利用互斥鎖。緩存失效的時候,不能直接訪問數據庫,而是要先獲取到鎖,才能去請求數據庫。沒得到鎖,則休眠一段時間後重試。

  • 採用異步更新策略。無論key是否取到值,都直接返回。value值中維護一個緩存失效時間,緩存如果過期,異步起一個線程去讀數據庫,更新緩存。需要做緩存預熱(項目啓動前,先加載緩存)操作。

  • 提供一個能迅速判斷請求是否有效的攔截機制。比如利用布隆過濾器,內部維護一系列合法有效的key,迅速判斷出請求所攜帶的Key是否合法有效。如果不合法,則直接返回。

緩存的併發競爭問題

問題:

緩存併發競爭的問題,主要發生在多線程對某個key進行set的時候,這時會出現數據不一致的情況。

比如Redis中我們存着一個key爲amount的值,它的value是100,兩個線程同時都對value加100然後更新,正確的結果應該是變爲300。但是兩個線程拿到這個值的時候都是100,最後結果也就是200,這就導致了緩存的併發競爭問題。

解決

  • 如果多線程操作沒有順序要求的話,我們可以設置一個分佈式鎖,然後多個線程去爭奪鎖,誰先搶到鎖誰就可以先執行。這個分佈式鎖可以用zookeeper或者Redis本身去實現。

  • 可以利用Redis的incr命令。

  • 當我們的多線程操作需要順序的時候,我們可以設置一個消息隊列,把需要的操作加到消息隊列中去,嚴格按照隊列的先後執行命令。

Redis的過期策略

Redis隨着數據的增多,內存佔用率會持續變高,我們以爲一些鍵到達設置的刪除時間就會被刪除,但是時間到了,內存的佔用率還是很高,這是爲什麼呢?

Redis採用的是定期刪除惰性刪除的內存淘汰機制。

定期刪除

定期刪除和定時刪除是有區別的:

  • 定時刪除是必須嚴格按照設定的時間去刪除緩存,這就需要我們設置一個定時器去不斷地輪詢所有的key,判斷是否需要進行刪除。但是這樣的話cpu的資源會被大幅度地佔據,資源的利用率變低。所以我們選擇採用定期刪除,。

  • 定期刪除是時間由我們定,我們可以每隔100ms進行檢查,但還是不能檢查所有的緩存,Redis還是會卡死,只能隨機地去檢查一部分緩存,但是這樣會有一些緩存無法在規定時間內刪除。這時惰性刪除就派上用場了。

惰性刪除

舉個簡單的例子:中學的時候,平時作業太多,根本做不完,老師說下節課要講這個卷子,你們都做完了吧?其實有很多人沒做完,所以需要在下節課之前趕緊補上。

惰性刪除也是這個道理,我們的這個值按理說應該沒了,但是它還在,當你要獲取這個key的時候,發現這個key應該過期了,趕緊刪了,然後返回一個'沒有這個值,已經過期了!'。

現在我們有了定期刪除 + 惰性刪除的過期策略,就可以高枕無憂了嗎?並不是這樣的,如果這個key一直不訪問,那麼它會一直滯留,也是不合理的,這就需要我們的內存淘汰機制了。

 

Redis閒談:Redis數據結構底層探祕

Redis的數據模型

用 鍵值對 name:"小明"來展示Redis的數據模型如下:

  • dictEntry: 在一些編程語言中,鍵值對的數據結構被稱爲字典,而在Redis中,會給每一個key-value鍵值對分配一個字典實體,就是“dicEntry”。dicEntry包含三部分: key的指針、val的指針、next指針,next指針指向下一個dicteEntry形成鏈表,這個next指針可以將多個哈希值相同的鍵值對鏈接在一起,通過鏈地址法來解決哈希衝突的問題

  • sds :Simple Dynamic String,簡單動態字符串,存儲字符串數據。

  • redisObject:Redis的5種常用類型都是以RedisObject來存儲的,redisObject中的type字段指明瞭值的數據類型(也就是5種基本類型)。ptr字段指向對象所在的地址。

RedisObject對象很重要,Redis對象的類型內部編碼內存回收共享對象等功能,都是基於RedisObject對象來實現的。

這樣設計的好處是:可以針對不同的使用場景,對5種常用類型設置多種不同的數據結構實現,從而優化對象在不同場景下的使用效率。

Redis將jemalloc作爲默認內存分配器,減小內存碎片。jemalloc在64位系統中,將內存空間劃分爲小、大、巨大三個範圍;每個範圍內又劃分了許多小的內存塊單位;當Redis存儲數據時,會選擇大小最合適的內存塊進行存儲。

Redis支持的數據結構

Redis支持的數據結構有哪些?

如果回答是String、List、Hash、Set、Zset就不對了,這5種是Redis的常用基本數據類型,每一種數據類型內部還包含着多種數據結構。

用encoding指令來看一個值的數據結構。比如:

127.0.0.1:6379> set name tom
OK
127.0.0.1:6379> object encoding name
"embstr"

此處設置了name值是tom,它的數據結構是embstr,下文介紹字符串時會詳解說明。

127.0.0.1:6379> set age 18
OK
127.0.0.1:6379> object encoding age
"int"

如下表格總結Redis中所有的數據結構類型:

底層數據結構 編碼常量 object encoding指令輸出
整數類型 REDIS_ENCODING_INT "int"
embstr字符串類型 REDIS_ENCODING_EMBSTR "embstr"
簡單動態字符串 REDIS_ENCODING_RAW "raw"
字典類型 REDIS_ENCODING_HT "hashtable"
雙端鏈表 REDIS_ENCODING_LINKEDLIST "linkedlist"
壓縮列表 REDIS_ENCODING_ZIPLIST "ziplist"
整數集合 REDIS_ENCODING_INTSET "intset"
跳錶和字典 REDIS_ENCODING_SKIPLIST "skiplist"

補充說明

假如面試官問:Redis的數據類型有哪些?

回答:String、list、hash、set、zet

一般情況下這樣回答是正確的,前文也提到Redis的數據類型確實是包含這5種,但細心的同學肯定發現了之前說的是“常用”的5種數據類型。其實,隨着Redis的不斷更新和完善,Redis的數據類型早已不止5種了。

登錄Redis的官方網站打開官方的數據類型介紹(https://redis.io/topics/data-types-intro):

發現Redis支持的數據結構不止5種,而是8種,後三種類型分別是:

  • 位數組(或簡稱位圖):使用特殊命令可以處理字符串值,如位數組:您可以設置和清除各個位,將所有位設置爲1,查找第一個位或未設置位,等等。

  • HyperLogLogs:這是一個概率數據結構,用於估計集合的基數。不要害怕,它比看起來更簡單。

  • Streams:僅附加的類似於地圖的條目集合,提供抽象日誌數據類型。

本文主要介紹5種常用的數據類型,上述三種以後再共同探索。

2.1 string字符串

字符串類型是Redis最常用的數據類型,在Redis中,字符串是可以修改的,在底層它是以字節數組的形式存在的。

Redis中的字符串被稱爲簡單動態字符串「SDS」,這種結構很像Java中的ArrayList,其長度是動態可變的。

struct SDS<T> {
  T capacity; // 數組容量
  T len; // 數組長度
  byte[] content; // 數組內容
}

content[] 存儲的是字符串的內容,capacity表示數組分配的長度,len表示字符串的實際長度。

字符串的編碼類型有int、embstr和raw三種,如上表所示,那麼這三種編碼類型有什麼不同呢?

  • int 編碼:保存的是可以用 long 類型表示的整數值。

  • raw 編碼:保存長度大於44字節的字符串(redis3.2版本之前是39字節,之後是44字節)。

  • embstr 編碼:保存長度小於44字節的字符串(redis3.2版本之前是39字節,之後是44字節)。

設置一個值測試一下:

127.0.0.1:6379> set num 300
127.0.0.1:6379> object encoding num
"int"
127.0.0.1:6379> set key1 wealwaysbyhappyhahaha
OK
127.0.0.1:6379> object encoding key1
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahaha
OK
127.0.0.1:6379> strlen key2
(integer) 39
127.0.0.1:6379> object encoding key2
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahahahahaha
OK
127.0.0.1:6379> object encoding key2
"raw"
127.0.0.1:6379> strlen key2
(integer) 45

raw類型和embstr類型對比

embstr編碼的結構:

raw編碼的結構:

embstr和raw都是由redisObject和sds組成的。不同的是:embstr的redisObject和sds是連續的,只需要使用malloc分配一次內存;而raw需要爲redisObject和sds分別分配內存,即需要分配兩次內存。

所有相比較而言,embstr少分配一次內存,更方便。但embstr也有明顯的缺點:如要增加長度,redisObject和sds都需要重新分配內存。

上文介紹了embstr和raw結構上的不同。重點來了~ 爲什麼會選擇44作爲兩種編碼的分界點?在3.2版本之前爲什麼是39?這兩個值是怎麼得出來的呢?

1) 計算RedisObject佔用的字節大小

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes = 32bits
    void *ptr; // 8bytes,64-bit system
}
  • type: 不同的redis對象會有不同的數據類型(string、list、hash等),type記錄類型,會用到4bits

  • encoding:存儲編碼形式,用4bits

  • lru:用24bits記錄對象的LRU信息。

  • refcount:引用計數器,用到32bits

  • *ptr:指針指向對象的具體內容,需要64bits

計算: 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes

第一步就完成了,RedisObject對象頭信息會佔用16字節的大小,這個大小通常是固定不變的.

2) sds佔用字節大小計算

舊版本:

struct SDS {
    unsigned int capacity; // 4byte
    unsigned int len; // 4byte
    byte[] content; // 內聯數組,長度爲 capacity
}

這裏的unsigned int 一個4字節,加起來是8字節.

內存分配器jemalloc分配的內存如果超出了64個字節就認爲是一個大字符串,就會用到embstr編碼。

前面提到 SDS 結構體中的 content 的字符串是以字節\0結尾的字符串,之所以多出這樣一個字節,是爲了便於直接使用 glibc 的字符串處理函數,以及爲了便於字符串的調試打印輸出。所以我們還要減去1字節 64byte - 16byte - 8byte - 1byte = 39byte

新版本:

struct SDS {
    int8 capacity; // 1byte
    int8 len; // 1byte
    int8 flags; // 1byte
    byte[] content; // 內聯數組,長度爲 capacity
}

這裏unsigned int 變成了uint8_t、uint16_t.的形式,還加了一個char flags標識,總共只用了3個字節的大小。相當於優化了sds的內存使用,相應的用於存儲字符串的內存就會變大。

然後進行計算:

64byte - 16byte -3byte -1byte = 44byte

總結:

所以,Redis 3.2版本之後embstr最大能容納的字符串長度是44,之前是39。長度變化的原因是SDS中內存的優化。

2.2 List

Redis中List對象的底層是由quicklist(快速列表)實現的,快速列表支持從鏈表頭和尾添加元素,並且可以獲取指定位置的元素內容。

那麼,快速列表的底層是如何實現的呢?爲什麼能夠達到如此快的性能?

羅馬不是一日建成的,quicklist也不是一日實現的,起初redis的list的底層是ziplist(壓縮列表)或者是 linkedlist(雙端列表)。先分別介紹這兩種數據結構。

ziplist 壓縮列表

當一個列表中只包含少量列表項,且是小整數值或長度比較短的字符串時,redis就使用ziplist(壓縮列表)來做列表鍵的底層實現。

測試:

127.0.0.1:6379> rpush dotahero sf qop doom
(integer) 3
127.0.0.1:6379> object encoding dotahero
"ziplist"

此處使用老版本Redis進行測試,向dota英雄列表中加入了qop痛苦女王、sf影魔、doom末日使者三個英雄,數據結構編碼使用的是ziplist。

壓縮列表顧名思義是進行了壓縮,每一個節點之間沒有指針的指向,而是多個元素相鄰,沒有縫隙。所以 ziplist是Redis爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型數據結構。具體結構相對比較複雜,大家有興趣地話可以深入瞭解。

struct ziplist<T> {
    int32 zlbytes; // 整個壓縮列表佔用字節數
    int32 zltail_offset; // 最後一個元素距離壓縮列表起始位置的偏移量,用於快速定位到最後一個節點
    int16 zllength; // 元素個數
    T[] entries; // 元素內容列表,挨個挨個緊湊存儲
    int8 zlend; // 標誌壓縮列表的結束,值恆爲 0xFF
}

雙端列表(linkedlist)

雙端列表大家都很熟悉,這裏的雙端列表和java中的linkedlist很類似。

從圖中可以看出Redis的linkedlist雙端鏈表有以下特性:節點帶有prev、next指針、head指針和tail指針,獲取前置節點、後置節點、表頭節點和表尾節點、獲取長度的複雜度都是O(1)。

壓縮列表佔用內存少,但是是順序型的數據結構,插入刪除元素的操作比較複雜,所以壓縮列表適合數據比較小的情況,當數據比較多的時候,雙端列表的高效插入刪除還是更好的選擇

在Redis開發者的眼中,數據結構的選擇,時間上、空間上都要達到極致,所以,他們將壓縮列表和雙端列表合二爲一,創建了快速列表(quicklist)。和java中的hashmap一樣,結合了數組和鏈表的優點。

快速列表(quicklist)

  • rpush: listAddNodeHead ---O(1)

  • lpush: listAddNodeTail ---O(1)

  • push:listInsertNode ---O(1)

  • index : listIndex ---O(N)

  • pop:ListFirst/listLast ---O(1)

  • llen:listLength ---O(N)

struct ziplist {
    ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向壓縮列表
    int32 size; // ziplist 的字節總數
    int16 count; // ziplist 中的元素數量
    int2 encoding; // 存儲形式 2bit,原生字節數組還是 LZF 壓縮存儲
    ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素總數
    int nodes; // ziplist 節點的個數
    int compressDepth; // LZF 算法壓縮深度
    ...
}

quicklist 默認的壓縮深度是 0,也就是不壓縮。壓縮的實際深度由配置參數list-compress-depth決定。爲了支持快速的 push/pop 操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。如果深度爲 2,表示 quicklist 的首尾第一個 ziplist 以及首尾第二個 ziplist 都不壓縮。

2.3 Hash

Hash數據類型的底層實現是ziplist(壓縮列表)或字典(也稱爲hashtable或散列表)。這裏壓縮列表或者字典的選擇,也是根據元素的數量大小決定的。

如圖hset了三個鍵值對,每個值的字節數不超過64的時候,默認使用的數據結構是ziplist

當我們加入了字節數超過64的值的數據時,默認的數據結構已經成爲了hashtable。

Hash對象只有同時滿足下面兩個條件時,纔會使用ziplist(壓縮列表):

  • 哈希中元素數量小於512個;

  • 哈希中所有鍵值對的鍵和值字符串長度都小於64字節。

壓縮列表剛纔已經瞭解了,hashtables類似於jdk1.7以前的hashmap。hashmap採用了鏈地址法的方法解決了哈希衝突的問題。想要深入瞭解的話可以參考之前寫的一篇博客: hashmap你真的瞭解嗎?(https://blog.csdn.net/qq_32519415/article/details/87006982)

Redis中的字典

Redis中的dict 結構內部包含兩個 hashtable,通常情況下只有一個 hashtable 是有值的。但是在 dict 擴容縮容時,需要分配新的 hashtable,然後進行漸進式搬遷,這時兩個 hashtable 存儲的分別是舊的 hashtable 和新的 hashtable。待搬遷結束後,舊的 hashtable 被刪除,新的 hashtable 取而代之。

2.4 Set

Set數據類型的底層可以是intset(整數集)或者是hashtable(散列表也叫哈希表)。

當數據都是整數並且數量不多時,使用intset作爲底層數據結構;當有除整數以外的數據或者數據量增多時,使用hashtable作爲底層數據結構。

127.0.0.1:6379> sadd myset 111 222 333
(integer) 3
127.0.0.1:6379> object encoding myset
"intset"
127.0.0.1:6379> sadd myset hahaha
(integer) 1
127.0.0.1:6379> object encoding myset
"hashtable"

inset的數據結構爲:

typedef struct intset {
    // 編碼方式
    uint32_t encoding;
    // 集合包含的元素數量
    uint32_t length;
    // 保存元素的數組
    int8_t contents[];
} intset;

intset底層實現爲有序、無重複數的數組。 intset的整數類型可以是16位的、32位的、64位的。如果數組裏所有的整數都是16位長度的,新加入一個32位的整數,那麼整個16的數組將升級成一個32位的數組。升級可以提升intset的靈活性,又可以節約內存,但不可逆。

2.5 Zset

Redis中的Zset,也叫做有序集合。它的底層是ziplist(壓縮列表)或 skiplist(跳躍表)。

壓縮列表前文已經介紹過了,同理是在元素數量比較少的時候使用。此處主要介紹跳躍列表。

跳錶

跳躍列表,顧名思義是可以跳的,跳着查詢自己想要查到的元素。大家可能對這種數據結構比較陌生,雖然平時接觸的少,但它確實是一個各方面性能都很好的數據結構,可以支持快速的查詢、插入、刪除操作,開發難度也比紅黑樹要容易的多

爲什麼跳錶有如此高的性能呢?它究竟是如何“跳”的呢?跳錶利用了二分的思想,在數組中可以用二分法來快速進行查找,在鏈表中也是可以的。

舉個例子,鏈表如下:

假設要找到10這個節點,需要一個一個去遍歷,判斷是不是要找的節點。那如何提高效率呢?mysql索引相信大家都很熟悉,可以提高效率,這裏也可以使用索引。抽出一個索引層來:

這樣只需要找到9然後再找10就可以了,大大節省了查找的時間。

還可以再抽出來一層索引,可以更好地節約時間:

這樣基於鏈表的“二分查找”支持快速的插入、刪除,時間複雜度都是O(logn)。

由於跳錶的快速查找效率,以及實現的簡單、易讀。所以Redis放棄了紅黑樹而選擇了更爲簡單的跳錶。

Redis中的跳躍表:

typedef struct zskiplist {
     // 表頭節點和表尾節點
    struct zskiplistNode *header, *tail;
    // 表中節點的數量
    unsigned long length;
    // 表中層數最大的節點的層數
    int level;
 } zskiplist;
typedef struct zskiplistNode {
    // 成員對象
    robj *obj;
    // 分值
    double score;
     // 後退指針
    struct zskiplistNode *backward;
    // 層
    struct zskiplistLevel {
        // 前進指針
        struct zskiplistNode *forward;
         // 跨度---前進指針所指向節點與當前節點的距離
        unsigned int span;
    } level[];
} zskiplistNode;

zadd---zslinsert---平均O(logN), 最壞O(N)

zrem---zsldelete---平均O(logN), 最壞O(N)

zrank--zslGetRank---平均O(logN), 最壞O(N)

總結

以上大概介紹了Redis的5種常用數據類型的底層實現,希望大家結合源碼和資料更深入地瞭解。

數據結構之美在Redis中體現得淋漓盡致,從String到壓縮列表、快速列表、散列表、跳錶,這些數據結構都適用在了不同的地方,各司其職。

不僅如此,Redis將這些數據結構加以升級、結合,將內存存儲的效率性能達到了極致,正因爲如此,Redis才能成爲衆多互聯網公司不可缺少的高性能、秒級的key-value內存數據庫。

Redis閒談:鎖的基本概念到Redis分佈式鎖實現

鎖的基本瞭解

怎麼解決呢?加個synchronized關鍵字。

解決了線程安全的問題,還要考慮到加鎖之後的代碼執行效率問題:縮短鎖的持有時間;減少鎖的粒度;鎖分離(即:讀寫分離,把鎖分成讀鎖和寫鎖,讀的鎖不需要阻塞,而寫的鎖要考慮併發問題。)

鎖的種類

  • 公平鎖:ReentrantLock

  • 非公平鎖:Synchronized、ReentrantLock、cas

  • 悲觀鎖:Synchronized

  • 樂觀鎖:cas

  • 獨享鎖:Synchronized、ReentrantLock

  • 共享鎖:Semaphore

另外,鎖還可以按照偏向鎖、輕量級鎖、重量級鎖來分類。

Redis分佈式鎖

分佈式鎖的概念。

上圖所示是我們搭建的分佈式環境,有三個購票項目,對應一個庫存,每一個系統會有多個線程,和上文一樣,對庫存的修改操作加上鎖,能不能保證這6個線程的線程安全呢?

當然是不能的,因爲每一個購票系統都有各自的JVM進程,互相獨立,所以加synchronized只能保證一個系統的線程安全,並不能保證分佈式的線程安全。

所以需要對於三個系統都是公共的一箇中間件來解決這個問題。

這裏我們選擇Redis來作爲分佈式鎖,多個系統在Redis中set同一個key,只有key不存在的時候,才能設置成功,並且該key會對應其中一個系統的唯一標識,當該系統訪問資源結束後,將key刪除,則達到了釋放鎖的目的。

4.1 分佈式鎖需要注意哪些點

1)互斥性

在任意時刻只有一個客戶端可以獲取鎖。

這個很容易理解,所有的系統中只能有一個系統持有鎖。

2)防死鎖

假如一個客戶端在持有鎖的時候崩潰了,沒有釋放鎖,那麼別的客戶端無法獲得鎖,則會造成死鎖,所以要保證客戶端一定會釋放鎖。

Redis中我們可以設置鎖的過期時間來保證不會發生死鎖。

3)持鎖人解鎖

解鈴還須繫鈴人,加鎖和解鎖必須是同一個客戶端,客戶端A的線程加的鎖必須是客戶端A的線程來解鎖,客戶端不能解開別的客戶端的鎖。

4)可重入

當一個客戶端獲取對象鎖之後,這個客戶端可以再次獲取這個對象上的鎖。

4.2 Redis分佈式鎖流程

Redis分佈式鎖的具體流程:

1)首先利用Redis緩存的性質在Redis中設置一個key-value形式的鍵值對,key就是鎖的名稱,然後客戶端的多個線程去競爭鎖,競爭成功的話將value設爲客戶端的唯一標識。

2)競爭到鎖的客戶端要做兩件事:

  • 設置鎖的有效時間 目的是防死鎖 (非常關鍵)

需要根據業務需要,不斷的壓力測試來決定有效期的長短。

  • 分配客戶端的唯一標識,目的是保證持鎖人解鎖(非常重要)

所以這裏的value就設置成唯一標識(比如uuid)。

3)訪問共享資源

4)釋放鎖,釋放鎖有兩種方式,第一種是有效期結束後自動釋放鎖,第二種是先根據唯一標識判斷自己是否有釋放鎖的權限,如果標識正確則釋放鎖

4.3 加鎖和解鎖

4.3.1 加鎖

1)setnx命令加鎖

set if not exists 我們會用到Redis的命令setnx,setnx的含義就是隻有鎖不存在的情況下才會設置成功。

2)設置鎖的有效時間,防止死鎖 expire

加鎖需要兩步操作,思考一下會有什麼問題嗎?

假如我們加鎖完之後客戶端突然掛了呢?那麼這個鎖就會成爲一個沒有有效期的鎖,接着就可能發生死鎖。雖然這種情況發生的概率很小,但是一旦出現問題會很嚴重,所以我們也要把這兩步合爲一步。

幸運的是,Redis3.0已經把這兩個指令合在一起成爲一個新的指令。

來看jedis的官方文檔中的源碼:

    public String set(String key, String value, String nxxx, String expx, long time) {
        this.checkIsInMultiOrPipeline();
        this.client.set(key, value, nxxx, expx, time);
        return this.client.getStatusCodeReply();
    }

這就是我們想要的!

4.3.2 解鎖

  • 檢查是否自己持有鎖(判斷唯一標識);

  • 刪除鎖。

解鎖也是兩步,同樣也要保證解鎖的原子性,把兩步合爲一步。

這就無法藉助於Redis了,只能依靠Lua腳本來實現。

if Redis.call("get",key==argv[1])then
    return Redis.call("del",key)
else return 0 end

這就是一段判斷是否自己持有鎖並釋放鎖的Lua腳本。

爲什麼Lua腳本是原子性呢?因爲Lua腳本是jedis用eval()函數執行的,如果執行則會全部執行完成。

Redis分佈式鎖代碼實現

public class RedisDistributedLock implements Lock {

    //上下文,保存當前鎖的持有人id
    private ThreadLocal<String> lockContext = new ThreadLocal<String>();

    //默認鎖的超時時間
    private long time = 100;

    //可重入性
    private Thread ownerThread;

    public RedisDistributedLock() {
    }

    public void lock() {
        while (!tryLock()){
            try {
                Thread.sleep(100);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    }


    public boolean tryLock() {
        return tryLock(time,TimeUnit.MILLISECONDS);
    }


    public boolean tryLock(long time, TimeUnit unit){
        String id = UUID.randomUUID().toString(); //每一個鎖的持有人都分配一個唯一的id
        Thread t = Thread.currentThread();
        Jedis jedis = new Jedis("127.0.0.1",6379);
        //只有鎖不存在的時候加鎖並設置鎖的有效時間
        if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
            //持有鎖的人的id
            lockContext.set(id); ①
            //記錄當前的線程
            setOwnerThread(t); ②
            return true;
        }else if(ownerThread == t){
            //因爲鎖是可重入的,所以需要判斷當前線程已經持有鎖的情況
            return true;
        }else {
            return false;
        }
    }

    private void setOwnerThread(Thread t){
        this.ownerThread = t;
    }

    public void unlock() {
        String script = null;
        try{
            Jedis jedis = new Jedis("127.0.0.1",6379);
            script = inputStream2String(getClass().getResourceAsStream("/Redis.Lua"));
            if(lockContext.get()==null){
                //沒有人持有鎖
                return;
            }
            //刪除鎖  ③
            jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get()));
            lockContext.remove();
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * 將InputStream轉化成String
     * @param is
     * @return
     * @throws IOException
     */
    public String inputStream2String(InputStream is) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int i = -1;
        while ((i = is.read()) != -1) {
            baos.write(i);
        }
        return baos.toString();
    }

    public void lockInterruptibly() throws InterruptedException {

    }

    public Condition newCondition() {
        return null;
    }
}
  • 用一個上下文全局變量來記錄持有鎖的人的uuid,解鎖的時候需要將該uuid作爲參數傳入Lua腳本中,來判斷是否可以解鎖。

  • 要記錄當前線程,來實現分佈式鎖的重入性,如果是當前線程持有鎖的話,也屬於加鎖成功。

  • 用eval函數來執行Lua腳本,保證解鎖時的原子性。

 

分佈式鎖的對比

6.1 基於數據庫的分佈式鎖

1)實現方式

獲取鎖的時候插入一條數據,解鎖時刪除數據。

2)缺點

  • 數據庫如果掛掉會導致業務系統不可用。

  • 無法設置過期時間,會造成死鎖。

6.2 基於zookeeper的分佈式鎖

1)實現方式

加鎖時在指定節點的目錄下創建一個新節點,釋放鎖的時候刪除這個臨時節點。因爲有心跳檢測的存在,所以不會發生死鎖,更加安全

2)缺點

性能一般,沒有Redis高效。

所以:

  • 從性能角度: Redis > zookeeper > 數據庫 

  • 從可靠性(安全)性角度: zookeeper > Redis > 數據庫

 總結

多線程訪問共享資源會出現線程安全問題。通過加鎖的方式去解決線程安全的問題,性能會下降,需要通過:縮短鎖的持有時間、減小鎖的粒度、鎖分離三種方式去優化鎖。

分佈式鎖的4個特點:

  • 互斥性 

  • 防死鎖 

  • 加鎖人解鎖 

  • 可重入性

用Redis實現了分佈式鎖,加鎖的時候用到了Redis的命令去加鎖,解鎖的時候則藉助了Lua腳本來保證原子性。

發佈了41 篇原創文章 · 獲贊 5 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章