首頁 >資料庫 >Redis >redis分散式鎖定實作方法介紹

redis分散式鎖定實作方法介紹

尚
轉載
2019-12-05 17:09:383571瀏覽

redis分散式鎖定實作方法介紹

一、使用分散式鎖定要滿足的幾個條件:

1、系統是分散式系統(關鍵是分散式,單機的可以使用ReentrantLock或synchronized程式碼區塊來實現)

2、共享資源(各個系統存取同一個資源,資源的載體可能是傳統關係型資料庫或NoSQL)

3 、同步訪問(即有很多個進程同事訪問同一個共享資源。沒有同步訪問,誰管你資源競爭不競爭)

二、應用的場景例子

#管理後台的部署架構(多台tomcat伺服器redis【多台tomcat伺服器存取一台redis】 mysql【多台tomcat伺服器存取一台伺服器上的mysql】)就滿足使用分散式鎖的條件。多台伺服器要存取redis全域快取的資源,如果不使用分散式鎖定就會出現問題。 看以下偽代碼:

long N=0L;
//N从redis获取值
if(N<5){
N++;
//N写回redis
}

上面的程式碼主要實現的功能:

從redis取得值N,對數值N進行邊界檢查,自加1,然後N寫回redis中。 這種應用場景很常見,像秒殺,全域遞增ID、IP存取限制等。

以IP訪問限制來說,惡意攻擊者可能發起無限次訪問,並發量比較大,分佈式環境下對N的邊界檢查就不可靠,因為從redis讀的N可能已經是髒數據。

傳統的加鎖的做法(如java的synchronized和Lock)也沒用,因為這是分散式環境,這個同步問題的救火隊員也束手無策。在這危急存亡之秋,分散式鎖終於有用武之地了。

分散式鎖定可以基於很多種方式實現,例如zookeeper、redis...。不管哪種方式,他的基本原理是不變的:用一個狀態值表示鎖,對鎖的佔用和釋放通過狀態值來標識。

這裡主要講如何用redis實作分散式鎖定。

三、使用redis的setNX指令實作分散式鎖定

#1、實作的原理

Redis為單一行程單執行緒模式,採用佇列模式將並發存取變成串行訪問,且多客戶端對Redis的連接並不存在競爭關係。 redis的SETNX指令可以方便的實作分散式鎖定。

2、基本指令解析

1)setNX(SET if Not eXists)

語法:

SETNX key value

將 key 的值設為 value ,當且僅當 key 不存在。

如果給定的 key 已經存在,則 SETNX 不做任何動作。

SETNX 是『SET if Not eXists』(如果不存在,則 SET)的簡稱

傳回值:

設定成功,傳回 1 。

設定失敗,回到 0 。

 範例:

redis> EXISTS job                # job 不存在
(integer) 0

redis> SETNX job "programmer"    # job 设置成功
(integer) 1

redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败
(integer) 0

redis> GET job                   # 没有被覆盖
"programmer"

所以我們使用執行下面的指令

SETNX lock.foo <current Unix time + lock timeout + 1>

如回傳1,則該客戶端獲得鎖,把lock.foo的鍵值設定為時間值表示該鍵已被鎖定,該客戶端最後可以透過DEL lock.foo來釋放該鎖。

如回傳0,表示該鎖已被其他客戶端取得,這時我們可以先返回或進行重試等對方完成或等待鎖定逾時。

2)getSET

語法:

GETSET key value

將給定 key 的值設為 value ,並傳回 key 的舊值(old value)。

當 key 存在但不是字串型別時,回傳一個錯誤。

傳回值:

傳回給定 key 的舊值。

當 key 沒有舊值時,也就是, key 不存在時,則回傳 nil 。

3)get

語法:

GET key

回傳值:

當 key 不存在時,回傳 nil ,否則,傳回 key 的值。

如果 key 不是字串型,那麼回傳一個錯誤

四、解決死鎖

上面的鎖定邏輯有一個問題:如果一個持有鎖的客戶端失敗或崩潰了不能釋放鎖,該怎麼解決?

我們可以透過鎖的鍵對應的時間戳來判斷這種情況是否發生了,如果當前的時間已經大於lock.foo的值,表示該鎖已失效,可以被重新使用。

