Redis的分布式锁实现方案

前言

在Java中,关于锁我想大家都很熟悉。在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它。
但是Java中的锁,只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?

分布式锁

分布式锁,是一种思想,它的实现方式有很多。比如,我们将沙滩当做分布式锁的组件,那么它看起来应该是这样的:

加锁
在沙滩上踩一脚,留下自己的脚印,就对应了加锁操作。其他进程或者线程,看到沙滩上已经有脚印,证明锁已被别人持有,则等待。

解锁
把脚印从沙滩上抹去,就是解锁的过程。

锁超时
为了避免死锁,我们可以设置一阵风,在单位时间后刮起,将脚印自动抹去。

分布式锁的实现有很多,比如基于数据库、memcached、Redis、系统文件、zookeeper等。它们的核心的理念跟上面的过程大致相同。

安全和可靠性保证

  • 安全属性:互斥,不管任何时候,只有一个客户端能持有同一个锁。
  • 效率属性A:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
  • 效率属性B:容错,只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。

为什么基于故障切换的方案不够好

为了理解我们想要提高的到底是什么,我们先看下当前大多数基于Redis的分布式锁三方库的现状。 用Redis来实现分布式锁最简单的方式就是在实例里创建一个键值,创建出来的键值一般都是有一个超时时间的(这个是Redis自带的超时特性),所以每个锁最终都会释放(参见前文属性2)。而当一个客户端想要释放锁时,它只需要删除这个键值即可。 表面来看,这个方法似乎很管用,但是这里存在一个问题:在我们的系统架构里存在一个单点故障,如果Redis的master节点宕机了怎么办呢?有人可能会说:加一个slave节点!在master宕机时用slave就行了!但是其实这个方案明显是不可行的,因为这种方案无法保证第1个安全互斥属性,因为Redis的复制是异步的。 总的来说,这个方案里有一个明显的竞争条件(race condition),举例来说:

  • 客户端A在master节点拿到了锁。
  • master节点在把A创建的key写入slave之前宕机了。
  • slave变成了master节点 4.B也得到了和A还持有的相同的锁(因为原来的slave里还没有A持有锁的信息)

当然,在某些特殊场景下,前面提到的这个方案则完全没有问题,比如在宕机期间,多个客户端允许同时都持有锁,如果你可以容忍这个问题的话,那用这个基于复制的方案就完全没有问题,否则的话我们还是建议你采用这篇文章里接下来要描述的方案。

采用单实例的正确实现

在讲述如何用其他方案突破单实例方案的限制之前,让我们先看下是否有什么办法可以修复这个简单场景的问题,因为这个方案其实如果可以忍受竞争条件的话是有望可行的,而且单实例来实现分布式锁是我们后面要讲的算法的基础。 要获得锁,要用下面这个命令: SET resource_name my_random_value NX PX 30000 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用) 这个key的值设为“my_random_value”。这个值必须在所有获取锁请求的客户端里保持唯一。 基本上这个随机值就是用来保证能安全地释放锁,我们可以用下面这个Lua脚本来告诉Redis:删除这个key当且仅当这个key存在而且值是我期望的那个值。

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

这个很重要,因为这可以避免误删其他客户端得到的锁,举个例子,一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁,用上面这个脚本可以保证每个客户单都用一个随机字符串’签名’了,这样每个锁就只能被获得锁的客户端删除了。

这个随机字符串应该用什么生成呢?我假设这是从/dev/urandom生成的20字节大小的字符串,但是其实你可以有效率更高的方案来保证这个字符串足够唯一。比如你可以用RC4加密算法来从/dev/urandom生成一个伪随机流。还有更简单的方案,比如用毫秒的unix时间戳加上客户端id,这个也许不够安全,但是也许在大多数环境下已经够用了。

key值的超时时间,也叫做”锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其他客户端能抢占锁之前可以执行任务的时间,这个时间从获取锁的时间点开始计算。 所以现在我们有很好的获取和释放锁的方式,在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题。

