使用Redis+Redisson实现分布式锁Demo

使用redis实现分布式锁,相对于使用数据库锁或者使用ZooKeeper,简单方便,相对可靠,是最常用的方式,本文上一个实现demo。

在写代码之前,先抛出几个常见问题,带着问题去实现代码,逻辑更清晰完整。

redis实现分布式锁,几个常见经典问题:

问题一:锁不被释放

就是说一个服务在获取到分布式锁后,在释放锁之前,由于某种原因比如服务挂掉了,导致锁一直不会被释放,那么其他服务自然也就再也拿不到锁了。针对这个问题。解决办法一般都是加锁时,同步设置锁的过期时间。

问题二:服务A释放了服务B的锁,导致问题

比如,服务A在拿到锁之后,设置过期时间1s,但是服务A由于自身某种原因,业务执行了2s才结束;那么,在锁过期后,1.5s的时候,服务B正好来拿锁,并且拿到了,然后执行B的业务1s,那么B业务还没执行结束,A结束了,然后去释放锁,这个时候释放的就是B拿到的锁。

为了避免这个问题,需要为每个服务拿锁的请求进行标记,避免分不清锁是谁的。释放锁的时候,判断此刻redis中的锁是不是自己档时获取到的。

问题三:释放锁过程要保证原子性

针对问题二,说到释放锁的时候,要进行判断是不是自己的锁,这个判断+释放的过程,必须是原子性的,否则同样会产生释放别人锁的问题。

比如,服务A解锁时刚判断锁是自己的,于是下一步就是释放锁,结果释放锁之前,锁正好过期,并且服务B刚好申请到了此锁,那么服务A接下来释放的锁,必然是服务B的。

问题四:多个服务同时获取到了锁

业务中,分布式锁的目的肯定是只希望同时只有一个服务拿到锁,不能多个服务同时拿到锁,不然就失去了锁的意义。

但是,有一种场景,比如A服务拿到了锁,由于A业务执行时间过长,在解锁之前锁早已经被释放,同时又被服务B获取到,这样实际上就是服务A和服务B都获取到了锁并且在执行业务逻辑,这是有问题的。

我们可能会想到,把锁的过期时间设置的足够长,比如1min,保证不少于服务A的业务执行时间,这样的确可以,但是这样又产生了别的问题,比如服务A挂掉了,那么其他服务就需要等1min的时间才能拿到锁,这个等待时间未免太久;

那么,过期时间到底设置多久呢,这个不好设定,只能说设置为服务A业务大多数执行的时长,比如服务A的业务大多数执行时间是200ms,那么就设置为1s,这个应该足够了,但是万一服务A某次业务由于特殊原因,执行了2s呢,还是会有上述问题。

那么,我们会想,既然服务执行时间不是那么稳定,这个锁的过期时间是否能根据业务执行时间动态变化呢?答案是肯定的,本问Demo中,我们使用守护线程来动态延长锁的过期时间。

问题五:redis服务宕机,如何保证锁正常使用

此问题是针对单机版的redis做分布式锁,如果此单机redis服务挂掉,那么redis锁将会不可用。解决方式是使用redis集群,但是,在集群环境下,我们的分布式锁的加锁策略是怎样的呢?

原理是对redis集群的每个节点都加锁,然后判断超过半数的节点返回true,表示加锁成功。这里推荐使用Redisson框架,它实现的RedLock就是解决这种场景的。

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

Redissonh实现了可重入锁,公平锁等各种java中定义的锁类型,可以解决上述的5个问题,相关资料可参考官方文档:https://github.com/redisson/redisson/wiki/目录

Demo:
以上前4个问题,在本文Demo中都有解决,并添加了注释,下面看代码。

先上主线程:

public class RedisLockDemo {
    //随便弄个key的名字
    private static final String LOCK_KEY = "distributedLock:key";

	//主线程
    public static void main(String[] args) {
        //获取redis客户端
        RedisClient redisClient = RedisClient.getInstance();
        //开启两个工作线程,模拟分布式服务中的两个服务
        for (int i = 0; i < 1; i++) {
            startAWork(redisClient, String.valueOf(i), 10);
        }
    }

