分散式鎖之Redis實現

清幽之地發表於2019-02-21

在Java中,關於鎖我想大家都很熟悉。在併發程式設計中,我們通過鎖,來避免由於競爭而造成的資料不一致問題。通常,我們以synchronized 、Lock來使用它。

但是Java中的鎖,只能保證在同一個JVM程式內中執行。如果在分散式叢集環境下呢?

一、分散式鎖

分散式鎖,是一種思想,它的實現方式有很多。比如,我們將沙灘當做分散式鎖的元件,那麼它看起來應該是這樣的:

1、加鎖

在沙灘上踩一腳,留下自己的腳印,就對應了加鎖操作。其他程式或者執行緒,看到沙灘上已經有腳印,證明鎖已被別人持有,則等待。

2、解鎖

把腳印從沙灘上抹去,就是解鎖的過程。

3、超時

為了避免死鎖,我們可以設定一陣風,在單位時間後颳起,將腳印自動抹去。

分散式鎖的實現有很多,比如基於資料庫、memcached、Redis、系統檔案、zookeeper等。它們的核心的理念跟上面的過程大致相同。

二、redis

我們先來看如何通過單節點Redis實現一個簡單的分散式鎖。

1、加鎖

加鎖實際上就是在redis中,給Key鍵設定一個值,為避免死鎖,並給定一個過期時間。

SET lock_key random_value NX PX 5000

值得注意的是: random_value 是客戶端生成的唯一的字串。 NX 代表只在鍵不存在時,才對鍵進行設定操作。 PX 5000 設定鍵的過期時間為5000毫秒。

這樣,如果上面的命令執行成功,則證明客戶端獲取到了鎖。

2、解鎖

解鎖的過程就是將Key鍵刪除。但也不能亂刪,不能說客戶端1的請求將客戶端2的鎖給刪除掉。這時候random_value的作用就體現出來。

為了保證解鎖操作的原子性,我們用LUA指令碼完成這一操作。先判斷當前鎖的字串是否與傳入的值相等,是的話就刪除Key,解鎖成功。

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0 
end
複製程式碼

3、實現

首先,我們在pom檔案中,引入Jedis。在這裡,筆者用的是最新版本,注意由於版本的不同,API可能有所差異。

<dependency>
	<groupId>redis.clients</groupId>
	<artifactId>jedis</artifactId>
	<version>3.0.1</version>
</dependency>
複製程式碼

加鎖的過程很簡單,就是通過SET指令來設定值,成功則返回;否則就迴圈等待,在timeout時間內仍未獲取到鎖,則獲取失敗。

@Service
public class RedisLock {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    private String lock_key = "redis_lock"; //鎖鍵

    protected long internalLockLeaseTime = 30000;//鎖過期時間

    private long timeout = 999999; //獲取鎖的超時時間

	
	//SET命令的引數 
    SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);

    @Autowired
    JedisPool jedisPool;

	
	/**
     * 加鎖
     * @param id
     * @return
     */
    public boolean lock(String id){
        Jedis jedis = jedisPool.getResource();
        Long start = System.currentTimeMillis();
        try{
            for(;;){
				//SET命令返回OK ,則證明獲取鎖成功
                String lock = jedis.set(lock_key, id, params);
                if("OK".equals(lock)){
                    return true;
                }
				//否則迴圈等待,在timeout時間內仍未獲取到鎖,則獲取失敗
                long l = System.currentTimeMillis() - start;
                if (l>=timeout) {
                    return false;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }finally {
            jedis.close();
        }
    }
}
複製程式碼

解鎖我們通過jedis.eval來執行一段LUA就可以。將鎖的Key鍵和生成的字串當做引數傳進來。

	/**
     * 解鎖
     * @param id
     * @return
     */
    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            Object result = jedis.eval(script, Collections.singletonList(lock_key), 
									Collections.singletonList(id));
            if("1".equals(result.toString())){
                return true;
            }
            return false;
        }finally {
            jedis.close();
        }
    }
複製程式碼

最後,我們可以在多執行緒環境下測試一下。我們開啟1000個執行緒,對count進行累加。呼叫的時候,關鍵是唯一字串的生成。這裡,筆者使用的是Snowflake演算法。

@Controller
public class IndexController {

	@Autowired
    RedisLock redisLock;
	
	int count = 0;
	
