在 Java 中利用 redis 實現一個分散式鎖服務

楊高超發表於2018-01-01

在現代的程式語言中,接觸過多執行緒程式設計的程式設計師多多少少對鎖有一定的瞭解。簡單的說,多執行緒中的鎖就是在多執行緒環境下,多個執行緒對共享資源進行修改的時候,保證共享資源一致性的機制。這裡不展開說。在分散式環境下,原來的多執行緒的鎖就不管用了,也就出現了分散式鎖的需求。所謂分散式鎖服務也就是在分散式環境下,保證多個分散式的服務共享的資源一致性的服務。

在分散式環境下實現一個分散式鎖服務並不太容易,需要考慮很多在單程式下的鎖服務不需要考慮的問題。分散式鎖鎖的實現也有很多。這裡我們討論在 Java 中通過 redis 來實現。在 GitHub 中的 redisson 專案中已經有開源的實現。但是那個太複雜了。現在我們來基於單機的 redis 實現一個簡單的分散式鎖服務。這個服務必須滿足下面的要求

  1. 支援立即獲取鎖方式,如果獲取到返回true,獲取不到則返回false;
  2. 支援等待獲取鎖方式,如果獲取到,直接返回true,獲取不到在等待一小段時間,在這一小段時間內反覆嘗試,如果嘗試成功,則返回true,等待時間過後還獲取不到則返回false;
  3. 不能產生死鎖的情況;
  4. 不能釋放非自己加的鎖;

下面我們用例項來演示在 Java 中利用 redis 實現分散式鎖服務

##加鎖

通過 redis 來實現分散式鎖的加鎖邏輯如下所示:

redis 分散式鎖上鎖實現邏輯

根據這個邏輯,實現上鎖的核心程式碼如下所示:

jedis.select(dbIndex);
String key = KEY_PRE + key;
String value = fetchLockValue();
if(jedis.exists(key)){
  jedis.set(key,value);
  jedis.expire(key,lockExpirseTime);
  return value;
}
複製程式碼

表面上看這段程式碼好像沒有什麼問題,實際上並不能在分散式環境中正確的實現加鎖的操作。要能夠正確的實現加鎖操作,“判斷 key 是否存在”“儲存 key-value”、**“設定 key 的過期時間”**這三步操作必須是原子操作。如果不是原子操作,那麼可能會出現下面兩種情況:

  1. 在**“判斷 key 是否存在”得出 key 不存在的結果步驟後,“儲存 key-value”步驟前,另一個客戶端執行同樣的邏輯,並且執行到了“判斷 key 是否存在”**步驟,同樣得出了 key 不存在的結果。這樣回導致多個客戶端獲得了同一把鎖;
  2. 在客戶端執行完**“儲存 key-value”** 步驟後,需要設定一個 key 的過期時間,防止客戶端因為程式碼質量未解鎖,在或者程式崩潰未解鎖導致的死鎖情況。在**“儲存 key-value”步驟之後,“設定 key 的過期時間”步驟之前,可能程式崩潰,導致“設定 key 的過期時間”**步驟失敗;

redis 在2.6.12版本之後,對 set 命令進行了擴充,能夠規避上面的兩個問題。新版的 redis set 命令的引數如下

SET key value [EX seconds] [PX milliseconds] [NX|XX]
複製程式碼

新版的 set 命令增加了 EX 、 PX 、 NX|XX 引數選項。他們的含義如下

EX seconds – 設定鍵 key 的過期時間,單位時秒
PX milliseconds – 設定鍵 key 的過期時間,單位時毫秒
NX – 只有鍵 key 不存在的時候才會設定 key 的值
XX – 只有鍵 key 存在的時候才會設定 key 的值
複製程式碼

這樣,原來的三步操作就可以在一個 set 的原子操作裡面來完成,規避了上面我們提到的兩個問題。新版的 redis 加鎖核心程式碼修改如下所示:

jedis = redisConnection.getJedis();
jedis.select(dbIndex);
String key = KEY_PRE + key;
String value = fetchLockValue();
if ("OK".equals(jedis.set(key, value, "NX", "EX", lockExpirseTime))) {
    return value;
}
複製程式碼

##解鎖 解鎖的基本流程如下:

redis 分散式鎖解鎖實現邏輯

根據這個邏輯,在 Java 中解鎖的核心程式碼如下所示:

jedis.select(dbIndex);
String key = KEY_PRE + key;
if(jedis.exists(key) && value.equals(jedis.get(key))){
    jedis.del(key);
    return true;
}
return false;
複製程式碼

和加鎖的時候一樣,key 是否存在判斷是否自己持有鎖、**刪除 key-value **這三步操作需要是原子操作,否則當一個客戶端執行完“判斷是否自己持有鎖”步驟後,得出自己持有鎖的結論,此時鎖的過期時間到了,自動被 redis 釋放了,同時另一個客戶端又基於這個 key 加鎖成功,如果第一個客戶端還繼續執行刪除 key-value的操作,就將不屬於自己的鎖給釋放了。這顯然是不執行的。在這裡我們利用 redis 執行 Lua 指令碼的能力來解決原子操作的問題。修改後的解鎖核心程式碼如下所示:

jedis.select(dbIndex);
String key = KEY_PRE + key;
String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
if (1L.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
    return true;
}
複製程式碼