發生這種情況時,可不能簡單的通過DEL來刪除鎖,然後再SETNX一次(講道理,刪除鎖的操作應該是鎖擁有這執行的,這裡只需要等它逾時即可),當多個客戶端偵測到鎖定逾時後都會嘗試去釋放它,這裡就可能出現一個競態條件,讓我們模擬一下這個場景: 

C0操作超時了,但它還持有鎖,C1和C2讀取lock.foo檢查時間戳,先後發現超時了。
C1 傳送DEL lock.foo
C1 傳送SETNX lock.foo 並且成功了。
C2 傳送DEL lock.foo
C2 傳送SETNX lock.foo 並且成功了。
這樣一來,C1,C2都拿到鎖了!問題大了!

幸好這種問題是可以避免的,讓我們來看看C3這個客戶端是怎麼做的: 

C3發送SETNX lock.foo 想要獲得鎖,由於C0還持有鎖,所以Redis回傳給C3一個0 

#

C3发送GET lock.foo 以检查锁是否超时了,如果没超时,则等待或重试。 

反之,如果已超时,C3通过下面的操作来尝试获得锁: 

GETSET lock.foo  

通过GETSET,C3拿到的时间戳如果仍然是超时的,那就说明,C3如愿以偿拿到锁了。 

如果在C3之前,有个叫C4的客户端比C3快一步执行了上面的操作,那么C3拿到的时间戳是个未超时的值,这时,C3没有如期获得锁,需要再次等待或重试。留意一下,尽管C3没拿到锁,但它改写了C4设置的锁的超时值,不过这一点非常微小的误差带来的影响可以忽略不计。

注意:为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作,因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。  

五、代码实现

expireMsecs 锁持有超时,防止线程在入锁以后,无限的执行下去,让锁无法释放 
timeoutMsecs 锁等待超时,防止线程饥饿,永远没有入锁执行代码的机会 

注意:项目里面需要先搭建好redis的相关配置

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis distributed lock implementation.
 *
 * @author zhengcanrui
 */
public class RedisLock {

    private static Logger logger = LoggerFactory.getLogger(RedisLock.class);

    private RedisTemplate redisTemplate;

    private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 100;

    /**
     * Lock key path.
     */
    private String lockKey;

    /**
     * 锁超时时间,防止线程在入锁以后,无限的执行等待
     */
    private int expireMsecs = 60 * 1000;

    /**
     * 锁等待时间,防止线程饥饿
     */
    private int timeoutMsecs = 10 * 1000;

    private volatile boolean locked = false;

