基于LUA腳本的Redis分布式鎖(SpringBoot實(shí)現(xiàn))

1.前言

Redis實(shí)現(xiàn)分布式鎖,本身比較簡(jiǎn)單弄息,就是Redis中一個(gè)簡(jiǎn)單的KEY痊班。一般都利用setnx(set if not exists)指令可以非常簡(jiǎn)單的實(shí)現(xiàn)加鎖,鎖用完后疑枯,再調(diào)用del指令釋放鎖辩块。要確保鎖可用,一般需要解決幾個(gè)問題:

  1. 不能出現(xiàn)死鎖情況荆永,一個(gè)獲得鎖的客戶端宕機(jī)或者異常后废亭,要保障其他客戶端也能獲得鎖。
  2. 應(yīng)用程序通過網(wǎng)絡(luò)與Redis交互具钥,為避免網(wǎng)絡(luò)延遲以及獲取鎖線程與其他線程不沖突豆村,需要保障鎖操作的原子性,既同一時(shí)間只有一個(gè)客戶端可用獲取到鎖骂删。
  3. 加鎖和解鎖的客戶端必須是同一個(gè)掌动,不能把其他客戶端加的鎖給解了。
  4. 考慮容錯(cuò)性宁玫,如果一個(gè)客戶端加鎖成功后粗恢,Redis集群Master宕掉并沒有及時(shí)同步,另外一個(gè)客戶端加鎖會(huì)立即成功欧瘪,避免同一把鎖被兩個(gè)客戶端持有眷射。

2.解決思路

  1. 死鎖問題,通常是在拿到鎖后給鎖設(shè)置一個(gè)過期時(shí)間(expire指令)佛掖,即使出現(xiàn)異常妖碉,在過期時(shí)間后,鎖也會(huì)自動(dòng)釋放
  2. 原子性問題通常的兩個(gè)解決方式:
    • 通過redis2.8版本后加入的set擴(kuò)展參數(shù)芥被,將加鎖和設(shè)置過期時(shí)間作為一個(gè)原子操作欧宜,目前發(fā)現(xiàn)不是所有Java的Redis客戶端都支持這樣的set指令
    set lock:test true ex 5 nx
    
    • LUA腳本,Redis Lua腳本可以保證多條指令的原子性執(zhí)行
  3. 釋放其他客戶端鎖拴魄,通過在加鎖的時(shí)候指定隨機(jī)值冗茸,在解鎖的時(shí)候用這個(gè)隨機(jī)值去匹配,匹配成功則解鎖匹中,匹配失敗就不能解鎖夏漱,因?yàn)殒i可能已經(jīng)過期或者已經(jīng)被其他客戶端占用
  4. Redis集群宕掉的極端情況下,可以考慮redlock算法职员,但是算法本身復(fù)雜,而且?guī)硪恍┬阅軗p耗跛溉,可以根據(jù)實(shí)際場(chǎng)景判斷焊切,是否非常在乎這樣的高可用

3.SpringBoot實(shí)現(xiàn)

3.1 LUA腳本

本實(shí)現(xiàn)基于SpringBoot2x扮授,考慮SpringBoot2x中Redis的默認(rèn)連接是由lettuce提供,不是常用的Jedis专肪,同時(shí)考慮不同版本的Redis刹勃,加鎖和解鎖都采用LUA腳本。

 -- 加鎖腳本嚎尤,其中KEYS[]為外部傳入?yún)?shù)
 -- KEYS[1]表示key
 -- KEYS[2]表示value
 -- KEYS[3]表示過期時(shí)間
 if redis.call("setnx", KEYS[1], KEYS[2]) == 1 then
     return redis.call("pexpire", KEYS[1], KEYS[3])
 else
     return 0
 -- 解鎖腳本
 -- KEYS[1]表示key
 -- KEYS[2]表示value
 -- return -1 表示未能獲取到key或者key的值與傳入的值不相等
 if redis.call("get",KEYS[1]) == KEYS[2] then
     return redis.call("del",KEYS[1])
 else
     return -1
3.2 加鎖代碼

