redis分布式锁最佳实践(并实现锁续期机制)


文章目录

  • redis分布式锁最佳实践(并实现锁续期机制)
  • 1. 分布式锁是什么?
  • 2. setnx 和 AQS state
  • 3. jedis完成分布式锁得设计
  • #3.1 v1版本
  • 3.2 v2版本
  • 3.3 v3版本
  • 4. 测试


1. 分布式锁是什么?

在单体项目进入分布式项目之后,各个业务被拆分成多个微型服务,核心微服务还可以搭建集群,那么搭建集群之后问题就来了,以前是单体项目,如果涉及共享资源得一些操作我们可以使用ReentrantLock来进行加锁,那么如今微服务集群项目下,做不到了,那么我么其实就可以使用一些中间件,基于中间件得原子性操作来实现分布式锁,今天我们就来使用redis搭建分布式锁实践。

2. setnx 和 AQS state

在redis中有setnx命令 ,该命令使用逻辑如下:

redis锁设置过期时间 redis锁续期_分布式锁


可以看到我们使用setnx添加一个key-value得值,第一次增加之后,第二次再去增加发现返回值变成了0。意味着setnx命令就如同Java中unsfae类得CAS操作一样。

这里简单说一下AQS得机制,AQS是Java同步器得基石,如我们常用得ReentranLock和ReadWriteReetranLock还有seameple信号量等都是基于AQS来实现,在AQS有一个state得状态值,当state状态值为0时表示没有人获取该锁,=1 表示有线程获取了该锁,其他线程lock时会进入阻塞队列中等待。

而redis中得setnx和javaAQS得state就有异曲同工之处,我们就可以借助setnx来实现分布式锁,当然这一前提是建立在redis得业务处理是单线程模式执行。

3. jedis完成分布式锁得设计

#3.1 v1版本

package com.xzq.lock;

/**
 - @Author xzq
 - @Description // 分布式锁实践
 - @Date 2021/11/27 9:45
 - @Version 1.0.0
 **/
public class RedisLock {
    private Logger logger = LoggerFactory.getLogger(RedisLock.class);
    private  Jedis jedis = JedisPoolManager.getJedis();
    
    static class JedisPoolManager{
        private static JedisPool jedisPool;
        static {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxTotal(10);
            jedisPool = new JedisPool(config, "127.0.0.1", 6379);
        }
        public static Jedis getJedis() {
            Jedis jedis = jedisPool.getResource();
            return jedis;
        }
    }
    public boolean lock(String key, String value) {
        if (jedis.setnx(key, value)==1) {
            return true;
        }
        return false;
}
    public boolean unlock(String key) {
        if (jedis.del(key)==1) {
            return true;
        }
        return false;
    }
}

一个简单得redis分布式锁就完成了,但是这就完了吗?其中有没有一些问题呢?

  • 当上层应用得业务逻辑出现异常,并且没有try catch机制进行unlock,那么锁将一直被占有,其它分布式服务永远得不到锁。这其实也是一种死锁。

好办呀,加个过期时间就可以了,redis中不是有expire命令吗?

3.2 v2版本

为了解决v1版本得问题我们将获取锁得代码改造如下:

public boolean lock(String key, String value,long ttl,long timeOut) {
        //结束时间,表示超时时间,3s内获取不到锁则结束
        long end = System.currentTimeMillis() + timeOut;
        while (System.currentTimeMillis() < end) {
            if (acquire(key, value,ttl)) {
                return true;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return false;
    }
    private boolean acquire(String key, String value, long ttl) {
        if (jedis.setnx(key, value)==1) {
            //加锁成功,设置过期时间
            jedis.expire(key, ttl);
            return true;
        }
        return false;
    }
    public boolean unlock(String key) {
        if (jedis.del(key)==1) {
            return true;
        }
        return false;
    }

v2版本我们不仅设置了过期时间ttl,还进行了一个超时等待得自旋优化,当获取锁失败后进行一个指定时间得自旋重试,如果超过这个时间那么就返回。
看着好像也没啥问题了,如果我业务系统出现某些问题,但是我的锁有过期时间并不会形成死锁。
但是啊,我们得这个unlock其实有一些问题
直接拿着key去删合适吗?
会不会我加锁得时候,有人拿着RedisLock直接先调用个unlock,就把我得锁给删了。所以说我们要对del删除锁时进行一个比对,那么比对得值其实就是value,我们可以给value设置一个唯一值,当进行删除得时候比对value,value比对成功才能进行删除。
对应得代码上其实就是一个 get-del得操作也就是先get锁得value,在比较,比较完成在del.但这是两步操作,可能会出现多应用下得指令交错,所以我们必须实现原子性,借助于lua脚本

local lock_key=KEYS[1]
local lock_value=ARGV[1]

local current_value=redis.call('get',lock_key)
local result=0
if lock_value==current_value then
    redis.call('del',lock_key)
    result=1
end
    return result
private  final String unlockLua = jedis.scriptLoad("local lock_key=KEYS[1]\n" +
            "local lock_value=ARGV[1]\n" +
            "\n" +
            "local current_value=redis.call('get',lock_key)\n" +
            "local result=0\n" +
            "if lock_value==current_value then\n" +
            "    redis.call('del',lock_key)\n" +
            "    result=1\n" +
            "end\n" +
            "    return result");
    /**
     * 释放锁得操作 使用Lua脚本保证原子性
     * @param uuid
     */
    public boolean unlock(String key,String uuid) {
        if ((long)jedis.evalsha(unlockLua, Arrays.asList(key), Arrays.asList(uuid))==1) {
            scheduledExecutorService.shutdown();
            return true;
        }
        return false;
    }
}

我们就可以把释放锁得代码改造如上。

3.3 v3版本

那么这样就大功告成了吗?并没有。
假设我们得ttl设置的是30s, 可是我的业务系统却执行了40s, 在第30s锁就已经释放,其他线程已经可以进来了。
那么如何解决这个问题呢?
在redission中有一个watchDog看门狗机制其实就是应对这种情况,实现锁的一个续期处理。这里我们就不演示redission如何实现的了。
我们自己来实现watchDog相同思想的操作,给锁续期。
其实思路也很简单我们可以new 一条线程,定期检查锁的过期时间,如果快过期了,那么就进行续费操作。
我们这里就简单一点搞一个定时任务,根据ttl的最后几秒钟来设置执行的速率,当临近ttl快过期的时候,我们重新设置ttl.实现续期操作。

package com.xzq.lock;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @Author xzq
 * @Description // 分布式锁实践
 * @Date 2021/11/27 9:45
 * @Version 1.0.0
 **/
public class RedisLock {
    private Logger logger = LoggerFactory.getLogger(RedisLock.class);
    private  Jedis jedis = JedisPoolManager.getJedis();
    private ScheduledThreadPoolExecutor scheduledExecutorService = new ScheduledThreadPoolExecutor(1);
    static class JedisPoolManager{
        private static JedisPool jedisPool;
        static {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxTotal(10);
            jedisPool = new JedisPool(config, "127.0.0.1", 6379);
        }
        public static Jedis getJedis() {
            Jedis jedis = jedisPool.getResource();
            return jedis;
        }
    }


    public boolean lock(String key, String value,long ttl) {
        //结束时间,表示超时时间,3s内获取不到锁则结束
        long end = System.currentTimeMillis() + 1000 * 3;
        //自旋重试
        while (System.currentTimeMillis() < end) {
            if (acquire(key, value,ttl)) {
                watchDog(key, value,ttl);
                return true;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void watchDog(String key,String value,long ttl) {
        //获取续命速率
        long rate = getRate(ttl);
        if (scheduledExecutorService.isShutdown()) {
            scheduledExecutorService=new ScheduledThreadPoolExecutor(1);
        }
        scheduledExecutorService.scheduleAtFixedRate(new watchDogThread(scheduledExecutorService,Arrays.asList(key),Arrays.asList(value,String.valueOf(ttl))),
                1, rate, TimeUnit.SECONDS);
    }

     class watchDogThread implements Runnable{
        private ScheduledThreadPoolExecutor poolExecutor;
         private List<String> keys;
         private List<String> args;

         public watchDogThread(ScheduledThreadPoolExecutor poolExecutor, List<String> keys, List<String> args) {
             this.poolExecutor = poolExecutor;
             this.keys = keys;
             this.args = args;
         }

         @Override
        public void run() {
            logger.info("进行续期");
            if ((long)jedis.evalsha(watchLua, keys,args)==0) {
                //续期失败 可能是业务系统发生异常并且没有进行异常捕捉,没有进行释放锁操作
                poolExecutor.shutdown();
            }
        }
    }

    private long getRate(long ttl) {
        if (ttl - 5 > 0) {
            return ttl - 5;
        } else if (ttl - 1 > 0) {
            return ttl - 1;
        }
        throw new RuntimeException("ttl 不允许小于1");
    }

    public boolean lock(String key, String value,long ttl,long timeOut) {
        //结束时间,表示超时时间,3s内获取不到锁则结束
        long end = System.currentTimeMillis() + timeOut;
        while (System.currentTimeMillis() < end) {
            if (acquire(key, value,ttl)) {
                watchDog(key, value,ttl);
                return true;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return false;
    }
    private boolean acquire(String key, String value, long ttl) {
        if (jedis.setnx(key, value)==1) {
            //加锁成功,设置过期时间
            jedis.expire(key, ttl);
            return true;
        }
        return false;
    }

    private  final String watchLua = jedis.scriptLoad("local lock_key=KEYS[1]\n" +
            "local lock_value=ARGV[1]\n" +
            "local lock_ttl=ARGV[2]\n" +
            "local current_value=redis.call('get',lock_key)\n" +
            "local result=0;\n" +
            "if lock_value==current_value then\n" +
            "    result=1;\n" +
            "    redis.call('expire',lock_key,lock_ttl)\n" +
            "end\n" +
            "return result");

    private  final String unlockLua = jedis.scriptLoad("local lock_key=KEYS[1]\n" +
            "local lock_value=ARGV[1]\n" +
            "\n" +
            "local current_value=redis.call('get',lock_key)\n" +
            "local result=0\n" +
            "if lock_value==current_value then\n" +
            "    redis.call('del',lock_key)\n" +
            "    result=1\n" +
            "end\n" +
            "    return result");
    /**
     * 释放锁得操作 使用Lua脚本保证原子性
     * @param uuid
     */
    public boolean unlock(String key,String uuid) {
        if ((long)jedis.evalsha(unlockLua, Arrays.asList(key), Arrays.asList(uuid))==1) {
            scheduledExecutorService.shutdown();
            return true;
        }
        return false;
    }
}

4. 测试

package com.xzq;

import com.xzq.lock.RedisLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @Author xzq
 * @Description //TODO
 * @Date 2021/11/27 10:20
 * @Version 1.0.0
 **/
public class LockTest {
    private static Logger logger = LoggerFactory.getLogger(LockTest.class);
    private static final String LOCK_PREV = "REIDS:LOCK:";
    private static final long LOCK_TTL = 5 ;

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                something();
            }).start();
        }
    }

    public static void something(){
        RedisLock redisLock = new RedisLock();
        String uuid = UUID.randomUUID().toString();
        String key = LOCK_PREV + "something";
        if (redisLock.lock(key, uuid, LOCK_TTL, 1000 * 2)) {
            try {
                doSomething();
            } catch (Exception e) {
                e.printStackTrace();
                redisLock.unlock(key, uuid);
            }finally {
                redisLock.unlock(key, uuid);
            }
        }else{
            logger.error("获取锁失败");
        }
    }

    private static void doSomething() {
        logger.info("做一些业务操作.......");
        try {
            Thread.sleep(1000 * 15);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

如图所示,我们直接new 5个线程 去争抢锁,并设置业务执行时间为15s,但是锁的过期时间设置为5s。

redis锁设置过期时间 redis锁续期_java_02


可以看到五个线程中只有一个线程获取到了锁,并且进行了续期操作,并且其他线程是在自旋2s后退出的。