《Redis實現分佈式鎖》一、JAVA實現Redis分佈式鎖

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

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

一、分佈式鎖

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

  • 加鎖

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

  • 解鎖

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

  • 鎖超時

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

分佈式鎖的實現有很多,比如基於數據庫、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,等待鎖釋放的消息,然後重新嘗試獲取鎖。流程如下:

 

獲取鎖

獲取鎖的過程是怎樣的呢?接下來就要看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的內存數據中,就有一條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併發布鎖釋放的消息,解鎖成功

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



作者:清幽之地
鏈接:https://www.jianshu.com/p/47fd7f86c848
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章