分散式鎖--Redis小試牛刀

molashaonian發表於2018-08-09

參考文章:

Redis分散式鎖的正確實現方式

分散式鎖看這篇就夠了

在這兩篇文章的指引下親測 Redis分散式鎖

引言

分散式系統一定會存在CAP權衡問題,所以才會出現分散式鎖

什麼是CAP理論?   

為了更好的理解文章,建議閱讀:分散式系統的CAP理論

什麼是鎖?

  • 在單程式的系統中,當存在多個執行緒可以同時改變某個變數(可變共享變數)時,就需要對變數或程式碼塊做同步,使其在修改這種變數時能夠線性執行消除併發修改變數。
  • 而同步的本質是通過鎖來實現的。為了實現多個執行緒在一個時刻同一個程式碼塊只能有一個執行緒可執行,那麼需要在某個地方做個標記,這個標記必須每個執行緒都能看到,當標記不存在時可以設定該標記,其餘後續執行緒發現已經有標記了則等待擁有標記的執行緒結束同步程式碼塊取消標記後再去嘗試設定標記。這個標記可以理解為鎖。
  • 不同地方實現鎖的方式也不一樣,只要能滿足所有執行緒都能看得到標記即可。如 Java 中 synchronize 是在物件頭設定標記,Lock 介面的實現類基本上都只是某一個 volitile 修飾的 int 型變數其保證每個執行緒都能擁有對該 int 的可見性和原子修改,linux 核心中也是利用互斥量或訊號量等記憶體資料做標記。
  • 除了利用記憶體資料做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水錶中流水號與時間結合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個檔案是否存在作為鎖等。只需要滿足在對標記進行修改能保證原子性和記憶體可見性即可。

分散式場景

此處主要指叢集模式下,多個相同服務同時開啟.

在許多的場景中,我們為了保證資料的最終一致性,需要很多的技術方案來支援,比如分散式事務分散式鎖等。很多時候我們需要保證一個方法在同一時間內只能被同一個執行緒執行。在單機環境中,通過 Java 提供的併發 API 我們可以解決,但是在分散式環境下,就沒有那麼簡單啦。

  • 分散式與單機情況下最大的不同在於其不是多執行緒而是多程式
  • 多執行緒由於可以共享堆記憶體,因此可以簡單的採取記憶體作為標記儲存位置。而程式之間甚至可能都不在同一臺物理機上,因此需要將標記儲存在一個所有程式都能看到的地方。

什麼是分散式鎖?

  • 當在分散式模型下,資料只有一份(或有限制),此時需要利用鎖的技術控制某一時刻修改資料的程式數。
  • 與單機模式下的鎖不僅需要保證程式可見,還需要考慮程式與鎖之間的網路問題。(我覺得分散式情況下之所以問題變得複雜,主要就是需要考慮到網路的延時和不可靠。。。一個大坑)
  • 分散式鎖還是可以將標記存在記憶體,只是該記憶體不是某個程式分配的記憶體而是公共記憶體如 Redis、Memcache。至於利用資料庫、檔案等做鎖與單機的實現是一樣的,只要保證標記能互斥就行。

我們需要怎樣的分散式鎖?

  • 可以保證在分散式部署的應用叢集中,同一個方法在同一時間只能被一臺機器上的一個執行緒執行。
  • 這把鎖要是一把可重入鎖(避免死鎖)
  • 這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
  • 這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)
  • 有高可用的獲取鎖和釋放鎖功能
  • 獲取鎖和釋放鎖的效能要好

可靠性

首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:

  • 互斥性。在任意時刻,只有一個客戶端能持有鎖。
  • 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
  • 具有容錯性。只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。
  • 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

一般實現方式

分散式鎖一般有三種實現方式:

  • 資料庫樂觀鎖;
  • 基於Redis的分散式鎖;
  • 基於ZooKeeper的分散式鎖。

本文將介紹第二種方式,基於Redis實現分散式鎖。


測試程式碼實現

注意: Redis 從2.6.12版本開始 set 命令支援 NX 、 PX 這些引數來達到 setnx 、 setex 、 psetex 命令的效果,文件參見: http://doc.redisfans.com/string/set.html

Spring Boot 下的 RedisTemplate 並不支援 NX 同時設定過期時間這種 set 操作(具有原子性)

所以這裡我們需要 Maven 引入支援這種 set 操作的 Jedis 依賴

<dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
      <version>2.9.0</version>
</dependency>

併發下單,庫存鎖測試:

建立10個執行緒,同時啟動下單操作,對庫存操作加入分散式鎖

測試程式碼:

package com.elise.userinfocenter;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import redis.clients.jedis.Jedis;

