1. 基于Jedis setnx、expire实现分布式锁(存在问题,作为错误示范)
    先引入相关依赖(jedis 2.3.0后支持redis集群模式,2.4.2后支持jedisCluster多线程处理,2.9.0之后版本较稳定,这里使用jedis 2.9.0版本,)
<dependency>
     <groupId>redis.clients</groupId>
     <artifactId>jedis</artifactId>
     <version>2.9.0</version>
</dependency>

Jedis单机版连接池:配置jedis连接池获得连接池对象,然后从连接池获得Jedis实例对象(我们一般用集群版的,单机版不过多介绍)

JedisPool jedisPool = null;
        Jedis jedis = null;
        //设置连接池的配置对象
        JedisPoolConfig config = new JedisPoolConfig();
        //设置连接池参数
        config.setMaxTotal(30);//最大活动对象数
        config.setMaxIdle(10);//最小能够保持idel(空闲)状态的对象数
        //获取连接池对象
        jedisPool = new JedisPool(config,"127.0.0.1", 6379);

				//获得jedis实例
				jedis=jedisPool.getResource();

				//归还连接
				jedis.close();

        //连接池关闭
				jedisPool.close();

Jedis集群版连接池:配置连接池参数

配置文件(yml):

#最大活动对象数
maxTotal: 1000
#最大能够保持idel状态的对象数
maxIdle: 100
#最小能够保持idel状态的对象数
minIdle: 50
#当池内没有返回对象时,最大等待时间
maxWaitMillis: 10000
#当调用borrow Object方法时,是否进行有效性检查
testOnBorrow: true
#当调用return Object方法时,是否进行有效性检查
testOnReturn: true
#“空闲链接”检测线程,检测的周期,毫秒数。如果为负值,表示不运行“检测线程”。默认为-1.
timeBetweenEvictionRunsMillis: 30000
#向调用者输出“链接”对象时,是否检测它的空闲超时;
testWhileIdle: true
# 对于“空闲链接”检测线程而言,每次检测的链接资源的个数。默认为3.
numTestsPerEvictionRun: 50
#redis服务器节点地址(ip:port)
nodes:
  - "*.*.*.*:****"

把JedisCluster作为单例类HTJedisClusterClient的一个属性,经过过单例类HTJedisClusterClient初始化,完成JedisCluster的配置,然后只需要通过调用HTJedisClusterClient的getJedisCluster()方法获得JedisCluster实例对象

public class HTJedisClusterClient {

    protected final Logger cLogger = Logger.getLogger(getClass());

    private static final String LOCK_SUCCESS = "OK";

    private static final Long RELEASE_SUCCESS = 1L;

    private JedisCluster jedisCluster;

    private Set<HostAndPort> nodes = new HashSet();

    private static volatile HTJedisClusterClient instance;

    private static String cPath="jedis-config.yml";

    private HTJedisClusterClient() {
        //初始化
        this.init();
    }

    private void init() {
        try {

            //读取配置文件
            Yaml yaml = new Yaml();
            //         String filePath= SysInfo.cHome+cPath;
            String filePath = "classpath:" + cPath;
            File file = ResourceUtils.getFile(filePath);
            InputStream in=new FileInputStream(file);

            Map<String,Object> map = yaml.load(in);

            //配置连接池
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxIdle((int)map.get("maxIdle"));
            config.setMaxTotal((int)map.get("maxTotal"));
            config.setMinIdle((int)map.get("minIdle"));
            config.setTestOnBorrow((boolean)map.get("testOnBorrow"));
            config.setTimeBetweenEvictionRunsMillis((long)map.get("timeBetweenEvictionRunsMillis"));

            //节点信息配置
            List<String> hostanportList=(List<String>) map.get("nodes");
            for(String hostandport:hostanportList){
                HostAndPort hostAndPort=HostAndPort.parseString(hostandport);
                this.nodes.add(hostAndPort);
            }

            //获得jedisCluster对象
            this.jedisCluster = new JedisCluster(this.nodes, config);

            cLogger.info("jedis cluster init pub/sub pool finish nodes=" + this.nodes);

        }catch (Exception e){
            e.printStackTrace();
            cLogger.info("初始化Jedis连接池异常:"+e.getMessage());
        }
    }

