基于LUA脚本的Redis分布式锁(SpringBoot实现)

1.前言

Redis实现分布式锁,本身比较简单,就是Redis中一个简单的KEY。一般都利用setnx(set if not exists)指令可以非常简单的实现加锁,锁用完后,再调用del指令释放锁。要确保锁可用,一般需要解决几个问题:

  1. 不能出现死锁情况,一个获得锁的客户端宕机或者异常后,要保障其他客户端也能获得锁。
  2. 应用程序通过网络与Redis交互,为避免网络延迟以及获取锁线程与其他线程不冲突,需要保障锁操作的原子性,既同一时间只有一个客户端可用获取到锁。
  3. 加锁和解锁的客户端必须是同一个,不能把其他客户端加的锁给解了。
  4. 考虑容错性,如果一个客户端加锁成功后,Redis集群Master宕掉并没有及时同步,另外一个客户端加锁会立即成功,避免同一把锁被两个客户端持有。

2.解决思路

  1. 死锁问题,通常是在拿到锁后给锁设置一个过期时间(expire指令),即使出现异常,在过期时间后,锁也会自动释放
  2. 原子性问题通常的两个解决方式:
    • 通过redis2.8版本后加入的set扩展参数,将加锁和设置过期时间作为一个原子操作,目前发现不是所有Java的Redis客户端都支持这样的set指令
    set lock:test true ex 5 nx
    
    • LUA脚本,Redis Lua脚本可以保证多条指令的原子性执行
  3. 释放其他客户端锁,通过在加锁的时候指定随机值,在解锁的时候用这个随机值去匹配,匹配成功则解锁,匹配失败就不能解锁,因为锁可能已经过期或者已经被其他客户端占用
  4. Redis集群宕掉的极端情况下,可以考虑redlock算法,但是算法本身复杂,而且带来一些性能损耗,可以根据实际场景判断,是否非常在乎这样的高可用

3.SpringBoot实现

3.1 LUA脚本

本实现基于SpringBoot2x,考虑SpringBoot2x中Redis的默认连接是由lettuce提供,不是常用的Jedis,同时考虑不同版本的Redis,加锁和解锁都采用LUA脚本。

 -- 加锁脚本,其中KEYS[]为外部传入参数
 -- KEYS[1]表示key
 -- KEYS[2]表示value
 -- KEYS[3]表示过期时间
 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执行LUA脚本,同时考虑重试机制

    /**
     * 加锁
     * @param key Key
     * @param timeout 过期时间
     * @param retryTimes 重试次数
     * @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脚本参数
            List<String> keys = Arrays.asList(redisKey, requestId, String.valueOf(timeout));
            //执行脚本
            Long 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 if (retryTimes == 0) {
                //重试次数为0直接返回失败
                return false;
            } else {
                //重试获取锁
                logger.info("retry to acquire lock:" + Thread.currentThread().getName() + ", Status code reply:" + result);
                int count = 0;
                while(true) {
                    try {
                        //休眠一定时间后再获取锁,这里时间可以通过外部设置
                        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根据传入的key加上一个前缀生成锁的key
     /**
      * 获取RedisKey
      * @param key 原始KEY,如果为空,自动生成随机KEY
      * @return
      */
     private String getRedisKey(String key) {
         //如果Key为空且线程已经保存,直接用,异常保护
         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用于为每一个加锁请求生成请求ID,内部方法
     /**
      * 获取随机请求ID
      * @return
      */
     private String getRequestId() {
         return UUID.randomUUID().toString();
     }
    
  3. redisTemplate.execute(LOCK_LUA_SCRIPT, keys),execute最终调用的RedisConnection的eval方法将LUA脚本交给Redis服务端执行,可兼容springboot中不同redis客户端实现(Jedis、Lettuce等)。这个操作通过setnx设置锁key,成功后设置锁的有效期,成功返回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. 根据脚本执行情况,将锁的key和requestId分别存入线程本地变量localKeys和localRequestIds中,两个都是ThreadLocal变量,通过两个变量在释放锁的时候避免释放其他客户端占用的锁。
  5. 根据重试次数retryTimes值进行重试判断,如果为0则不重试,否则进入重试逻辑。
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脚本参数
            List<String> keys = Arrays.asList(redisKey, localRequestIds.get());
            logger.debug("unlock :::: redisKey = " + redisKey + " requestid = " + localRequestIds.get());
            // 使用lua脚本删除redis中匹配value的key,可以避免由于方法执行时间过长而redis锁自动过期失效的时候误删其他线程的锁
            Long result = redisTemplate.execute(UNLOCK_LUA_SCRIPT, keys);
            //如果这里抛异常,后续锁无法释放
            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不存在,可能已经过期或被其他线程加锁
                // 一般发生在key的过期时间短于业务处理时间,属于正常可接受情况
                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服务端执行。UNLOCK_LUA_SCRIPT常量定义,先判断key值是否与传入的requestId一致,如果一致则删除key,如果不一致返回-1表示key可能已经过期或被其他客户端占用,避免误删。
     //定义释放锁的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做清理工作
     /**
      * 清除本地线程变量,防止内存泄露
      */
     private void clean() {
         localRequestIds.remove();
         localKeys.remove();
     }
    

4.后记

  1. 可将锁改成注解方式,通过AOP降低锁使用的复杂度
  2. 重试机制可以根据业务情况进行优化
  3. 可以更进一步借助ThreadLocal保存锁计数器可实现类似ReentrantLock可重入锁机制
  4. 释放锁失败后可以加入回调方法进行一些业务处理
  5. 如果业务挂起或者执行时间过长,超过了锁的超时时间,另外的客户端可能提前获取到锁,导致临界区代码不能严格的串行执行。除了合理设置锁超时时间外,尽量不要把分布式锁用于执行时间长的任务

5.补充

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自动配置的RedisTemplate,参加RedisAutoConfiguration
 * 这个类没有设置序列化方式等
 * @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设置为字符串序列化器
        template.setKeySerializer(stringRedisSerializer);
        //Hash结构的key设置为字符串序列化器
        template.setHashKeySerializer(stringRedisSerializer);
        //普通值和hash的值都设置为jackson序列化器
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}
5.2 简单测试类
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 测试类
 * @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("获取锁成功,继续执行任务" + 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>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,110评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,443评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,474评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,881评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,902评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,698评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,418评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,332评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,796评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,968评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,110评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,792评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,455评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,003评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,130评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,348评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,047评论 2 355

推荐阅读更多精彩内容