如何实现分布式锁

本文基于注解来实现分布式锁

项目结构图下图
在这里插入图片描述

RedisLock

/**
 * 加锁的注解
 */

@Target(value = {ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
@Documented
public @interface RedisLock {

    /**
     * 锁的名称
     */
    String name() default "";

    /**
     * 锁的value
     */
    String value() default "";

    /**
     * 过期时间(如果不设置默认60s)
     */
    int expireTime() default 60;


}

RedislockAspectHandler

/**
 * @FileName: KlockAspectHandler.java
 * @Description: 给添加@RedisLock切面加锁处理
 * @Author: kimwu
 * @Time: 2020-12-19 14:08:56
 */
@Aspect
@Component
@Order(0)
@Slf4j
public class RedislockAspectHandler {


    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Pointcut("@annotation(com.kimwu.kill.mall.redis.lock.annotation.RedisLock)")
    public void lockPointcut() {
        log.info("-------------------");
    }


    @Before("lockPointcut()")
    public Object around(JoinPoint joinPoint) throws Throwable {
        RedisLock redisLock = getKimLock(joinPoint);
        RedisLockModel redisLockModel = RedisLockModelProvider.get(joinPoint, redisLock);
        if (ObjectUtils.isAllFieldNull(redisLockModel)) {
            throw new RuntimeException("对象不可为空");
        }
        //执行加锁过程
        RedisDistributionLockPlus redisDistributionLockPlus = new RedisDistributionLockPlus(stringRedisTemplate);
        CurentMapUtils.currentThreadLock.put(CurentMapUtils.getCurrentLockId(), redisDistributionLockPlus);
        boolean lock = redisDistributionLockPlus.lock(redisLockModel.getName(), redisLockModel.getValue(), redisLockModel.getExpireTime());
        log.info("{} 获取锁结果 {}", redisLockModel, lock);
        return joinPoint;
    }

    @AfterReturning(pointcut = "lockPointcut()")
    public void afterReturning(JoinPoint joinPoint) throws Throwable {
        RedisLock redisLock = getKimLock(joinPoint);
        RedisLockModel redisLockModel = RedisLockModelProvider.get(joinPoint, redisLock);
        if (ObjectUtils.isAllFieldNull(redisLockModel)) {
            throw new RuntimeException("对象不可为空");
        }
        RedisDistributionLockPlus redisDistributionLockPlus = CurentMapUtils.currentThreadLock.get(CurentMapUtils.getCurrentLockId());

        boolean unlock = redisDistributionLockPlus.unlock(redisLockModel.getName(), redisLockModel.getValue());
        log.info("{} 释放锁结果 {}", redisLockModel, unlock);
    }

    @AfterThrowing(pointcut = "lockPointcut()", throwing = "e")
    public void afterThrowing(JoinPoint joinPoint, Throwable e) throws Throwable {
        RedisLock redisLock = getKimLock(joinPoint);
        RedisLockModel redisLockModel = RedisLockModelProvider.get(joinPoint, redisLock);
        if (ObjectUtils.isAllFieldNull(redisLockModel)) {
            throw new RuntimeException("对象不可为空");
        }
        RedisDistributionLockPlus redisDistributionLockPlus = CurentMapUtils.currentThreadLock.get(CurentMapUtils.getCurrentLockId());

        boolean unlock = redisDistributionLockPlus.unlock(redisLockModel.getName(), redisLockModel.getValue());
        log.info("{} 释放锁结果 {}", redisLockModel, unlock);
        throw e;
    }


    private RedisLock getKimLock(JoinPoint joinPoint) {
        RedisLock redisLock = null;
        //获得所在切点的该类的class对象,也就是UserController这个类的对象
        Class<?> aClass = joinPoint.getTarget().getClass();
        //获取该切点所在方法的名称
        String name = joinPoint.getSignature().getName();
        Class[] parameterTypes = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameterTypes();
        try {
            //通过反射获得该方法
            Method method = aClass.getMethod(name, parameterTypes);
            //获得该注解
            redisLock = method.getAnnotation(RedisLock.class);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        return redisLock;
    }
}

RedisLockModelProvider

/**
 * @FileName: KimLockModelProvider.java
 * @Description: 获取锁的信息
 * @Author: kimwu
 * @Time: 2020-12-19 14:15:22
 */
@Slf4j
public class RedisLockModelProvider {

    static ThreadLocal threadLocal = new ThreadLocal();

    public static RedisLockModel get(JoinPoint joinPoint, RedisLock redisLock) {
        if (threadLocal.get() == null) {
            if (StringUtils.isNotBlank(redisLock.value())) {
                threadLocal.set(redisLock.value());
            } else {
                threadLocal.set(UUID.randomUUID().toString());
            }
        }
        return new RedisLockModel(redisLock.name(), threadLocal.get().toString(), getExpiteTime(redisLock));
    }


    public static Integer getExpiteTime(RedisLock lock) {
        return lock.expireTime() == Integer.MIN_VALUE ?
                60 : lock.expireTime();
    }
}

RedisDistributionLockPlus

/**
 * redis分布式锁
 * redis分布式锁过期时间到了,但业务还没执行完,怎么办?
 * redisson给的答案是锁获取成功后,注册一个定时任务,每隔一定时间(this.internalLockLeaseTime / 3L)就去续约。
 * internalLockLeaseTime可配置,默认30s。
 * 这种方式每次获取一个锁,就会创建一个定时任务,有些浪费
 * 这里借鉴jvm对自旋锁优化的思想,将续约的key的耗时保存在redis,再次获取锁时,直接使用上次耗时 + 10s 作为锁的过期时间,
 * 不用每次都去续约。
 * 缺点:获取锁后->释放锁前,若每次执行耗时差距过大(业务方法的各条流程耗时差距过大),将得不到预期的效果
 */
@Slf4j
@Service
public class RedisDistributionLockPlus {

    /**
     * 加锁超时时间,单位毫秒, 即:加锁时间内执行完操作,如果未完成会有并发现象
     */
    private static final long DEFAULT_LOCK_TIMEOUT = 30;

    private static final long TIME_SECONDS_FIVE = 5;

    /**
     * 每个key的过期时间 {@link LockContent}
     */
    private Map<String, LockContent> lockContentMap = new ConcurrentHashMap<>(512);

    /**
     * redis执行成功的返回
     */
    private static final Long EXEC_SUCCESS = 1L;

    /**
     * 获取锁lua脚本, k1:获锁key, k2:续约耗时key, arg1:requestId,arg2:超时时间
     */
    private static final String LOCK_SCRIPT = "if redis.call('exists', KEYS[2]) == 1 then ARGV[2] = math.floor(redis.call('get', KEYS[2]) + 10) end " +
            "if redis.call('exists', KEYS[1]) == 0 then " +
            "local t = redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) " +
            "for k, v in pairs(t) do " +
            "if v == 'OK' then return tonumber(ARGV[2]) end " +
            "end " +
            "return 0 end";

    /**
     * 释放锁lua脚本, k1:获锁key, k2:续约耗时key, arg1:requestId,arg2:业务耗时 arg3: 业务开始设置的timeout
     */
    private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "local ctime = tonumber(ARGV[2]) " +
            "local biz_timeout = tonumber(ARGV[3]) " +
            "if ctime > 0 then  " +
            "if redis.call('exists', KEYS[2]) == 1 then " +
            "local avg_time = redis.call('get', KEYS[2]) " +
            "avg_time = (tonumber(avg_time) * 8 + ctime * 2)/10 " +
            "if avg_time >= biz_timeout - 5 then redis.call('set', KEYS[2], avg_time, 'EX', 24*60*60) " +
            "else redis.call('del', KEYS[2]) end " +
            "elseif ctime > biz_timeout -5 then redis.call('set', KEYS[2], ARGV[2], 'EX', 24*60*60) end " +
            "end " +
            "return redis.call('del', KEYS[1]) " +
            "else return 0 end";
    /**
     * 续约lua脚本
     */
    private static final String RENEW_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";


    private final StringRedisTemplate redisTemplate;

    public RedisDistributionLockPlus(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        ScheduleTask task = new ScheduleTask(this, lockContentMap);
        // 启动定时任务
        ScheduleExecutor.schedule(task, 1, 1, TimeUnit.SECONDS);
    }

    /**
     * 加锁
     * 取到锁加锁,取不到锁一直等待知道获得锁
     *
     * @param lockKey
     * @param requestId 全局唯一
     * @param expire    锁过期时间, 单位秒
     * @return
     */
    public boolean lock(String lockKey, String requestId, long expire) {
        log.info("开始执行加锁, lockKey ={}, requestId={}", lockKey, requestId);
        for (; ; ) {
            // 判断是否已经有线程持有锁,减少redis的压力
            LockContent lockContentOld = lockContentMap.get(lockKey);
            boolean unLocked = null == lockContentOld;
            // 如果没有被锁,就获取锁
            if (unLocked) {
                long startTime = System.currentTimeMillis();
                // 计算超时时间
                long bizExpire = expire == 0L ? DEFAULT_LOCK_TIMEOUT : expire;
                String lockKeyRenew = lockKey + "_renew";

                RedisScript<Long> script = RedisScript.of(LOCK_SCRIPT, Long.class);
                List<String> keys = new ArrayList<>();
                keys.add("{redisLock}" + lockKey);
                keys.add("{redisLock}" + lockKeyRenew);
                Long lockExpire = redisTemplate.execute(script, keys, requestId, Long.toString(bizExpire));
                if (null != lockExpire && lockExpire > 0) {
                    // 将锁放入map
                    LockContent lockContent = new LockContent();
                    lockContent.setStartTime(startTime);
                    lockContent.setLockExpire(lockExpire);
                    lockContent.setExpireTime(startTime + lockExpire * 1000);
                    lockContent.setRequestId(requestId);
                    lockContent.setThread(Thread.currentThread());
                    lockContent.setBizExpire(bizExpire);
                    lockContent.setLockCount(1);
                    lockContentMap.put(lockKey, lockContent);
                    log.info("加锁成功, lockKey ={}, requestId={}", lockKey, requestId);
                    return true;
                }

            }
            if (lockContentOld != null) {
                // 重复获取锁,在线程池中由于线程复用,线程相等并不能确定是该线程的锁
                if (Thread.currentThread() == lockContentOld.getThread()
                        && requestId.equals(lockContentOld.getRequestId())) {
                    // 计数 +1
                    lockContentOld.setLockCount(lockContentOld.getLockCount() + 1);
                    return true;
                }
            }
            // 如果被锁或获取锁失败,则等待100毫秒
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                // 这里用lombok 有问题
                log.error("获取redis 锁失败, lockKey ={}, requestId={}", lockKey, requestId, e);
                return false;
            }
        }
    }


    /**
     * 解锁
     *
     * @param lockKey
     * @param lockValue
     */
    public boolean unlock(String lockKey, String lockValue) {
        String lockKeyRenew = lockKey + "_renew";
        LockContent lockContent = lockContentMap.get(lockKey);

        long consumeTime;
        if (null == lockContent) {
            consumeTime = 0L;
        } else if (lockValue.equals(lockContent.getRequestId())) {
            int lockCount = lockContent.getLockCount();
            // 每次释放锁, 计数 -1,减到0时删除redis上的key
            if (--lockCount > 0) {
                lockContent.setLockCount(lockCount);
                return false;
            }
            consumeTime = (System.currentTimeMillis() - lockContent.getStartTime()) / 1000;
        } else {
            log.info("释放锁失败,不是自己的锁。");
            return false;
        }

        // 删除已完成key,先删除本地缓存,减少redis压力, 分布式锁,只有一个,所以这里不加锁
        lockContentMap.remove(lockKey);

        RedisScript<Long> script = RedisScript.of(UNLOCK_SCRIPT, Long.class);
        List<String> keys = new ArrayList<>();
        keys.add("{redisLock}" + lockKey);
        keys.add("{redisLock}" + lockKeyRenew);

        Long result = redisTemplate.execute(script, keys, lockValue, Long.toString(consumeTime),
                Long.toString(lockContent.getBizExpire()));
        return EXEC_SUCCESS.equals(result);

    }

    /**
     * 多服务器集群,使用下面的方法,代替System.currentTimeMillis(),获取redis时间,避免多服务的时间不一致问题!!!
     *
     * @return
     */
    public long currtTimeForRedis() {
        return redisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.time());
    }

    /**
     * 续约
     *
     * @param lockKey
     * @param lockContent
     * @return true:续约成功,false:续约失败(1、续约期间执行完成,锁被释放 2、不是自己的锁,3、续约期间锁过期了(未解决))
     */
    public boolean renew(String lockKey, LockContent lockContent) {

        // 检测执行业务线程的状态
        Thread.State state = lockContent.getThread().getState();
        if (Thread.State.TERMINATED == state) {
            log.info("执行业务的线程已终止,不再续约 lockKey ={}, lockContent={}", lockKey, lockContent);
            return false;
        }

        String requestId = lockContent.getRequestId();
        long timeOut = (lockContent.getExpireTime() - lockContent.getStartTime()) / 1000;

        RedisScript<Long> script = RedisScript.of(RENEW_SCRIPT, Long.class);
        List<String> keys = new ArrayList<>();
        keys.add("{redisLock}" + lockKey);

        Long result = redisTemplate.execute(script, keys, requestId, Long.toString(timeOut));
        log.info("续约结果,True成功,False失败 lockKey ={}, result={}", lockKey, EXEC_SUCCESS.equals(result));
        return EXEC_SUCCESS.equals(result);
    }


    static class ScheduleExecutor {

        public static void schedule(ScheduleTask task, long initialDelay, long period, TimeUnit unit) {
            long delay = unit.toMillis(initialDelay);
            long period_ = unit.toMillis(period);
            // 定时执行
            new Timer("Lock-Renew-Task").schedule(task, delay, period_);
        }
    }

    static class ScheduleTask extends TimerTask {

        private final RedisDistributionLockPlus redisDistributionLock;
        private final Map<String, LockContent> lockContentMap;

        public ScheduleTask(RedisDistributionLockPlus redisDistributionLock, Map<String, LockContent> lockContentMap) {
            this.redisDistributionLock = redisDistributionLock;
            this.lockContentMap = lockContentMap;
        }

        @Override
        public void run() {
            if (lockContentMap.isEmpty()) {
                return;
            }
            Set<Map.Entry<String, LockContent>> entries = lockContentMap.entrySet();
            for (Map.Entry<String, LockContent> entry : entries) {
                String lockKey = entry.getKey();
                LockContent lockContent = entry.getValue();
                long expireTime = lockContent.getExpireTime();
                // 减少线程池中任务数量
                if ((expireTime - System.currentTimeMillis()) / 1000 < TIME_SECONDS_FIVE) {
                    //线程池异步续约
                    ThreadPool.submit(() -> {
                        boolean renew = redisDistributionLock.renew(lockKey, lockContent);
                        if (renew) {
                            long expireTimeNew = lockContent.getStartTime() + (expireTime - lockContent.getStartTime()) * 2 - TIME_SECONDS_FIVE * 1000;
                            lockContent.setExpireTime(expireTimeNew);
                        } else {
                            // 续约失败,说明已经执行完 OR com.kimwu.kill.mall.redis 出现问题
                            lockContentMap.remove(lockKey);
                        }
                    });
                }
            }
        }
    }


    @Data
    @ToString
    public static class LockContent implements Serializable {

        /**
         * 锁过期时间,单位秒
         */
        private volatile long lockExpire;

        /**
         * 锁过期时间,单位毫秒
         */
        private volatile long expireTime;

        /**
         * 获取锁的开始时间,单位毫秒
         */
        private volatile long startTime;

        /**
         * 用于防止锁的误删,全局唯一
         */
        private String requestId;

        /**
         * 执行业务的线程
         */
        private volatile Thread thread;

        /**
         * 业务调用设置的锁过期时间,单位秒
         */
        private long bizExpire;

        /**
         * 重入次数
         */
        private int lockCount = 0;

    }


    static class ThreadPool {

        private static final int CORESIZE = Runtime.getRuntime().availableProcessors();
        private static final int MAXSIZE = 200;
        private static final int KEEPALIVETIME = 60;
        private static final int CAPACITY = 2000;

        private static ThreadPoolExecutor threadPool;

        static {
            init();
        }

        private ThreadPool() {
        }

        /**
         * 初始化所有线程池。
         */
        private static void init() {
            threadPool = new ThreadPoolExecutor(CORESIZE, MAXSIZE, KEEPALIVETIME,
                    TimeUnit.SECONDS, new LinkedBlockingQueue<>(CAPACITY));
            log.info("初始化线程池成功。");
        }

        public static Future<?> submit(Runnable task) {
            if (null == task) {
                throw new IllegalArgumentException("任务不能为空");
            }
            return threadPool.submit(task);
        }
    }


}

RedisLockModel

/**
 * @Project: kim-com.kimwu.kill.mall.redis
 * @PackageName: com.kim.com.kimwu.kill.mall.redis.common.lock.model
 * @FileName: KimLockModel.java
 * @Description: The KimLockModel is...
 * @Author: kimwu
 * @Time: 2020-12-19 14:13:32
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RedisLockModel {

    /**
     * 锁的名称
     */
    private String name;

    /**
     * 锁的value
     */
    private String value;

    /**
     * 过期时间(如果不设置默认60s)
     */
    private Integer expireTime;

}

