Redis实现分布式锁(利用分布式锁,实现分布式定时任务)

简述

利用Redis的Setnx命令,来实现一个分布式的加锁方案。利用注解,在拥有该注解的方法上,进行切面处理,在方法执行前,进行加锁,执行结束后,根据是否自动释放锁,进行解锁。
将该注解用在定时任务的方法上,即可实现分布式定时任务,即获取到锁的方法,才会执行。

1 redis命令

1.1 setnx命令

  • Redis setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。(该命令无法设置过期时间)
    Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。redis的SETNX命令可以方便的实现分布式锁。
    当某一个客户端将key的值设置成功后,其他的客户端再进行设置,将返回失败,保证同一时间,只有一个客户端能够设置成功。
  • Redis事务
    watch key1 key2 ... : 监视一或多个key,如果在事务执行之前,被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
    multi : 标记一个事务块的开始( queued )
    exec : 执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 ) 
    discard : 取消事务,放弃事务块中的所有命令
    unwatch : 取消watch对所有key的监控
# 事务正常使用
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> exec

# 取消事务
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> discard

# watch使用
# number初始为10
127.0.0.1:6379> watch number
127.0.0.1:6379> multi
127.0.0.1:6379> set number 11
127.0.0.1:6379> exec
# 如果在执行exec时,number没有被其他客户端修改,还是10,则事务执行成功;
# 如果被其他客户端修改了,number不是10了,则事务执行失败,这时候就需求程序自行处理,进行再次提交或者其他操作

  • 在spring boot 中,我们用StringRedisTemplate来操作Redis,它的方法:stringRedisTemplate.opsForValue().setIfAbsent()方法即对应setnx命令,这个方法有两个重载的方法:
    1、Boolean setIfAbsent(K key, V value); 设置key value,返回成功/失败
    2、Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit); 设置key value,返回成功/失败,同时设置过期时间,redisTemplate 会调用 EXPIRE进行过期时间的设定,同时在设置值和过期时间时,会开启事务,保存全部成功。
// org.springframework.data.redis.core 中实现的方法
@Override
public Boolean setIfAbsent(K key, V value) {

    byte[] rawKey = rawKey(key);
    byte[] rawValue = rawValue(value);
    return execute(connection -> connection.setNX(rawKey, rawValue), true);
}

@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {

    byte[] rawKey = rawKey(key);
    byte[] rawValue = rawValue(value);

    Expiration expiration = Expiration.from(timeout, unit);
    return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
    }

1.2 DEL命令、lua脚本

在加锁之后,解锁时,需要判断锁,是否是当前线程所拥有的,如果是当前线程拥有的,则删除该key,删除key,用del命令。

  • del key_name

我们会先取出key对应的值,然后判断是否和当前线程的定义的值一致。如果一致,则说明是该线程拥有的key。如果我们在代码中取出key的值,然后判断通过后,调用redis del 删除key,这就不是一个原子操作了。如果在我们取出key的值后,然后在删除前,其他线程获取了锁,当前线程删除的动作,就会导致删除其他线程拥有的锁。所以释放锁,需要利用lua脚本进行,将判断和删除,这两个动作,合为一个原子性的操作。
所以我们会利用代码去执行下面的lua脚本,保证判断和删除的原子性。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

一般教程中,利用RedisTemplate来执行lua脚本时,会将lua脚本放到静态资源目录中。而在下面的代码中,利用ByteArrayResource直接从String字符串中读取了lua脚本内容:

    /*
     * 保存lua脚本
     */
    private DefaultRedisScript getRedisScript;

    @PostConstruct
    public void init(){
        // 定义lua脚本资源
        // 也可以放到文件中,加载进来: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
        String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return   redis.call('del',KEYS[1])  else return 0 end";
        ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());

        getRedisScript = new DefaultRedisScript<>();
        getRedisScript.setResultType(List.class);
        getRedisScript.setScriptSource(new ResourceScriptSource(resource));
    }

2 分布式锁实现

下面是实现的核心类:

  • RedisLock: reids分布式锁工具类
  • EmLock: 分布式锁注解
  • LockRangeEnum: 分布式锁的范围枚举
  • EmLockAspect: 分布式锁切面