依賴SpringBoot的RedisTemplate執(zhí)行LUA腳本荔仁,同時(shí)考慮重試機(jī)制

    /**
     * 加鎖
     * @param key Key
     * @param timeout 過期時(shí)間
     * @param retryTimes 重試次數(shù)
     * @return
     */
    public boolean lock(String key, long timeout, int retryTimes) {
        try {
            final String redisKey = this.getRedisKey(key);
            final String requestId = this.getRequestId();
            logger.debug("lock :::: redisKey = " + redisKey + " requestid = " + requestId);
            //組裝lua腳本參數(shù)
            List<String> keys = Arrays.asList(redisKey, requestId, String.valueOf(timeout));
            //執(zhí)行腳本
            Long result = redisTemplate.execute(LOCK_LUA_SCRIPT, keys);
            //存儲(chǔ)本地變量
            if(!StringUtils.isEmpty(result) && result == LOCK_SUCCESS) {
                localRequestIds.set(requestId);
                localKeys.set(redisKey);
                logger.info("success to acquire lock:" + Thread.currentThread().getName() + ", Status code reply:" + result);
                return true;
            } else if (retryTimes == 0) {
                //重試次數(shù)為0直接返回失敗
                return false;
            } else {
                //重試獲取鎖
                logger.info("retry to acquire lock:" + Thread.currentThread().getName() + ", Status code reply:" + result);
                int count = 0;
                while(true) {
                    try {
                        //休眠一定時(shí)間后再獲取鎖,這里時(shí)間可以通過外部設(shè)置
                        Thread.sleep(100);
                        result = redisTemplate.execute(LOCK_LUA_SCRIPT, keys);
                        if(!StringUtils.isEmpty(result) && result == LOCK_SUCCESS) {
                            localRequestIds.set(requestId);
                            localKeys.set(redisKey);
                            logger.info("success to acquire lock:" + Thread.currentThread().getName() + ", Status code reply:" + result);
                            return true;
                        } else {
                            count++;
                            if (retryTimes == count) {
                                logger.info("fail to acquire lock for " + Thread.currentThread().getName() + ", Status code reply:" + result);
                                return false;
                            } else {
                                logger.warn(count + " times try to acquire lock for " + Thread.currentThread().getName() + ", Status code reply:" + result);
                                continue;
                            }
                        }
                    } catch (Exception e) {
                        logger.error("acquire redis occured an exception:" + Thread.currentThread().getName(), e);
                        break;
                    }
                }
            }
        } catch (Exception e1) {
            logger.error("acquire redis occured an exception:" + Thread.currentThread().getName(), e1);
        }
        return false;
    }
  1. getRedisKey根據(jù)傳入的key加上一個(gè)前綴生成鎖的key
     /**
      * 獲取RedisKey
      * @param key 原始KEY芽死,如果為空乏梁,自動(dòng)生成隨機(jī)KEY
      * @return
      */
     private String getRedisKey(String key) {
         //如果Key為空且線程已經(jīng)保存,直接用关贵,異常保護(hù)
         if (StringUtils.isEmpty(key) && !StringUtils.isEmpty(localKeys.get())) {
             return localKeys.get();
         }
         //如果都是空那就拋出異常
         if (StringUtils.isEmpty(key) && StringUtils.isEmpty(localKeys.get())) {
             throw new RuntimeException("key is null");
         }
         return LOCK_PREFIX + key;
     }
    
  2. getRequestId用于為每一個(gè)加鎖請(qǐng)求生成請(qǐng)求ID遇骑,內(nèi)部方法
     /**
      * 獲取隨機(jī)請(qǐng)求ID
      * @return
      */
     private String getRequestId() {
         return UUID.randomUUID().toString();
     }
    
  3. redisTemplate.execute(LOCK_LUA_SCRIPT, keys),execute最終調(diào)用的RedisConnection的eval方法將LUA腳本交給Redis服務(wù)端執(zhí)行揖曾,可兼容springboot中不同redis客戶端實(shí)現(xiàn)(Jedis落萎、Lettuce等)。這個(gè)操作通過setnx設(shè)置鎖key炭剪,成功后設(shè)置鎖的有效期练链,成功返回1,失敗返回0奴拦,其中LOCK_LUA_SCRIPT為常量定義
     //定義獲取鎖的lua腳本
     private final static DefaultRedisScript<Long> LOCK_LUA_SCRIPT = new DefaultRedisScript<>(
             "if redis.call(\"setnx\", KEYS[1], KEYS[2]) == 1 then return redis.call(\"pexpire\", KEYS[1], KEYS[3]) else return 0 end"
             , Long.class
     );
    
  4. 根據(jù)腳本執(zhí)行情況媒鼓,將鎖的key和requestId分別存入線程本地變量localKeys和localRequestIds中,兩個(gè)都是ThreadLocal變量粱坤,通過兩個(gè)變量在釋放鎖的時(shí)候避免釋放其他客戶端占用的鎖隶糕。
  5. 根據(jù)重試次數(shù)retryTimes值進(jìn)行重試判斷,如果為0則不重試站玄,否則進(jìn)入重試邏輯枚驻。