	@RequestMapping("/index")
    @ResponseBody
    public String index() throws InterruptedException {

        int clientcount =1000;
        CountDownLatch countDownLatch = new CountDownLatch(clientcount);

        ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
        long start = System.currentTimeMillis();
        for (int i = 0;i<clientcount;i++){
            executorService.execute(() -> {
			
				//通過Snowflake演算法獲取唯一的ID字串
                String id = IdUtil.getId();
                try {
                    redisLock.lock(id);
                    count++;
                }finally {
                    redisLock.unlock(id);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        logger.info("執行執行緒數:{},總耗時:{},count數為:{}",clientcount,end-start,count);
        return "Hello";
    }
}
複製程式碼

至此,單節點Redis的分散式鎖的實現就已經完成了。比較簡單,但是問題也比較大,最重要的一點是,鎖不具有可重入性。

三、redisson

Redisson是架設在Redis基礎上的一個Java駐記憶體資料網格(In-Memory Data Grid)。充分的利用了Redis鍵值資料庫提供的一系列優勢,基於Java實用工具包中常用介面,為使用者提供了一系列具有分散式特性的常用工具類。使得原本作為協調單機多執行緒併發程式的工具包獲得了協調分散式多機多執行緒併發系統的能力,大大降低了設計和研發大規模分散式系統的難度。同時結合各富特色的分散式服務,更進一步簡化了分散式環境中程式相互之間的協作。

相對於Jedis而言,Redisson強大的一批。當然了,隨之而來的就是它的複雜性。它裡面也實現了分散式鎖,而且包含多種型別的鎖,更多請參閱分散式鎖和同步器

1、可重入鎖

上面我們自己實現的Redis分散式鎖,其實不具有可重入性。那麼下面我們先來看看Redisson中如何呼叫可重入鎖。

在這裡,筆者使用的是它的最新版本,3.10.1。

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.10.1</version>
</dependency>
複製程式碼

首先,通過配置獲取RedissonClient客戶端的例項,然後getLock獲取鎖的例項,進行操作即可。

public static void main(String[] args) {

	Config config = new Config();
	config.useSingleServer().setAddress("redis://127.0.0.1:6379");
	config.useSingleServer().setPassword("redis1234");
	
	final RedissonClient client = Redisson.create(config);	
	RLock lock = client.getLock("lock1");
	
	try{
		lock.lock();
	}finally{
		lock.unlock();
	}
}
複製程式碼

2、獲取鎖例項

我們先來看RLock lock = client.getLock("lock1"); 這句程式碼就是為了獲取鎖的例項,然後我們可以看到它返回的是一個RedissonLock物件。

public RLock getLock(String name) {
	return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
複製程式碼

RedissonLock構造方法中,主要初始化一些屬性。

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
	super(commandExecutor, name);
	//命令執行器
	this.commandExecutor = commandExecutor;
	//UUID字串
	this.id = commandExecutor.getConnectionManager().getId();
	//內部鎖過期時間
	this.internalLockLeaseTime = commandExecutor.
				getConnectionManager().getCfg().getLockWatchdogTimeout();
	this.entryName = id + ":" + name;
}
複製程式碼

3、加鎖

當我們呼叫lock方法,定位到lockInterruptibly。在這裡,完成了加鎖的邏輯。

public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
	
	//當前執行緒ID
	long threadId = Thread.currentThread().getId();
	//嘗試獲取鎖
	Long ttl = tryAcquire(leaseTime, unit, threadId);
	// 如果ttl為空,則證明獲取鎖成功
	if (ttl == null) {
		return;
	}
	//如果獲取鎖失敗,則訂閱到對應這個鎖的channel
	RFuture<RedissonLockEntry> future = subscribe(threadId);
	commandExecutor.syncSubscription(future);

	try {
		while (true) {
			//再次嘗試獲取鎖
			ttl = tryAcquire(leaseTime, unit, threadId);
			//ttl為空,說明成功獲取鎖,返回
			if (ttl == null) {
				break;
			}
			//ttl大於0 則等待ttl時間後繼續嘗試獲取
			if (ttl >= 0) {
				getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
			} else {
				getEntry(threadId).getLatch().acquire();
			}
		}
	} finally {
		//取消對channel的訂閱
		unsubscribe(future, threadId);
	}
	//get(lockAsync(leaseTime, unit));
}
複製程式碼

如上程式碼,就是加鎖的全過程。先呼叫tryAcquire來獲取鎖,如果返回值ttl為空,則證明加鎖成功,返回;如果不為空,則證明加鎖失敗。這時候,它會訂閱這個鎖的Channel,等待鎖釋放的訊息,然後重新嘗試獲取鎖。流程如下:

分散式鎖之Redis實現

獲取鎖

獲取鎖的過程是怎樣的呢?接下來就要看tryAcquire方法。在這裡,它有兩種處理方式,一種是帶有過期時間的鎖,一種是不帶過期時間的鎖。

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {

	//如果帶有過期時間,則按照普通方式獲取鎖
	if (leaseTime != -1) {
		return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
	}
	
	//先按照30秒的過期時間來執行獲取鎖的方法
	RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
		commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
		TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
		
	//如果還持有這個鎖,則開啟定時任務不斷重新整理該鎖的過期時間
	ttlRemainingFuture.addListener(new FutureListener<Long>() {
		@Override
		public void operationComplete(Future<Long> future) throws Exception {
			if (!future.isSuccess()) {
				return;
			}

			Long ttlRemaining = future.getNow();
			// lock acquired
			if (ttlRemaining == null) {
				scheduleExpirationRenewal(threadId);
			}
		}
	});
	return ttlRemainingFuture;
}
複製程式碼

接著往下看,tryLockInnerAsync方法是真正執行獲取鎖的邏輯,它是一段LUA指令碼程式碼。在這裡,它使用的是hash資料結構。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, 	
							long threadId, RedisStrictCommand<T> command) {

		//過期時間
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
				  //如果鎖不存在,則通過hset設定它的值,並設定過期時間
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
				  //如果鎖已存在,並且鎖的是當前執行緒,則通過hincrby給數值遞增1
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
				  //如果鎖已存在,但並非本執行緒,則返回過期時間ttl
                  "return redis.call('pttl', KEYS[1]);",
        Collections.<Object>singletonList(getName()), 
				internalLockLeaseTime, getLockName(threadId));
    }
複製程式碼

這段LUA程式碼看起來並不複雜,有三個判斷:

  • 通過exists判斷,如果鎖不存在,則設定值和過期時間,加鎖成功
  • 通過hexists判斷,如果鎖已存在,並且鎖的是當前執行緒,則證明是重入鎖,加鎖成功
  • 如果鎖已存在,但鎖的不是當前執行緒,則證明有其他執行緒持有鎖。返回當前鎖的過期時間,加鎖失敗

分散式鎖之Redis實現

加鎖成功後,在redis的記憶體資料中,就有一條hash結構的資料。Key為鎖的名稱;field為隨機字串+執行緒ID;值為1。如果同一執行緒多次呼叫lock方法,值遞增1。

127.0.0.1:6379> hgetall lock1
1) "b5ae0be4-5623-45a5-8faa-ab7eb167ce87:1"
2) "1"
複製程式碼

4、解鎖

我們通過呼叫unlock方法來解鎖。

public RFuture<Void> unlockAsync(final long threadId) {
	final RPromise<Void> result = new RedissonPromise<Void>();
	
	//解鎖方法
	RFuture<Boolean> future = unlockInnerAsync(threadId);

	future.addListener(new FutureListener<Boolean>() {
		@Override
		public void operationComplete(Future<Boolean> future) throws Exception {
			if (!future.isSuccess()) {
				cancelExpirationRenewal(threadId);
				result.tryFailure(future.cause());
				return;
			}
			//獲取返回值
			Boolean opStatus = future.getNow();
			//如果返回空,則證明解鎖的執行緒和當前鎖不是同一個執行緒,丟擲異常
			if (opStatus == null) {
				IllegalMonitorStateException cause = 
					new IllegalMonitorStateException("
						attempt to unlock lock, not locked by current thread by node id: "
						+ id + " thread-id: " + threadId);
				result.tryFailure(cause);
				return;
			}
			//解鎖成功,取消重新整理過期時間的那個定時任務
			if (opStatus) {
				cancelExpirationRenewal(null);
			}
			result.trySuccess(null);
		}
	});

	return result;
}
複製程式碼

然後我們再看unlockInnerAsync方法。這裡也是一段LUA指令碼程式碼。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
	return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
	
			//如果鎖已經不存在, 釋出鎖釋放的訊息
			"if (redis.call('exists', KEYS[1]) == 0) then " +
				"redis.call('publish', KEYS[2], ARGV[1]); " +
				"return 1; " +
			"end;" +
			//如果釋放鎖的執行緒和已存在鎖的執行緒不是同一個執行緒,返回null
			"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
				"return nil;" +
			"end; " +
			//通過hincrby遞減1的方式,釋放一次鎖
			//若剩餘次數大於0 ,則重新整理過期時間
			"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
			"if (counter > 0) then " +
				"redis.call('pexpire', KEYS[1], ARGV[2]); " +
				"return 0; " +
			//否則證明鎖已經釋放,刪除key併發布鎖釋放的訊息
			"else " +
				"redis.call('del', KEYS[1]); " +
				"redis.call('publish', KEYS[2], ARGV[1]); " +
				"return 1; "+
			"end; " +
			"return nil;",
	Arrays.<Object>asList(getName(), getChannelName()), 
		LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));

}
複製程式碼

如上程式碼,就是釋放鎖的邏輯。同樣的,它也是有三個判斷:

  • 如果鎖已經不存在,通過publish釋出鎖釋放的訊息,解鎖成功

  • 如果解鎖的執行緒和當前鎖的執行緒不是同一個,解鎖失敗,丟擲異常

  • 通過hincrby遞減1,先釋放一次鎖。若剩餘次數還大於0,則證明當前鎖是重入鎖,重新整理過期時間;若剩餘次數小於0,刪除key併發布鎖釋放的訊息,解鎖成功

分散式鎖之Redis實現

至此,Redisson中的可重入鎖的邏輯,就分析完了。但值得注意的是,上面的兩種實現方式都是針對單機Redis例項而進行的。如果我們有多個Redis例項,請參閱Redlock演算法。該演算法的具體內容,請參考redis.cn/topics/dist…

相關文章