import java.util.Collections;

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserInfoCenterApplicationTests {

	private static final String LOCK_SUCCESS = "OK";
	private static final String SET_IF_NOT_EXIST = "NX";
	private static final String SET_WITH_EXPIRE_TIME = "PX";

	private static final Long RELEASE_SUCCESS = 1L;

	private int splitPoint = 500;

	@Autowired
	private RedisProperties redisConfig;

	@Test
	public void redisLock() {
		ThreadTest[] threadTests = new ThreadTest[10];
		for (int i=0; i<10; i++) {
			threadTests[i] = new ThreadTest();
		}
		for (int i=0; i<10; i++) {
			threadTests[i].start();
		}
	}

	private class ThreadTest extends Thread {
		@Override
		public void run() {
			Jedis jedis = new Jedis(redisConfig.getHost(),redisConfig.getPort(),redisConfig.getTimeout());
			String requestId = this.getId()+"";
			int i=0;
			while (true){
				i = ++i;
				try {
					if(tryGetDistributedLock(jedis,"lock-test",requestId,2000)) {
						System.out.println("執行緒:"+requestId+"	成功獲得分散式鎖!!!");
						System.out.println("當前庫存:"+splitPoint);
						splitPoint = --splitPoint;
						System.out.println("執行緒:"+requestId+"下單成功後庫存:"+splitPoint);
						if(releaseDistributedLock(jedis,"lock-test",requestId)) {
							System.out.println("執行緒:"+requestId+"	成功釋放分散式鎖!!!");
						}
						break;
					} else {
						System.out.println("執行緒:"+requestId+"	第"+i+"次無法獲得分散式鎖,繼續搶鎖!!!");
					}
				}catch (Exception e) {
					e.printStackTrace();
				}
			}
		}
	}

	/**
	 * 嘗試獲取分散式鎖
	 * @param jedis Redis客戶端
	 * @param lockKey 鎖
	 * @param requestId 請求標識
	 * @param expireTime 超期時間
	 * @return 是否獲取成功
	 */
	public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

		String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

		if (LOCK_SUCCESS.equals(result)) {
			return true;
		}
		return false;
	}

	/**
	 * 釋放分散式鎖
	 * @param jedis Redis客戶端
	 * @param lockKey 鎖
	 * @param requestId 請求標識
	 * @return 是否釋放成功
	 */
	public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

		String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
		Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

		if (RELEASE_SUCCESS.equals(result)) {
			return true;
		}
		return false;
	}

}

效果圖:

通過上面的程式碼+效果圖可以知道這十個執行緒啟動之後都開始搶佔 redis分散式鎖,沒有獲得鎖繼續搶鎖,蹭蹭蹭幾下每個執行緒都準確無誤滴執行了下單,減少庫存操作,下面具體分析一下加鎖,解鎖程式碼

加鎖程式碼

	/**
	 * 嘗試獲取分散式鎖
	 * @param jedis Redis客戶端
	 * @param lockKey 鎖
	 * @param requestId 請求標識
	 * @param expireTime 超期時間
	 * @return 是否獲取成功
	 */
	public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

		String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

		if (LOCK_SUCCESS.equals(result)) {
			return true;
		}
		return false;
	}

可以看到,我們加鎖就一行程式碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

  • 第一個為key,我們使用key來當鎖,因為key是唯一的。

  • 第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什麼還要用到value?原因就是我們在上面講到可靠性時,分散式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。(本測試用例使用的是當前執行緒ID)

  • 第三個為nxxx,這個引數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;

  • 第四個為expx,這個引數我們傳的是PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個引數決定。

  • 第五個為time,與第四個引數相呼應,代表key的過期時間。

總的來說,執行上面的set()方法就只會導致兩種結果:

  • 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設定個有效期,同時value表示加鎖的客戶端。
  • 已有鎖存在,不做任何操作。

心細的童鞋就會發現了,我們的加鎖程式碼滿足我們可靠性裡描述的三個條件。

  • 首先,set()加入了NX引數,可以保證如果已有key存在,則函式不會呼叫成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。
  • 其次,由於我們對鎖設定了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。
  • 最後,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。

解鎖程式碼

	/**
	 * 釋放分散式鎖
	 * @param jedis Redis客戶端
	 * @param lockKey 鎖
	 * @param requestId 請求標識
	 * @return 是否釋放成功
	 */
	public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

		String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
		Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

		if (RELEASE_SUCCESS.equals(result)) {
			return true;
		}
		return false;
	}

可以看到,我們解鎖只需要兩行程式碼就搞定了!第一行程式碼,我們寫了一個簡單的Lua指令碼程式碼。第二行程式碼,我們將Lua程式碼傳到jedis.eval()方法裡,並使引數KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId。eval()方法是將Lua程式碼交給Redis服務端執行。

那麼這段Lua程式碼的功能是什麼呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那麼為什麼要使用Lua語言來實現呢?因為要確保上述操作是原子性的。

簡單來說,就是在eval命令執行Lua程式碼的時候,Lua程式碼將被當成一個命令去執行,並且直到eval命令執行完成,Redis才會執行其他命令。

關注公眾號,分享乾貨,討論技術

相關文章