分佈式鎖--Redis小試牛刀

參考文章:html

Redis分佈式鎖的正確實現方式java

分佈式鎖看這篇就夠了linux

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

引言

分佈式系統必定會存在CAP權衡問題,因此纔會出現分佈式鎖spring

什麼是CAP理論?   數據庫

爲了更好的理解文章,建議閱讀:分佈式系統的CAP理論網絡

什麼是鎖?

  • 在單進程的系統中,當存在多個線程能夠同時改變某個變量(可變共享變量)時,就須要對變量或代碼塊作同步,使其在修改這種變量時可以線性執行消除併發修改變量。
  • 而同步的本質是經過鎖來實現的。爲了實現多個線程在一個時刻同一個代碼塊只能有一個線程可執行,那麼須要在某個地方作個標記,這個標記必須每一個線程都能看到,當標記不存在時能夠設置該標記,其他後續線程發現已經有標記了則等待擁有標記的線程結束同步代碼塊取消標記後再去嘗試設置標記。這個標記能夠理解爲鎖。
  • 不一樣地方實現鎖的方式也不同,只要能知足全部線程都能看獲得標記便可。如 Java 中 synchronize 是在對象頭設置標記,Lock 接口的實現類基本上都只是某一個 volitile 修飾的 int 型變量其保證每一個線程都能擁有對該 int 的可見性和原子修改,linux 內核中也是利用互斥量或信號量等內存數據作標記。
  • 除了利用內存數據作鎖其實任何互斥的都能作鎖(只考慮互斥狀況),如流水錶中流水號與時間結合作冪等校驗能夠看做是一個不會釋放的鎖,或者使用某個文件是否存在做爲鎖等。只須要知足在對標記進行修改能保證原子性和內存可見性便可。

分佈式場景

此處主要指集羣模式下,多個相同服務同時開啓.多線程

在許多的場景中,咱們爲了保證數據的最終一致性,須要不少的技術方案來支持,好比分佈式事務分佈式鎖等。不少時候咱們須要保證一個方法在同一時間內只能被同一個線程執行。在單機環境中,經過 Java 提供的併發 API 咱們能夠解決,可是在分佈式環境下,就沒有那麼簡單啦。併發

  • 分佈式與單機狀況下最大的不一樣在於其不是多線程而是多進程
  • 多線程因爲能夠共享堆內存,所以能夠簡單的採起內存做爲標記存儲位置。而進程之間甚至可能都不在同一臺物理機上,所以須要將標記存儲在一個全部進程都能看到的地方。

什麼是分佈式鎖?

  • 當在分佈式模型下,數據只有一份(或有限制),此時須要利用鎖的技術控制某一時刻修改數據的進程數。
  • 與單機模式下的鎖不只須要保證進程可見,還須要考慮進程與鎖之間的網絡問題。(我以爲分佈式狀況下之因此問題變得複雜,主要就是須要考慮到網絡的延時和不可靠。。。一個大坑)
  • 分佈式鎖仍是能夠將標記存在內存,只是該內存不是某個進程分配的內存而是公共內存如 Redis、Memcache。至於利用數據庫、文件等作鎖與單機的實現是同樣的,只要保證標記能互斥就行。

咱們須要怎樣的分佈式鎖?

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

可靠性

首先,爲了確保分佈式鎖可用,咱們至少要確保鎖的實現同時知足如下四個條件:dom

  • 互斥性。在任意時刻,只有一個客戶端能持有鎖。
  • 不會發生死鎖。即便有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其餘客戶端能加鎖。
  • 具備容錯性。只要大部分的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纔會執行其餘命令。

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

相關文章
相關標籤/搜索