    public static HTJedisClusterClient getInstance() {
        if (instance == null) {
            synchronized(HTJedisClusterClient.class) {
                if (instance == null) {
                    instance = new HTJedisClusterClient();
                }
            }
        }

        return instance;
    }

    public JedisCluster getJedisCluster(){
        return jedisCluster;
    }

}

使用方式示例,JedisTest类,实现了Runnable接口,以下是run()方法的内容

@Override
    public void run() {
        //作为锁 key-value的 key
        String jedisKey="jedisKey";
        //作为锁 key-value的 value
        String requestId =jedisKey+"_"+ UUID.randomUUID().toString()+"_"+Thread.currentThread().getName();
        //过期时间,单位秒
        int expire=5;
        try{
            //设置成功,返回 1 ,设置失败,返回 0,value最好为分布式唯一值,这里测试使用线程名,实际上使用不能把线程名作为value,可能重复
            if(1l==jedisCluster.setnx(jedisKey,requestId)){
                try {
                    cLogger.info(requestId+"已获取锁");
                    //设置过期时间为5秒
                    jedisCluster.expire(jedisKey, expire);
                    cLogger.info(requestId+"已设置过期时间");
                    //(业务代码)
                    Thread.sleep(2000);

                }catch (Exception e){
                    e.printStackTrace();
                }finally {

                    //判断锁是否存在
                    if(jedisCluster.exists(jedisKey)) {
                        //判断是否是自己的锁,是则释放
                       if(jedisCluster.get(jedisKey).equals(requestId)){
                           jedisCluster.del(jedisKey);
                           cLogger.info(requestId+"释放锁");
                       }
                    }

                }
            }else{

                //若没有获得锁,判断锁有没有设置过期时间,没有则给它设置过期时间,避免死锁
                if(jedisCluster.ttl(jedisKey)==-1L){

                    cLogger.info("检测到锁未设置过期时间,"+requestId+"已重新为其设置过期时间");
                    jedisCluster.expire(jedisKey,expire);

                }
                cLogger.info(requestId+"获取锁失败");
            }

        }catch (Exception e){
            e.printStackTrace();
        }
    }

测试demo(创建10个线程模拟争夺锁的情况)

public static void main(String args[]) throws InterruptedException {
        //获取JedisCluster对象
        JedisCluster jedisCluster=HTJedisClusterClient.getInstance().getJedisCluster();
        for(int i=0;i<10;i++) {
            JedisTest2 jedisTest= new JedisTest2(jedisCluster);
            Thread thread = new Thread(jedisTest);
            thread.start();
            Thread.sleep(1000);
        }
    }

结果

jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3已获取锁
jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3已设置过期时间
jedisKey_ec5bb2c2-dedb-4227-89fd-863e1c8747e6_Thread-4获取锁失败
jedisKey_d1625319-7955-41a2-8f59-5205039bbcf4_Thread-5获取锁失败
jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3释放锁
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6已获取锁
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6已设置过期时间
jedisKey_b18cbfe5-d511-4405-89e1-4fc963c77546_Thread-7获取锁失败
jedisKey_e5934c4b-7dc3-4c67-89b4-e75b8bb6fad2_Thread-8获取锁失败
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6释放锁
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9已获取锁
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9已设置过期时间
jedisKey_ce8d9370-721d-4c6c-86e7-cfcb0bfdbe06_Thread-10获取锁失败
jedisKey_caf7446a-f58a-4f56-b964-f1d37ebb2aeb_Thread-11获取锁失败
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9释放锁
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12已获取锁
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12已设置过期时间
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12释放锁

缺陷:

1)setnx、expire是分两步操作而非一个原子性操作,可能会出现setnx成功,expire失败的情况下,导致锁没有设置过期时间而变成死锁;

2)没有设置等待时间,获取锁失败则不会尝试重新获取锁了。

3)释放锁时,判断锁是否已锁、是不是自己的锁、释放锁也是分了三步操作而非一个原子性操作。可能会出现判断是自己的锁了,即将执行释放锁操作但还未执行时,如果锁在此时超时释放了,别的线程同时获得了这个锁,我们继续执行释放锁的操作会导致释放了别人的锁;

4)没有实现锁的超时续期,导致可能业务还没执行完就过期释放了这个锁,从而出现线程安全问题;