3.3 解鎖代碼
    /**
     * 釋放KEY
     * @param key
     * @return
     */
    public boolean unlock(String key) {
        try {
            String localKey = localKeys.get();
            //如果本地線程沒有KEY,說明還沒加鎖株旷,不能釋放
            if(StringUtils.isEmpty(localKey)) {
                logger.error("release lock occured an error: lock key not found");
                return false;
            }
            String redisKey = getRedisKey(key);
            //判斷KEY是否正確再登,不能釋放其他線程的KEY
            if(!StringUtils.isEmpty(localKey) && !localKey.equals(redisKey)) {
                logger.error("release lock occured an error: illegal key:" + key);
                return false;
            }
            //組裝lua腳本參數(shù)
            List<String> keys = Arrays.asList(redisKey, localRequestIds.get());
            logger.debug("unlock :::: redisKey = " + redisKey + " requestid = " + localRequestIds.get());
            // 使用lua腳本刪除redis中匹配value的key,可以避免由于方法執(zhí)行時(shí)間過長而redis鎖自動(dòng)過期失效的時(shí)候誤刪其他線程的鎖
            Long result = redisTemplate.execute(UNLOCK_LUA_SCRIPT, keys);
            //如果這里拋異常晾剖,后續(xù)鎖無法釋放
            if (!StringUtils.isEmpty(result) && result == RELEASE_SUCCESS) {
                logger.info("release lock success:" + Thread.currentThread().getName() + ", Status code reply=" + result);
                return true;
            } else if (!StringUtils.isEmpty(result) && result == LOCK_EXPIRED) {
                //返回-1說明獲取到的KEY值與requestId不一致或者KEY不存在锉矢,可能已經(jīng)過期或被其他線程加鎖
                // 一般發(fā)生在key的過期時(shí)間短于業(yè)務(wù)處理時(shí)間,屬于正吵菥。可接受情況
                logger.warn("release lock exception:" + Thread.currentThread().getName() + ", key has expired or released. Status code reply=" + result);
            } else {
                //其他情況沽损,一般是刪除KEY失敗,返回0
                logger.error("release lock failed:" + Thread.currentThread().getName() + ", del key failed. Status code reply=" + result);
            }
        } catch (Exception e) {
            logger.error("release lock occured an exception", e);
        } finally {
            //清除本地變量
            this.clean();
        }
        return false;
    }
  1. 如果本地線程localKeys中無法獲取到key循头,或者獲取到的key與傳入的不一致绵估,解鎖失敗
  2. redisTemplate.execute(UNLOCK_LUA_SCRIPT, keys) 將LUA腳本交給Redis服務(wù)端執(zhí)行炎疆。UNLOCK_LUA_SCRIPT常量定義,先判斷key值是否與傳入的requestId一致国裳,如果一致則刪除key形入,如果不一致返回-1表示key可能已經(jīng)過期或被其他客戶端占用,避免誤刪缝左。
     //定義釋放鎖的lua腳本
     private final static DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>(
             "if redis.call(\"get\",KEYS[1]) == KEYS[2] then return redis.call(\"del\",KEYS[1]) else return -1 end"
             , Long.class
     );
    
  3. 最后通過clean做清理工作
     /**
      * 清除本地線程變量亿遂,防止內(nèi)存泄露
      */
     private void clean() {
         localRequestIds.remove();
         localKeys.remove();
     }
    

4.后記

  1. 可將鎖改成注解方式,通過AOP降低鎖使用的復(fù)雜度
  2. 重試機(jī)制可以根據(jù)業(yè)務(wù)情況進(jìn)行優(yōu)化
  3. 可以更進(jìn)一步借助ThreadLocal保存鎖計(jì)數(shù)器可實(shí)現(xiàn)類似ReentrantLock可重入鎖機(jī)制
  4. 釋放鎖失敗后可以加入回調(diào)方法進(jìn)行一些業(yè)務(wù)處理
  5. 如果業(yè)務(wù)掛起或者執(zhí)行時(shí)間過長渺杉,超過了鎖的超時(shí)時(shí)間蛇数,另外的客戶端可能提前獲取到鎖,導(dǎo)致臨界區(qū)代碼不能嚴(yán)格的串行執(zhí)行少办。除了合理設(shè)置鎖超時(shí)時(shí)間外苞慢,盡量不要把分布式鎖用于執(zhí)行時(shí)間長的任務(wù)

5.補(bǔ)充

5.1 RedisTemplate加載

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.io.Serializable;