另外,判斷是否自己持有鎖的機制是用加鎖的時候的 key-value 來判斷當前的 key 的值是否等於自己持有鎖時獲得的值。所以加鎖的時候的 value 必須是一個全域性唯一的字串。

##完整的程式碼如下所示

package com.x9710.common.redis.impl;

import com.x9710.common.redis.LockService;
import com.x9710.common.redis.RedisConnection;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import redis.clients.jedis.Jedis;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.UUID;

/**
 * 分散式鎖 redis 實現
 *
 * @author 楊高超
 * @since 2017-12-14
 */
public class LockServiceRedisImpl implements LockService {

private static Log log = LogFactory.getLog(LockServiceRedisImpl.class);

private static String SET_SUCCESS = "OK";

private static String KEY_PRE = "REDIS_LOCK_";

private DateFormat df = new SimpleDateFormat("yyyyMMddHHmmssSSS");

private RedisConnection redisConnection;

private Integer dbIndex;

private Integer lockExpirseTime;

private Integer tryExpirseTime;

public void setRedisConnection(RedisConnection redisConnection) {
    this.redisConnection = redisConnection;
}

public void setDbIndex(Integer dbIndex) {
    this.dbIndex = dbIndex;
}

public void setLockExpirseTime(Integer lockExpirseTime) {
    this.lockExpirseTime = lockExpirseTime;
}

public void setTryExpirseTime(Integer tryExpirseTime) {
    this.tryExpirseTime = tryExpirseTime;
}

public String lock(String key) {
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        key = KEY_PRE + key;
        String value = fetchLockValue();
        if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", lockExpirseTime))) {
            log.debug("Reids Lock key : " + key + ",value : " + value);
            return value;
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
    return null;
}

public String tryLock(String key) {

    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        key = KEY_PRE + key;
        String value = fetchLockValue();
        Long firstTryTime = new Date().getTime();
        do {
            if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", lockExpirseTime))) {
                log.debug("Reids Lock key : " + key + ",value : " + value);
                return value;
            }
            log.info("Redis lock failure,waiting try next");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } while ((new Date().getTime() - tryExpirseTime * 1000) < firstTryTime);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
    return null;
}

public boolean unLock(String key, String value) {
    Long RELEASE_SUCCESS = 1L;
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        key = KEY_PRE + key;
        String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
        if (RELEASE_SUCCESS.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
            return true;
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
    return false;
}

/**
 * 生成加鎖的唯一字串
 *
 * @return 唯一字串
 */
private String fetchLockValue() {
    return UUID.randomUUID().toString() + "_" + df.format(new Date());
}
}
複製程式碼

##測試程式碼

package com.x9710.common.redis.test;

import com.x9710.common.redis.RedisConnection;
import com.x9710.common.redis.impl.LockServiceRedisImpl;

public class RedisLockTest {

public static void main(String[] args) {
    for (int i = 0; i < 9; i++) {
        new Thread(new Runnable() {
            public void run() {
                RedisConnection redisConnection = RedisConnectionUtil.create();
                LockServiceRedisImpl lockServiceRedis = new LockServiceRedisImpl();
                lockServiceRedis.setRedisConnection(redisConnection);
                lockServiceRedis.setDbIndex(15);
                lockServiceRedis.setLockExpirseTime(20);
                String key = "20171228";
                String value = lockServiceRedis.lock(key);
                try {
                    if (value != null) {
                        System.out.println(Thread.currentThread().getName() + " lock key = " + key + " success! ");
                        Thread.sleep(25 * 1000);
                    }else{
                        System.out.println(Thread.currentThread().getName() + " lock key = " + key + " failure! ");
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    if (value == null) {
                        value = "";
                    }
                    System.out.println(Thread.currentThread().getName() + " unlock key = " + key + " " + lockServiceRedis.unLock(key, value));

                }
            }
        }).start();
    }
}
}
複製程式碼

測試結果

Thread-1 lock key = 20171228 failure! 
Thread-2 lock key = 20171228 failure! 
Thread-4 lock key = 20171228 failure! 
Thread-8 lock key = 20171228 failure! 
Thread-7 lock key = 20171228 failure! 
Thread-3 lock key = 20171228 failure! 
Thread-5 lock key = 20171228 failure! 
Thread-0 lock key = 20171228 failure! 
Thread-6 lock key = 20171228 success! 
Thread-1 unlock key = 20171228 false
Thread-2 unlock key = 20171228 false
Thread-4 unlock key = 20171228 false
Thread-8 unlock key = 20171228 false
Thread-3 unlock key = 20171228 false
Thread-5 unlock key = 20171228 false
Thread-0 unlock key = 20171228 false
Thread-7 unlock key = 20171228 false
Thread-6 unlock key = 20171228 true
複製程式碼

從測試結果來看可以看到,9個執行緒同時給一個 key 加鎖,只有一個能夠成功獲取到鎖,其餘的客戶端都無法獲取到鎖。

##後記 這個程式碼裡面還實現了一個 tryLock 的介面。這個主要是客戶端無法獲取到鎖的時候會在一小段時間內反覆嘗試是否能夠獲取到鎖。

這個程式實在前面的文章《在 Java 中使用 redis》的基礎上新增新的實現類的方式完成的。程式碼同步釋出在 GitHub 倉庫

原文發表在簡書中,原始連結

相關文章