1. 程式人生 > >單Redis例項實現分散式鎖 基於Lua指令碼

單Redis例項實現分散式鎖 基於Lua指令碼

前言

多執行緒併發執行情況下如何保證一個程式碼塊在同一時間只能由同一執行緒執行(同一個JVM中多執行緒同步執行)?

可以使用執行緒鎖的機制(如synchronized,ReentrantLock類)

synchronized(obj){

......

}
ReentrantLock lock = new ReentrantLock();

lock.lock(); 

.....

lock.unlock();

在分散式的叢集環境中如何保證不同節點的執行緒同步執行?

分散式鎖

這裡我們介紹使用Redis實現

如何用Redis實現分散式鎖

一、核心要素

Redis分散式鎖的基本流程並不難理解,但要想寫得盡善盡美,也並不是那麼容易。在這裡,我們需要先了解分散式鎖實現的三個核心要素:

1.加鎖

最簡單的方法是使用setnx命令。key是鎖的唯一標識,按業務來決定命名。比如想要給一種商品的秒殺活動加鎖,可以給key命名為 “lock_sale_商品ID” 。而value設定成什麼呢?我們可以姑且設定成1。加鎖的虛擬碼如下:    
setnx(key,1)
當一個執行緒執行setnx返回1,說明key原本不存在,該執行緒成功得到了鎖;當一個執行緒執行setnx返回0,說明key已經存在,該執行緒搶鎖失敗。

2.解鎖

有加鎖就得有解鎖。當得到鎖的執行緒執行完任務,需要釋放鎖,以便其他執行緒可以進入。釋放鎖的最簡單方式是執行del指令,虛擬碼如下:
del(key)
釋放鎖之後,其他執行緒就可以繼續執行setnx命令來獲得鎖。


3.鎖超時

鎖超時是什麼意思呢?如果一個得到鎖的執行緒在執行任務的過程中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住,別的執行緒再也別想進來。
所以,setnx的key必須設定一個超時時間,以保證即使沒有被顯式釋放,這把鎖也要在一定時間後自動釋放。setnx不支援超時引數,所以需要額外的指令,虛擬碼如下:
expire(key, 30)
綜合起來,我們分散式鎖實現的第一版虛擬碼如下:


if(setnx(key,1) == 1){

    expire(key,30)

    try {

        do something ......

    } finally {

        del(key)

    }

}

二、存在的問題

1. setnx和expire的非原子性

設想一種極端場景,當某執行緒執行setnx.獲得鎖,setnx剛執行成功,還未來執行expire命令,節點1突然掛掉,這樣鎖沒有設定過期時間,永遠無法被釋放。

解決?

Redis2.6.12版本,改進了set方法,可以同時設定value和expire

SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
  • EX seconds − 設定指定的到期時間(以秒為單位)。
  • PX milliseconds - 設定指定的到期時間(以毫秒為單位)。
  • NX - 僅在鍵不存在時設定鍵。
  • XX - 只有在鍵已存在時才設定。

redis 命令 :set key value ex 30 nx

Jedis java :  

String result = jedis.set(key,threadId,"NX","EX",30);

2.del誤刪

假如執行緒A獲得鎖並且設定超時時間為30秒,某種原因導致執行緒A執行很慢,超過30秒後執行緒A鎖自動過期,釋放了鎖,執行緒B獲了鎖。隨後執行緒A執行完成,del刪除鎖,但執行緒B還未執行完成,實際上執行緒A刪除的是執行緒B的鎖。

解決?

保證加鎖和刪除鎖是同一個執行緒,在del前加判斷條件。如可以把uuid當前value ,在刪除前驗證key的value是不是當前執行緒的uuid

虛擬碼

if(uuild.equals(redisClient.get(key))){

 del(key);

}

但判斷和刪除鎖是兩個獨立的操作,並不是原子級別的。

我們使用Lua指令碼來解決子性操作  (redis2.6後特性)

String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId)); //Collections.singletonList——用來生成只讀 的單一元素的List

3.出現併發的可能

上述第二點場景,雖然避免了key誤刪,但當超時時,還是存在A,B兩個執行緒同時訪問程式碼塊。

解決?

讓獲得鎖的執行緒開啟一個守護執行緒,給快要過期但認為未完成的鎖驗證過期時間。

關於守護執行緒的生命週期: 與鎖執行緒週期一致,執行緒A執行完成會顯式關掉守護執行緒。

Thread t = new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					//....延時操作
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		});
		t.setDaemon(true);//設定當前執行緒為守護執行緒
		t.start();	

三、實現

基於上面原理,jedis整合spring來簡單實現單例項分佈鎖

spring-redis.xml

使用sharedJedisPool

<bean id="poolConfig" class="redis.clients.jedis.JedisPoolConfig">
		<property name="minIdle" value="${redis.minIdle}" />
		<property name="maxIdle" value="${redis.maxIdle}" />
		<property name="maxTotal" value="${redis.maxActive}" />
		<property name="maxWaitMillis" value="${redis.maxWait}" />
		<property name="testOnBorrow" value="${redis.testOnBorrow}" />
	</bean>
<bean id="shardedJedisPool" class="redis.clients.jedis.ShardedJedisPool">
		<constructor-arg index="0" ref="poolConfig" />
		<constructor-arg index="1">
			<list>
				<bean class="redis.clients.jedis.JedisShardInfo">
					<constructor-arg index="0" value="${redis.host}" />
					<constructor-arg name="port" value="${redis.port}"/>
					<property name="password" value="${redis.password}" />
				</bean>
			</list>
		</constructor-arg>
	</bean>

RedisDistributedLockServiceImpl 實現阻塞式加鎖、解鎖


import com.ticket.service.RedisDistributedLockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.ShardedJedis;
import redis.clients.jedis.ShardedJedisPool;

import java.util.Collections;
import java.util.concurrent.TimeUnit;


@Service("redisDistributedLockService")
public class RedisDistributedLockServiceImpl implements RedisDistributedLockService {
    private final static String LOCK_SUFFIX = "_redis_lock";

    /**
     * 重試時間
     */
    private static final int DEFAULT_ACQUIRY_RETRY_MILLIS = 20;

    @Autowired
    private ShardedJedisPool shardedJedisPool;
    

    // value值和執行執行緒相聯絡,可以用uuid避免重複
    @Override
    public boolean lock(String lockKey,String value) throws InterruptedException {
        
            String key = lockKey + LOCK_SUFFIX ;
            ShardedJedis jedis = shardedJedisPool.getResource();
            while(true){
                //30秒後過期,釋放鎖
                String result = jedis.set(key,value,"NX","EX",30);
                if("OK".equals(result)){
                    jedis.close();
                    return true;
                }
                TimeUnit.MILLISECONDS.sleep(DEFAULT_ACQUIRY_RETRY_MILLIS);
            }
        
    }

    @Override
    public void unlock(String lockKey ,String value) {
       
            String key = lockKey + LOCK_SUFFIX ;
            ShardedJedis shardedJedis = shardedJedisPool.getResource();
            Jedis jedis = shardedJedis.getShard(key);
            //保證加鎖和解鎖為同一執行緒,通過value值判斷
            String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end ";
            Object obj = jedis.eval(luaScript,Collections.singletonList(key),Collections.singletonList(value));;
        
    }



}

建議:

1.新增守護執行緒給快要過期且未完成任務的執行緒,延長鎖過期時間。

2.不建議使用spring的redisTemplate,並不沒有提供set(key,value,"NX","EX",30) 類似的操作,除非使用lua指令碼操作

小結

其他更好實現,參考官方基於RedLock演算法的Redisson方式,它有一整套的解決方案