    /**
     * 开启一个工作线程,模拟分布式中的一个服务,抢分布式锁
     *
     * @param redisClient  redis客户端
     * @param threadName   线程名称
     * @param lengthOfWork 工作时长 秒
     */
    public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
        new Thread(() -> {
            try {
                //生成并保存 获取分布式锁的 请求id,解决问题二
                String requestId = UUID.randomUUID().toString();
                RedisLockThreadLocalContext.getThreadLocal().set(requestId);

                //获取分布式锁,设置过期时间2s,解决问题一
                boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);

                if (result) {//如果成功获取到锁
                    //开一个守护线程延长锁的过期时间
                    Thread thread = new Thread(() -> {
                        while (true) {
                            Jedis jedis = redisClient.getJedis();
                            try {
                                TimeUnit.SECONDS.sleep(1);
                                System.out.println("守护线程延长锁的过期时间1s");
                                jedis.setex(LOCK_KEY, 1, requestId);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            } finally {
                                if (jedis != null) {
                                    jedis.close();
                                }
                            }
                        }
                    });
                    thread.setDaemon(true);
                    thread.start();

                    System.out.println("线程" + threadName + "拿到锁,干点事情");
                    //睡眠一定时间,模拟业务耗时
                    TimeUnit.SECONDS.sleep(lengthOfWork);
                } else {
                    System.out.println("线程" + threadName + "没有拿到锁");
                }
            } catch (Exception e) {
                //
            } finally {
                //释放分布式锁
                String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
                boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
                if (result) {
                    System.out.println("线程" + threadName + "释放锁");
                } else {
                    System.out.println("线程" + threadName + "释放锁失败");
                }

            }
            System.out.println("线程" + threadName + "结束");
        }).start();
    }
}

主线程说明:

  • 主线程比较简单,只开启了两个工作线程,模拟抢分布式锁的过程;
  • 具体的startAWork()方法中,新建了工作线程,使用睡眠时间来模拟执行业务逻辑的耗时;
  • 在 RedisTool#tryGetDistributedLock()方法中,传入了过期时间参数,方法内容看下问代码。这个参数解决了问题一;
  • 在 RedisTool#tryGetDistributedLock()方法中,传入了requestId参数,这个是一个随机UUID,用来标识每一次加锁的线程,同时这个参数保存在了线程本地变量ThreadLocal中,解决了问题二。
  • 在开启工作线程后,代码中紧接着又开启另外一个线程,并使用thread.setDaemon(true);标识为守护线程;这个守护线程的任务就是死循环延长锁的过期时间;当业务线程执行完毕后,这个守护线程会自动销毁。注意循环的时间间隔要小于锁的过期时间,一般设置为过期时间的一半即可。

其他辅助类:

添加jedis依赖包:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

使用JedisPool初始化一个Jedis客户端:

/**
 * Description:Redis客户端
 */
public class RedisClient {
    private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
    private static RedisClient instance = new RedisClient();
    private JedisPool pool;

    private RedisClient() {
        init();
    }

    public static RedisClient getInstance() {
        return instance;
    }

    public Jedis getJedis() {
        return pool.getResource();
    }

    /**
     * 初始化redis连接池
     */
    private void init() {
        int maxTotal = 10;
        String ip = "redis IP";
        String pwd = "redis 密码";
        int port = 6379;

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxTotal);
        jedisPoolConfig.setMaxIdle(20);
        jedisPoolConfig.setMaxWaitMillis(6000);
        pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
        LOGGER.info("连接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
    }
}

上述代码初始化了redis连接信息,属于固定代码,没啥好解释的,继续往下看代码。

/**
 * Description:redis分布式锁访问工具类,提供具体的获取锁,释放锁方法
 */
public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final Long RELEASE_SUCCESS = 1L;

    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     *
     * @param jedis      Redis客户端
     * @param lockKey    锁的key
     * @param requestId  锁的Value,值是个唯一标识,用来标记加锁的线程请求;可以使用UUID.randomUUID().toString()方法生成
     * @param expireTime 过期时间 ms
     * @return 是否获取成功,成功返回true,否则false
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = null;
        try {
            result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return LOCK_SUCCESS.equals(result);
    }

    /**
     * 释放分布式锁
     *
     * @param jedis     Redis客户端
     * @param lockKey   锁
     * @param requestId 请求标识,锁的Value
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        Object result = null;
        try {
            //使用lua脚本保证原子性
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return RELEASE_SUCCESS.equals(result);
    }
}
  • RedisTool工具类,提供了加锁和解锁的两个方法;
  • tryGetDistributedLock()加锁方法设置了过期时间,解决了问题一;
  • releaseDistributedLock()解锁方法中使用了lua脚本,具备原子性,解锁时先判断key的value值,也就是当初加锁保存的requestId是不是和自己线程保存的一致,一致才说明是自己当初加的锁,方可进行解锁;不一致说明自己加锁已经自动过期,无需解锁;这个解决了问题二和问题三。
/**
 * Description:保存redis分布式锁的请求id
 */
public class RedisLockThreadLocalContext {

    private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");

    public static ThreadLocal<String> getThreadLocal() {
        return threadLocal;
    }
}

上述RedisLockThreadLocalContext中创建了一个threadLocal单例,用于保存加锁时设置的requestId。当然在使用线程池时,get完数据要注意清除里面的保存信息,这里就不写那么详细了。

以上就是本文全部内容,特别要注意本文开头的那几个问题。

发布了62 篇原创文章 · 获赞 29 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/csdn_20150804/article/details/103780247