您好,登錄后才能下訂單哦!
拓展閱讀:Redis閑談(1):構建知識圖譜
Redis專題(2):Redis數據結構底層探秘
近來,分布式的問題被廣泛提及,比如分布式事務、分布式框架、ZooKeeper、SpringCloud等等。本文先回顧鎖的概念,再介紹分布式鎖,以及如何用Redis來實現分布式鎖。
首先,回顧一下我們工作學習中的鎖的概念。
為什么要先講鎖再講分布式鎖呢?
我們都清楚,鎖的作用是要解決多線程對共享資源的訪問而產生的線程安全問題,而在平時生活中用到鎖的情況其實并不多,可能有些朋友對鎖的概念和一些基本的使用不是很清楚,所以我們先看鎖,再深入介紹分布式鎖。
通過一個賣票的小案例來看,比如大家去搶dota2 ti9門票,如果不加鎖的話會出現什么問題?此時代碼如下:
package Thread;
import java.util.concurrent.TimeUnit;
public class Ticket {
/**
* 初始庫存量
* */
Integer ticketNum = 8;
public void reduce(int num){
//判斷庫存是否夠用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
System.out.println(Thread.currentThread().getName() + "成功賣出"
+ num + "張,剩余" + ticketNum + "張票");
}else {
System.err.println(Thread.currentThread().getName() + "沒有賣出"
+ num + "張,剩余" + ticketNum + "張票");
}
}
public static void main(String[] args) throws InterruptedException{
Ticket ticket = new Ticket();
//開啟10個線程進行搶票,按理說應該有兩個人搶不到票
for(int i=0;i<10;i++){
new Thread(() -> ticket.reduce(1),"用戶" + (i + 1)).start();
}
Thread.sleep(1000L);
}
}
代碼分析:這里有8張ti9門票,設置了10個線程(也就是模擬10個人)去并發搶票,如果搶成功了顯示成功,搶失敗的話顯示失敗。按理說應該有8個人搶成功了,2個人搶失敗,下面來看運行結果:
我們發現運行結果和預期的情況不一致,居然10個人都買到了票,也就是說出現了線程安全的問題,那么是什么原因導致的呢?
原因就是多個線程之間產生了時間差。
如圖所示,只剩一張票了,但是兩個線程都讀到的票余量是1,也就是說線程B還沒有等到線程A改庫存就已經搶票成功了。
怎么解決呢?想必大家都知道,加個synchronized關鍵字就可以了,在一個線程進行reduce方法的時候,其他線程則阻塞在等待隊列中,這樣就不會發生多個線程對共享變量的競爭問題。
舉個例子
比如我們去健身房健身,如果好多人同時用一臺機器,同時在一臺跑步機上跑步,就會發生很大的問題,大家會打得不可開交。如果我們加一把鎖在健身房門口,只有拿到鎖的鑰匙的人才可以進去鍛煉,其他人在門外等候,這樣就可以避免大家對健身器材的競爭。代碼如下:
public synchronized void reduce(int num){
//判斷庫存是否夠用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
System.out.println(Thread.currentThread().getName() + "成功賣出"
+ num + "張,剩余" + ticketNum + "張票");
}else {
System.err.println(Thread.currentThread().getName() + "沒有賣出"
+ num + "張,剩余" + ticketNum + "張票");
}
}
運行結果:
果不其然,結果有兩個人沒有成功搶到票,看來我們的目地達成了。
事實上,按照我們對日常生活的理解,不可能整個健身房只有一個人在運動。所以我們只需要對某一臺機器加鎖就可以了,比如一個人在跑步,另一個人可以去做其他的運動。
對于票務系統來說,我們只需要對庫存的修改操作的代碼加鎖就可以了,別的代碼還是可以并行進行,這樣會大大減少鎖的持有時間,代碼修改如下:
public void reduceByLock(int num){
boolean flag = false;
synchronized (ticketNum){
if((ticketNum - num) >= 0){
ticketNum -= num;
flag = true;
}
}
if(flag){
System.out.println(Thread.currentThread().getName() + "成功賣出"
+ num + "張,剩余" + ticketNum + "張票");
}
else {
System.err.println(Thread.currentThread().getName() + "沒有賣出"
+ num + "張,剩余" + ticketNum + "張票");
}
if(ticketNum == 0){
System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒");
}
}
這樣做的目的是充分利用cpu的資源,提高代碼的執行效率。
這里我們對兩種方式的時間做個打印:
public synchronized void reduce(int num){
//判斷庫存是否夠用
if((ticketNum - num) >= 0){
try {
TimeUnit.MILLISECONDS.sleep(200);
}catch (InterruptedException e){
e.printStackTrace();
}
ticketNum -= num;
if(ticketNum == 0){
System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒");
}
System.out.println(Thread.currentThread().getName() + "成功賣出"
+ num + "張,剩余" + ticketNum + "張票");
}else {
System.err.println(Thread.currentThread().getName() + "沒有賣出"
+ num + "張,剩余" + ticketNum + "張票");
}
}
果然,只對部分代碼加鎖會大大提供代碼的執行效率。
所以,在解決了線程安全的問題后,我們還要考慮到加鎖之后的代碼執行效率問題。
舉個例子,有兩場電影,分別是最近剛上映的魔童哪吒和蜘蛛俠,我們模擬一個支付購買的過程,讓方法等待,加了一個CountDownLatch的await方法,運行結果如下:
package Thread;
import java.util.concurrent.CountDownLatch;
public class Movie {
private final CountDownLatch latch = new CountDownLatch(1);
//魔童哪吒
private Integer babyTickets = 20;
//蜘蛛俠
private Integer spiderTickets = 100;
public synchronized void showBabyTickets() throws InterruptedException{
System.out.println("魔童哪吒的剩余票數為:" + babyTickets);
//購買
latch.await();
}
public synchronized void showSpiderTickets() throws InterruptedException{
System.out.println("蜘蛛俠的剩余票數為:" + spiderTickets);
//購買
}
public static void main(String[] args) {
Movie movie = new Movie();
new Thread(() -> {
try {
movie.showBabyTickets();
}catch (InterruptedException e){
e.printStackTrace();
}
},"用戶A").start();
new Thread(() -> {
try {
movie.showSpiderTickets();
}catch (InterruptedException e){
e.printStackTrace();
}
},"用戶B").start();
}
}
執行結果:
魔童哪吒的剩余票數為:20
我們發現買哪吒票的時候阻塞會影響蜘蛛俠票的購買,而實際上,這兩場電影之間是相互獨立的,所以我們需要減少鎖的粒度,將movie整個對象的鎖變為兩個全局變量的鎖,修改代碼如下:
public void showBabyTickets() throws InterruptedException{
synchronized (babyTickets) {
System.out.println("魔童哪吒的剩余票數為:" + babyTickets);
//購買
latch.await();
}
}
public void showSpiderTickets() throws InterruptedException{
synchronized (spiderTickets) {
System.out.println("蜘蛛俠的剩余票數為:" + spiderTickets);
//購買
}
}
執行結果:
魔童哪吒的剩余票數為:20
蜘蛛俠的剩余票數為:100
現在兩場電影的購票不會互相影響了,這就是第二個優化鎖的方式:減少鎖的粒度。順便提一句,Java并發包里的ConcurrentHashMap就是把一把大鎖變成了16把小鎖,通過分段鎖的方式達到高效的并發安全。
鎖分離就是常說的讀寫分離,我們把鎖分成讀鎖和寫鎖,讀的鎖不需要阻塞,而寫的鎖要考慮并發問題。
這里就不一一講述每一種鎖的概念了,大家可以自己學習,鎖還可以按照偏向鎖、輕量級鎖、重量級鎖來分類。
了解了鎖的基本概念和鎖的優化后,重點介紹分布式鎖的概念。
上圖所示是我們搭建的分布式環境,有三個購票項目,對應一個庫存,每一個系統會有多個線程,和上文一樣,對庫存的修改操作加上鎖,能不能保證這6個線程的線程安全呢?
當然是不能的,因為每一個購票系統都有各自的JVM進程,互相獨立,所以加synchronized只能保證一個系統的線程安全,并不能保證分布式的線程安全。
所以需要對于三個系統都是公共的一個中間件來解決這個問題。
這里我們選擇Redis來作為分布式鎖,多個系統在Redis中set同一個key,只有key不存在的時候,才能設置成功,并且該key會對應其中一個系統的唯一標識,當該系統訪問資源結束后,將key刪除,則達到了釋放鎖的目的。
在任意時刻只有一個客戶端可以獲取鎖。
這個很容易理解,所有的系統中只能有一個系統持有鎖。
假如一個客戶端在持有鎖的時候崩潰了,沒有釋放鎖,那么別的客戶端無法獲得鎖,則會造成死鎖,所以要保證客戶端一定會釋放鎖。
Redis中我們可以設置鎖的過期時間來保證不會發生死鎖。
解鈴還須系鈴人,加鎖和解鎖必須是同一個客戶端,客戶端A的線程加的鎖必須是客戶端A的線程來解鎖,客戶端不能解開別的客戶端的鎖。
當一個客戶端獲取對象鎖之后,這個客戶端可以再次獲取這個對象上的鎖。
Redis分布式鎖的具體流程:
1)首先利用Redis緩存的性質在Redis中設置一個key-value形式的鍵值對,key就是鎖的名稱,然后客戶端的多個線程去競爭鎖,競爭成功的話將value設為客戶端的唯一標識。
2)競爭到鎖的客戶端要做兩件事:
需要根據業務需要,不斷的壓力測試來決定有效期的長短。
所以這里的value就設置成唯一標識(比如uuid)。
3)訪問共享資源
4)釋放鎖,釋放鎖有兩種方式,第一種是有效期結束后自動釋放鎖,第二種是先根據唯一標識判斷自己是否有釋放鎖的權限,如果標識正確則釋放鎖。
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();
}
這就是我們想要的!
解鎖也是兩步,同樣也要保證解鎖的原子性,把兩步合為一步。
這就無法借助于Redis了,只能依靠Lua腳本來實現。
if Redis.call("get",key==argv[1])then
return Redis.call("del",key)
else return 0 end
這就是一段判斷是否自己持有鎖并釋放鎖的Lua腳本。
為什么Lua腳本是原子性呢?因為Lua腳本是jedis用eval()函數執行的,如果執行則會全部執行完成。
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;
}
}
1)實現方式
獲取鎖的時候插入一條數據,解鎖時刪除數據。
2)缺點
1)實現方式
加鎖時在指定節點的目錄下創建一個新節點,釋放鎖的時候刪除這個臨時節點。因為有心跳檢測的存在,所以不會發生死鎖,更加安全。
2)缺點
性能一般,沒有Redis高效。
所以:
本文從鎖的基本概念出發,提出多線程訪問共享資源會出現的線程安全問題,然后通過加鎖的方式去解決線程安全的問題,這個方法會性能會下降,需要通過:縮短鎖的持有時間、減小鎖的粒度、鎖分離三種方式去優化鎖。
之后介紹了分布式鎖的4個特點:
然后用Redis實現了分布式鎖,加鎖的時候用到了Redis的命令去加鎖,解鎖的時候則借助了Lua腳本來保證原子性。
最后對比了三種分布式鎖的優缺點和使用場景。
希望大家對分布式鎖有新的理解,也希望大家在考慮解決問題的同時要多想想性能的問題。
作者:楊亨
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。