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腳本來保證原子性。