/**
 * @Description Redis配置類,替代SpringBoot自動(dòng)配置的RedisTemplate英妓,參加RedisAutoConfiguration
 * 這個(gè)類沒有設(shè)置序列化方式等
 * @Author Gazza Jiang
 * @Date 2018/11/12 9:30
 * @Version 1.0
 */
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Serializable> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        //Jackson序列化器
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        //字符串序列化器
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        //普通Key設(shè)置為字符串序列化器
        template.setKeySerializer(stringRedisSerializer);
        //Hash結(jié)構(gòu)的key設(shè)置為字符串序列化器
        template.setHashKeySerializer(stringRedisSerializer);
        //普通值和hash的值都設(shè)置為jackson序列化器
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}
5.2 簡(jiǎn)單測(cè)試類
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import xyz.gazza.demo.redis.Application;
import xyz.gazza.demo.redis.lock.RedisLock;

import java.util.ArrayList;

/**
 * @Description 測(cè)試類
 * @Author Gazza Jiang
 * @Date 2018/11/12 13:29
 * @Version 1.0
 */
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {Application.class})
public class ApplicationTest {
    private final Logger logger = LoggerFactory.getLogger(ApplicationTest.class);
    @Autowired
    RedisLock redisLock;
    @Test
    public void testRedisLock() throws InterruptedException {
        ArrayList<Thread> list = new ArrayList<>();
        for(int i =0; i<10; i++) {
            //logger.info("線程開始");
            Thread t = new Thread() {
                @Override
                public void run() {
                    if (redisLock.lock("suaner")) {
                        try {
                            //成功獲取鎖
                            logger.info("獲取鎖成功挽放,繼續(xù)執(zhí)行任務(wù)" + Thread.currentThread().getName());
                            try {
                                Thread.sleep(10);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }catch (Exception e) {
                            logger.error("excepiotn ", e);
                        } finally {
                            redisLock.unlock("suaner");
                        }
                    }
                }
            };
            list.add(t);
            t.start();
        }
        for(Thread t : list) {
            t.join();
        }
        Thread.sleep(10000);
    }
}
5.3 pom依賴
 <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市蔓纠,隨后出現(xiàn)的幾起案子辑畦,更是在濱河造成了極大的恐慌,老刑警劉巖腿倚,帶你破解...
    沈念sama閱讀 207,113評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件纯出,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡敷燎,警方通過查閱死者的電腦和手機(jī)暂筝,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來硬贯,“玉大人焕襟,你說我怎么就攤上這事》贡” “怎么了鸵赖?”我有些...
    開封第一講書人閱讀 153,340評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長拄衰。 經(jīng)常有香客問我它褪,道長,這世上最難降的妖魔是什么翘悉? 我笑而不...
    開封第一講書人閱讀 55,449評(píng)論 1 279
  • 正文 為了忘掉前任茫打,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘老赤。我一直安慰自己饼煞,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評(píng)論 5 374
  • 文/花漫 我一把揭開白布诗越。 她就那樣靜靜地躺著,像睡著了一般息堂。 火紅的嫁衣襯著肌膚如雪嚷狞。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評(píng)論 1 284
  • 那天荣堰,我揣著相機(jī)與錄音床未,去河邊找鬼。 笑死振坚,一個(gè)胖子當(dāng)著我的面吹牛薇搁,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播渡八,決...
    沈念sama閱讀 38,442評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼啃洋,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了屎鳍?” 一聲冷哼從身側(cè)響起宏娄,我...
    開封第一講書人閱讀 37,105評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎逮壁,沒想到半個(gè)月后孵坚,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,601評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡窥淆,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評(píng)論 2 325
  • 正文 我和宋清朗相戀三年卖宠,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片忧饭。...
    茶點(diǎn)故事閱讀 38,161評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡扛伍,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出眷昆,到底是詐尸還是另有隱情蜒秤,我是刑警寧澤,帶...
    沈念sama閱讀 33,792評(píng)論 4 323
  • 正文 年R本政府宣布亚斋,位于F島的核電站作媚,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏帅刊。R本人自食惡果不足惜纸泡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望赖瞒。 院中可真熱鬧女揭,春花似錦蚤假、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至境蔼,卻和暖如春灶平,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背箍土。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評(píng)論 1 261
  • 我被黑心中介騙來泰國打工逢享, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人吴藻。 一個(gè)月前我還...
    沈念sama閱讀 45,618評(píng)論 2 355
  • 正文 我出身青樓瞒爬,卻偏偏與公主長得像,于是被迫代替她去往敵國和親沟堡。 傳聞我的和親對(duì)象是個(gè)殘疾皇子侧但,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評(píng)論 2 344

推薦閱讀更多精彩內(nèi)容