2.1 RedisLock,reids分布式锁工具类

代码如下:

package com.emdata.lowvis.common.redislock;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

/**
 * reids分布式锁工具类
 *
 * @version 1.0
 * @date 2020/12/8 14:37
 */
@Slf4j
@Component
public class RedisLock {

    private static final String SPLIT = "_";

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 加锁解锁工具类
     * @param lockKey 加锁的key
     * @param uuid 线程的标志
     * @param timeout 超时时间
     * @param timeUnit 超时时间粒度
     * @return true:获取成功
     */
    public boolean lock(String lockKey, String uuid, long timeout, TimeUnit timeUnit) {
        // 根据key获取值
        String currentLock = stringRedisTemplate.opsForValue().get(lockKey);

        // 值为:uuid_时间
        String value = uuid + SPLIT + (timeUnit.toMillis(timeout) + System.currentTimeMillis());

        // 如果为空,则设置值
        if (StringUtils.isEmpty(currentLock)) {
            if (stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, timeout, timeUnit)) {
                // 对应setnx命令,可以成功设置,也就是key不存在,获得锁成功
                return true;
            } else {
                return false;
            }
        } else {
            // 可重入锁,如果是这个uuid持有的锁,则更新时间
            if (currentLock.startsWith(uuid)) {
                stringRedisTemplate.opsForValue().set(lockKey, value, timeout, timeUnit);
                return true;
            } else {
                return false;
            }
        }
    }
    
    /*
     * 保存lua脚本
     */
    private DefaultRedisScript getRedisScript;

    @PostConstruct
    public void init(){
        // 定义lua脚本资源
        // 也可以放到文件中,加载进来: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
        String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return   redis.call('del',KEYS[1])  else return 0 end";
        ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());

        getRedisScript = new DefaultRedisScript<>();
        getRedisScript.setResultType(List.class);
        getRedisScript.setScriptSource(new ResourceScriptSource(resource));
    }

    /**
     * 释放锁
     *
     * @param lockKey 加锁的key
     * @param uuid 线程的标志
     */
    public void release(String lockKey, String uuid) {
        try {
            List execute = stringRedisTemplate.execute(getRedisScript, Collections.singletonList(lockKey), uuid);
            log.debug("解锁结果: {}", execute.get(0) == 0);
        } catch (Exception e) {
            log.error("解锁异常, key: {}, uuid: {}", lockKey, uuid);
            log.error("", e);
        }
    }

}

2.2 EmLock,分布式锁注解

package com.emdata.lowvis.common.redislock;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * 分布式锁注解
 *
 * @version 1.0
 * @date 2020/12/8 17:59
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface EmLock {

    /**
     * 锁的范围,默认应用级别
     * @return 锁的范围
     */
    LockRangeEnum lockRange() default LockRangeEnum.APPLICATION;

    /**
     * 锁对应的key
     * @return key
     */
    String key();

    /**
     * 锁超时时间
     * @return 时间
     */
    int timeout() default 5;

    /**
     * 锁超时时间粒度
     * @return 粒度
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 是否自动释放锁
     * @return true: 方法完成后,自动释放
     */
    boolean autoRelease() default true;
}

2.3 LockRangeEnum, 分布式锁的范围枚举

package com.emdata.lowvis.common.redislock;

/**
 * 分布式锁的范围枚举
 *
 * @author pupengfei
 * @version 1.0
 * @date 2020/12/10 13:46
 */
public enum LockRangeEnum {

    /**
     * 应用级别,锁的级别在整个应用容器内
     */
    APPLICATION,

    /**
     * 线程级别,锁的级别在每个线程
     */
    THREAD

}

2.4 EmLockAspect,分布式锁切面

package com.emdata.lowvis.common.redislock;

import com.emdata.lowvis.common.utils.UUIDUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * 分布式锁切面
 *
 * @version 1.0
 * @date 2020/12/8 17:59
 */
@Slf4j
@Component
@Aspect
@Configuration
public class EmLockAspect {

    @Autowired
    private RedisLock redisLock;

    /**
     * 应用级别的容器的id
     */
    private final String appUUID = UUIDUtils.get();