    /**
     * Detailed constructor with default acquire timeout 10000 msecs and lock expiration of 60000 msecs.
     *
     * @param lockKey lock key (ex. account:1, ...)
     */
    public RedisLock(RedisTemplate redisTemplate, String lockKey) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey + "_lock";
    }

    /**
     * Detailed constructor with default lock expiration of 60000 msecs.
     *
     */
    public RedisLock(RedisTemplate redisTemplate, String lockKey, int timeoutMsecs) {
        this(redisTemplate, lockKey);
        this.timeoutMsecs = timeoutMsecs;
    }

    /**
     * Detailed constructor.
     *
     */
    public RedisLock(RedisTemplate redisTemplate, String lockKey, int timeoutMsecs, int expireMsecs) {
        this(redisTemplate, lockKey, timeoutMsecs);
        this.expireMsecs = expireMsecs;
    }

    /**
     * @return lock key
     */
    public String getLockKey() {
        return lockKey;
    }

    private String get(final String key) {
        Object obj = null;
        try {
            obj = redisTemplate.execute(new RedisCallback<Object>() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException {
                    StringRedisSerializer serializer = new StringRedisSerializer();
                    byte[] data = connection.get(serializer.serialize(key));
                    connection.close();
                    if (data == null) {
                        return null;
                    }
                    return serializer.deserialize(data);
                }
            });
        } catch (Exception e) {
            logger.error("get redis error, key : {}", key);
        }
        return obj != null ? obj.toString() : null;
    }

    private boolean setNX(final String key, final String value) {
        Object obj = null;
        try {
            obj = redisTemplate.execute(new RedisCallback<Object>() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException {
                    StringRedisSerializer serializer = new StringRedisSerializer();
                    Boolean success = connection.setNX(serializer.serialize(key), serializer.serialize(value));
                    connection.close();
                    return success;
                }
            });
        } catch (Exception e) {
            logger.error("setNX redis error, key : {}", key);
        }
        return obj != null ? (Boolean) obj : false;
    }

    private String getSet(final String key, final String value) {
        Object obj = null;
        try {
            obj = redisTemplate.execute(new RedisCallback<Object>() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException {
                    StringRedisSerializer serializer = new StringRedisSerializer();
                    byte[] ret = connection.getSet(serializer.serialize(key), serializer.serialize(value));
                    connection.close();
                    return serializer.deserialize(ret);
                }
            });
        } catch (Exception e) {
            logger.error("setNX redis error, key : {}", key);
        }
        return obj != null ? (String) obj : null;
    }

    /**
     * 获得 lock.
     * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
     * reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
     * 执行过程:
     * 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
     * 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
     *
     * @return true if lock is acquired, false acquire timeouted
     * @throws InterruptedException in case of thread interruption
     */
    public synchronized boolean lock() throws InterruptedException {
        int timeout = timeoutMsecs;
        while (timeout >= 0) {
            long expires = System.currentTimeMillis() + expireMsecs + 1;
            String expiresStr = String.valueOf(expires); //锁到期时间
            if (this.setNX(lockKey, expiresStr)) {
                // lock acquired
                locked = true;
                return true;
            }

            String currentValueStr = this.get(lockKey); //redis里的时间
            if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
                //判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
                // lock is expired

                String oldValueStr = this.getSet(lockKey, expiresStr);
                //获取上一个锁到期时间,并设置现在的锁到期时间,
                //只有一个线程才能获取上一个线上的设置时间,因为jedis.getSet是同步的
                if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
                    //防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为什么相差了很少的时间,所以可以接受

                    //[分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
                    // lock acquired
                    locked = true;
                    return true;
                }
            }
            timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS;

            /*
                延迟100 毫秒,  这里使用随机时间可能会好一点,可以防止饥饿进程的出现,即,当同时到达多个进程,
                只会有一个进程获得锁,其他的都用同样的频率进行尝试,后面有来了一些进行,也以同样的频率申请锁,这将可能导致前面来的锁得不到满足.
                使用随机的等待时间可以一定程度上保证公平性
             */
            Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS);

        }
        return false;
    }


    /**
     * Acqurired lock release.
     */
    public synchronized void unlock() {
        if (locked) {
            redisTemplate.delete(lockKey);
            locked = false;
        }
    }

}

调用:

RedisLock lock = new RedisLock(redisTemplate, key, 10000, 20000);
 try {
            if(lock.lock()) {
                   //需要加锁的代码
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            //为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作,因为可能客户端因为某个耗时的操作而挂起,
            //操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。 ————这里没有做
            lock.unlock();
        }

六、一些问题

1、为什么不直接使用expire设置超时时间,而将时间的毫秒数其作为value放在redis中?

如下面的方式,把超时的交给redis处理:

lock(key, expireSec){
isSuccess = setnx key
if (isSuccess)
expire key expireSec
}

这种方式貌似没什么问题,但是假如在setnx后,redis崩溃了,expire就没有执行,结果就是死锁了。锁永远不会超时。

 2、为什么前面的锁已经超时了,还要用getSet去设置新的时间戳的时间获取旧的值,然后和外面的判断超时时间的时间戳比较呢?

redis分散式鎖定實作方法介紹

因为是分布式的环境下,可以在前一个锁失效的时候,有两个进程进入到锁超时的判断。如:

C0超时了,还持有锁,C1/C2同时请求进入了方法里面

C1/C2获取到了C0的超时时间

C1使用getSet方法

C2也执行了getSet方法

假如我们不加 oldValueStr.equals(currentValueStr) 的判断,将会C1/C2都将获得锁,加了之后,能保证C1和C2只能一个能获得锁,一个只能继续等待。

注意:这里可能导致超时时间不是其原本的超时时间,C1的超时时间可能被C2覆盖了,但是他们相差的毫秒及其小,这里忽略了。

更多redis知识请关注redis入门教程栏目。

以上是redis分散式鎖定實作方法介紹的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:cnblogs.com。如有侵權,請聯絡admin@php.cn刪除