CurentMapUtils

/**
 * @Project: kim-com.kimwu.kill.mall.redis
 * @PackageName: com.kim.com.kimwu.kill.mall.redis.common.lock.utils
 * @FileName: CurentMapUtils.java
 * @Description: The CurentMapUtils is...
 * @Author: kimwu
 * @Time: 2020-12-19 14:31:37
 */
public class CurentMapUtils {


    public static final Map<String, RedisDistributionLockPlus> currentThreadLock = new ConcurrentHashMap<>();

    /**
     * 获取当前锁在map中的key
     *
     * @param joinPoint
     * @param klock
     * @return
     */
    public static String getCurrentLockId() {
        Long curentLock = Thread.currentThread().getId();
        return curentLock.toString();
    }
}

ObjectUtils

/**
 * @Project: kim-com.kimwu.kill.mall.redis
 * @PackageName: com.kim.com.kimwu.kill.mall.redis.common.lock.utils
 * @FileName: ObjectUtils.java
 * @Description: The ObjectUtils is...
 * @Author: kimwu
 * @Time: 2020-12-19 14:23:40
 */
@Slf4j
public class ObjectUtils {


    /**
     * 判断该对象是否: 返回ture表示所有属性为null  返回false表示不是所有属性都是null
     * @param obj
     * @return
     * @throws Exception
     */
    public static boolean isAllFieldNull(Object obj) throws Exception {
        // 得到类对象
        Class stuCla = (Class) obj.getClass();
        //得到属性集合
        Field[] fs = stuCla.getDeclaredFields();
        boolean flag = true;
        //遍历属性
        for (Field f : fs) {
            // 设置属性是可以访问的(私有的也可以)
            f.setAccessible(true);
            // 得到此属性的值
            Object val = f.get(obj);
            //只要有1个属性不为空,那么就不是所有的属性值都为空
            if (val != null) {
                flag = false;
                break;
            }
        }
        return flag;
    }


}

spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.kimwu.kill.mall.redis.lock.aspect.RedislockAspectHandler
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值