    /**
     * 线程级别的线程的id
     */
    private final ThreadLocal threadUUID = ThreadLocal.withInitial(UUIDUtils::get);

    /**
     * 定义切点
     */
    @Pointcut("@annotation(com.emdata.lowvis.common.redislock.EmLock)")
    public void lockAop() {

    }

    @Around("lockAop()")
    public Object doAround(ProceedingJoinPoint point) throws Throwable {
        // 获取方法
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();

        // 看有没有日志注解
        EmLock emLock = method.getAnnotation(EmLock.class);
        if (emLock == null) {
            return point.proceed();
        }

        // 获取锁的级别
        LockRangeEnum lockRangeEnum = emLock.lockRange();
        String uuid = lockRangeEnum == LockRangeEnum.APPLICATION ? appUUID : threadUUID.get();

        // 获取锁的key和超时时间
        String key = emLock.key();
        int timeout = emLock.timeout();
        TimeUnit timeUnit = emLock.timeUnit();

        // 加锁
        boolean lock = redisLock.lock(key, uuid, timeout, timeUnit);

        Object proceed = null;

        try {
            if (lock) {
                log.info("获取到锁,继续执行...");
                // 继续执行
                proceed = point.proceed();
            }
        } finally {
            // 自动释放,则释放锁
            if (emLock.autoRelease()) {
                redisLock.release(key, uuid);
            }
        }

        return proceed;
    }

}

3 使用示例

3.1 使用RedisLock

      @Autowired
    private RedisLock redisLock;

    public void useLock() {
        // 定义锁的key
        String lockKey = "camera_update_key";
        String uuid = UUIDUtils.get();

        // 定义超时时间
        long timeout = 5;
        TimeUnit timeUnit = TimeUnit.SECONDS;

        // 加锁
        boolean lock = redisLock.lock(lockKey, uuid, timeout, timeUnit);
        try {
            if (lock) {
                log.info("执行...");
            } else {
                throw new IllegalStateException("未获取到锁,放弃执行");
            }
        } finally {
            // 在finally里面进行解锁
            redisLock.release(lockKey, uuid);
        }
    }

3.2 使用EmLock

@Component
@Slf4j
public class ScheduleTask {

    /**
     * 用在定时任务方法上,锁的key为test_lock,指定了超时时间为2秒钟
     * 锁的级别为默认的应用级别(LockRangeEnum.APPLICATION),在这个如果应用启动了多个容器运行,在只会有一个容器获取到锁,
     * 自动释放锁为false,即方法执行完成后,也不会自动释放锁,只有到超时时间了,锁才会释放
     */
    @Scheduled(cron = "0 0/1 * * * ? ")
    @EmLock(key = "test_lock", timeout = 2, timeUnit = TimeUnit.SECONDS, autoRelease = false)
    public void recordUpdateTask() {
        log.info("执行任务.......");
    }
   
   /**
     * 用在普通的方法上,锁的key为method_Lock,指定了超时时间为1分钟,
     * 锁的级别为默认的线程级别,在该应用内多个线程执行该方法,则只会有一个线程获取到锁
     * 如果启动了多个应用容器,同样多个容器内的所有线程,也只会有一个线程获取到锁
     */
    @EmLock(key = "method_Lock", timeout = 1, timeUnit = TimeUnit.MINUTES, lockRange = LockRangeEnum.THREAD)
    public void recordUpdate() {
        log.info("执行任务2.......");
    }
}

4 使用注意

  • 使用Redis作为分布式锁的实现,依赖于Redis服务,如果Redis服务无法正常访问,则会导致整个方法无法执行。
  • 如果EmLock注解用在定时任务上时,如果应用运行在不同的服务器上,或者不同的docker容器里面时,必须保证运行环境的时间一致。
  • 如果设置了定时任务上面的锁,不是自动释放的,则运行环境的时间,相差不大于锁超时时间的时候,也可以保证定时任务,唯一执行。因为在超时时间范围内,某个应用容器持有该锁,其他应用来获取锁时,同样获取不到,方法不会执行。

你可能感兴趣的:(Redis实现分布式锁(利用分布式锁,实现分布式定时任务))