5)上锁失败,就判断是否设置了过期时间,若是没有,则给它设置过期时间;这里虽然可以避免了死锁的问题,但是不够严谨,无法判断别的线程业务代码的执行时间,过期时间设置无法确定合不合理;

带着以上问题,再看看接下来的另一个方案

  1. 使用Jedis set命令以及Lua脚本方式实现分布式锁(不完美)
    自从redis的2.6.12版本起,SET命令已经提供了可选的复合操作符,把上锁和设置过期时间合成了一个原子性的操作。(“resource_name”就是我们上锁 key-value的key;my_random_value是 key-value的value,这个值作为判断锁是否线程自身所持有的标识,必须是唯一的;"NX"的意思是"SET IF NOT EXIST",不存在这个KEY则创建;"PX"意思是要加过期时间,参数为后面的30000毫秒)
SET resource_name my_random_value NX PX 30000

我们可以使用set命令来实现获取锁,但释放锁的多步操作依旧是非原子性的;redis2.6.0之后提供了lua脚本的支持,lua脚本是原子性的操作,它能把释放锁的多步操作合成一条命令提交给redis执行

编写一个JedisCluster的子类(类名也叫JedisCluster),在这个类里编写一个release方法,使用lua脚本实现释放锁。

/**
     * 释放分布式锁(lua脚本)
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public boolean release(String lockKey, String requestId) {
        //Lua脚本,意思是首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        //eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令,确保原子性
        Object result = eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            cLogger.info(requestId+"释放锁成功");
            return true;
        }
        return false;
    }

既然Lua命令可以把多个操作合成一个原子性操作,基于这个特性,我们实际上也可以自己封装一个获取分布式锁的方法。

除了上面使用jedis的set方法获取分布式锁,还可以使用Lua脚本的方式获取锁(把setnx、expire合为一条lua命令),同样也是可以实现获取分布式锁+设置过期时间的原子性操作。

/**
     * 使用Lua脚本方式尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识,加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryLockByLua(JedisCluster jedis, String lockKey, String requestId, int expireTime){

        String script ="if redis.call('setnx',KEYS[1],ARGV[1])==1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end";

        List valueList=new ArrayList();
        valueList.add(requestId);//ARGV[1]
        valueList.add(String.valueOf(expireTime));//ARGV[2]

        //eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令,确保原子性
        Object result = jedis.eval(script, Collections.singletonList(lockKey), valueList);

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

然后把HTJedisClusterClient单例类里面的JedisCluster属性替换成刚刚编写的这个它的子类JedisCluster,这样就可以通过HTJedisClusterClient单例类获得这个JedisCluster,调用其相关方法实现redis分布式锁;

实现方式:通过set方法获取锁、release方法释放锁

@Override
    public void run() {

        //作为锁 key-value的 key
        String jedisKey="jedisKey";
        //作为锁 key-value的 value
        String requestId =jedisKey+"_"+ UUID.randomUUID().toString()+"_"+Thread.currentThread().getName();
        //过期时间,单位毫秒
        int expire=5000;
        try{
            // "NX"的意思是"SET IF NOT EXIST",不存在这个KEY则创建    "PX"意思是要加过期时间,参数为expire
            if("OK".equals(jedisCluster.set(jedisKey,requestId,"NX","PX",expire))){
                try {

                    cLogger.info(requestId+"已获取锁并设置过期时间");

                    //(业务代码)

                    Thread.sleep(3000);

                }catch (Exception e){
                    e.printStackTrace();
                }finally {

                    //释放锁
                    jedisCluster.release(jedisKey,requestId);
                }
            }else{
                cLogger.info(requestId+"获取锁失败");
            }

        }catch (Exception e){
            e.printStackTrace();
        }
    }

测试demo

public static void main(String args[]) throws InterruptedException {
        //获取JedisCluster对象
        JedisCluster jedisCluster= HTJedisClusterClient.getInstance().getJedisCluster();
        for(int i=0;i<10;i++) {
            JedisTest1 jedisTest= new JedisTest1(jedisCluster);
            Thread thread = new Thread(jedisTest);
            thread.start();
            Thread.sleep(1000);
        }
    }

结果

jedisKey_26a2d2de-7f50-4614-80d6-245eb68e8b08_Thread-3已获取锁并设置过期时间
jedisKey_b3f549d2-d1c1-4199-b94d-15b478e16dd2_Thread-4获取锁失败
jedisKey_ff57c051-8a0d-41a5-a277-1f6516553437_Thread-5获取锁失败
jedisKey_c6696d0b-8d2b-4d2a-88a8-8474601f8665_Thread-6获取锁失败
jedisKey_26a2d2de-7f50-4614-80d6-245eb68e8b08_Thread-3释放锁成功
jedisKey_7c39d0dd-15b9-448f-969f-d8b0d12e3e0a_Thread-7已获取锁并设置过期时间
jedisKey_fdf73350-55c0-4b5f-9210-afc89a485904_Thread-8获取锁失败
jedisKey_3acc7608-bb3d-49c1-9319-e3885fe4d500_Thread-9获取锁失败
jedisKey_ba73efec-bdb8-4f81-9cc1-6d78225db6ab_Thread-10获取锁失败
jedisKey_7c39d0dd-15b9-448f-969f-d8b0d12e3e0a_Thread-7释放锁成功
jedisKey_82150b2f-7926-4f60-983c-7cb33048562f_Thread-11已获取锁并设置过期时间
jedisKey_7d7fed0b-be86-436d-82ee-9f89417f6cad_Thread-12获取锁失败
jedisKey_82150b2f-7926-4f60-983c-7cb33048562f_Thread-11释放锁成功

缺陷:

1)使用set方法获取分布式锁,没有实现等待时,需要使用者自己实现这个等待时重新获取锁的逻辑;

2)没有实现锁续期功能,一旦过期就释放锁,不管代码是否执行完;

  1. 使用Redisson+RLock实现分布式锁(tryLock、unLock方法实现原子性的原理也是基于Lua脚本)
    引入依赖(这里使用3.12.0版本)
<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.12.0</version>
</dependency>

配置参数(yml):

#集群配置
clusterServersConfig:
#  连接空闲超时,单位:毫秒,默认值 10000
  idleConnectionTimeout: 10000
#  ping节点超时,单位:毫秒,默认值 1000
  pingTimeout: 1000
#  连接超时,单位:毫秒,默认值 10000
  connectTimeout: 10000
#  命令等待超时,单位:毫秒,默认值 3000
  timeout: 3000
#  命令失败重试次数,默认值 3
  retryAttempts: 3
#  命令重试发送时间间隔,单位:毫秒,默认值 1500
  retryInterval: 1500
#  重新连接时间间隔,单位:毫秒,默认值 3000
  reconnectionTimeout: 3000
#  执行失败最大次数,默认值 3
  failedAttempts: 3
#  密码
  password: null
#  单个连接最大订阅数量,默认值 5
  subscriptionsPerConnection: 5
#  客户端名称
  clientName: null
#  负载均衡算法
  loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
#  从节点发布和订阅连接的最小空闲连接数,默认值 1
  slaveSubscriptionConnectionMinimumIdleSize: 1
#  从节点发布和订阅连接池大小,默认值 50
  slaveSubscriptionConnectionPoolSize: 50
#  从节点最小空闲连接数,默认值 32
  slaveConnectionMinimumIdleSize: 32
#  从节点连接池大小,默认值 64
  slaveConnectionPoolSize: 64
#  主节点最小空闲连接数,默认值 32
  masterConnectionMinimumIdleSize: 32
#  主节点连接池大小,默认值 64
  masterConnectionPoolSize: 64
#  读取操作的负载均衡模式,默认值 SLAVE(只在从节点里读取)
  readMode: "SLAVE"
#  节点地址
  nodeAddresses:
    - "redis://*.*.*.*:****"
#  集群扫描间隔时间,默认值 1000
  scanInterval: 1000
# 线程池数量,默认值: 当前处理核数量 * 2
#threads: 0
# Netty线程池数量,默认值: 当前处理核数量 * 2
#nettyThreads: 0
# 编码,默认值 org.redisson.codec.JsonJacksonCodec
#codec: !<org.redisson.codec.JsonJacksonCodec> {}
# 传输模式,默认值 NIO
#"transportMode":"NIO"

# 看门狗默认过期时间 默认值 30000 单位:毫秒
lockWatchdogTimeout: 30000

把ReddissonClient作为单例类HTRedissonClient的一个属性,经过过单例类HTRedissonClient初始化,完成ReddissonClient的配置,然后只需要通过调用HTRedissonClient的getRedissonClient()方法,就可以获得ReddissonClient对象进行相应操作

public class HTRedissonClient {

    protected final Logger cLogger = Logger.getLogger(getClass());

    private static Config config = new Config();

    private static RedissonClient redissonClient;

    private static volatile HTRedissonClient instance;

    private static String cPath="redisson-config.yml";

    private HTRedissonClient(){
        init();
    }

    private void init() {
        try {

            cLogger.info("加载redisson配置文件");

//         String filePath= SysInfo.cHome+cPath;
            String filePath="classpath:redisson-config.yml";
            cLogger.info("配置文件地址:"+filePath);

            File file = ResourceUtils.getFile(filePath);
            config = Config.fromYAML(file);
            cLogger.info("配置成功");

            cLogger.info("创建RedissonClient实例");

            redissonClient= Redisson.create(config);

            cLogger.info("RedissonClient实例创建完成");

        }catch (Exception e){
            e.printStackTrace();
        }
    }

    public static HTRedissonClient getInstance(){
        if(instance==null){
            synchronized ((HTRedissonClient.class)){
                if(instance==null){
                    instance = new HTRedissonClient();
                }
            }
        }
        return instance;
    }

    public RedissonClient getRedissonClient(){
        return redissonClient;
    }

}

使用方式示例,这里要注意一下,Redisson是提供了自动续期的功能的,但是自动续期功能只有在我们不自定义过期时间时才启用;(不自定义过期时间时,默认是30秒过期,启用自动续期功能,默认的过期时间可以通过配置文件参数lockWatchdogTimeout配置)

@Override
    public void run() {
        String lockKey="lockKey";
        RLock rLock = redissonClient.getLock(lockKey);
        try {
            //尝试加锁,最多等待5秒,默认30秒过期时间,有自动续期功能
            if (rLock.tryLock(5, TimeUnit.SECONDS)) {
                cLogger.info(Thread.currentThread().getName()+"获取锁成功");
                try {
                    //(业务代码)
                    Thread.sleep(2000);//模拟业务代码执行时间

                }finally {

                    rLock.unlock();// 释放锁
                    cLogger.info(Thread.currentThread().getName()+"释放锁成功");

                }
            }else{
                cLogger.info(Thread.currentThread().getName()+"未获取到锁");
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

测试demo

public static void main(String args[]) throws InterruptedException {

        //获取HTRedissonClient实例对象
        RedissonClient redissonClient = HTRedissonClient.getInstance().getRedissonClient();

        for(int i=0;i<10;i++) {

            //作为入参传入实现了Runnable接口的RedissonTest的构造函数,获得RedissonTesst对象
            RedissonTest redissonTest=new RedissonTest(redissonClient);
            //创建线程
            Thread thread = new Thread(redissonTest);
            //启动线程
            thread.start();

        }

    }

运行结果

Thread-2获取锁成功
Thread-2释放锁成功
Thread-4获取锁成功
Thread-4释放锁成功
Thread-1获取锁成功
Thread-5未获取到锁
Thread-3未获取到锁
Thread-10未获取到锁
Thread-6未获取到锁
Thread-8未获取到锁
Thread-7未获取到锁
Thread-9未获取到锁
Thread-1释放锁成功

tryLock、unLock的底层Lua脚本

**tryLock:**

"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"

**unLock:**

"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",

优点:

Redisson把获取锁+设置等待时间+设置过期时间、释放锁的各种操作封装成原子操作,并提供了自动续期的功能,此外还提供了可重入锁的功能。

缺陷:

unLock()方法可能会尝试释放别的线程的锁,虽然不会成功,但是会报异常;

  1. 总结
    Jedis提供了和redis命令高度一致的方法,学习成本低,它支持redis的基本数据类型和特性;日常使用相对来说更容易上手,但它提供的分布式锁的封装程度不高,很多逻辑需要我们自己去实现。
    Redisson对redis的命令进行了高度封装,提供了许多强大了分布式服务api;不支持字符串操作,不支持redis的一些基本特性。它的方法和我们日常的redis命令有较大差别,上手难度较大,但是它所提供的分布式锁功能较为完善,不需要我们去实现一些比较复杂的逻辑。