Redis(八)-- Redis分布式锁实现

一、使用分布式锁要满足的几个条件

  1. 系统是一个分布式系统(关键是分布式,单机的可以使用ReentrantLock或者synchronized代码块来实现)
  2. 共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者NoSQL)
  3. 同步访问(即有很多个进程同事访问同一个共享资源。没有同步访问,谁管你资源竞争不竞争)

二、应用的场景例子

  管理后台的部署架构(多台tomcat服务器+redis【多台tomcat服务器访问一台redis】+mysql【多台tomcat服务器访问一台服务器上的mysql】)就满足使用分布式锁的条件。多台服务器要访问redis全局缓存的资源,如果不使用分布式锁就会出现问题。 看如下伪代码:

  long N=0L;
  //N从redis获取值
  if(N<5){
    N++;
  //N写回redis
  }

上面的代码主要实现的功能:

  从redis获取值N,对数值N进行边界检查,自加1,然后N写回redis中。 这种应用场景很常见,像秒杀,全局递增ID、IP访问限制等。以IP访问限制来说,恶意攻击者可能发起无限次访问,并发量比较大,分布式环境下对N的边界检查就不可靠,因为从redis读的N可能已经是脏数据。传统的加锁的做法(如java的synchronized和Lock)也没用,因为这是分布式环境,这个同步问题的救火队员也束手无策。在这危急存亡之秋,分布式锁终于有用武之地了。

  分布式锁可以基于很多种方式实现,比如zookeeper、redis...。不管哪种方式,他的基本原理是不变的:用一个状态值表示锁,对锁的占用和释放通过状态值来标识。

三、使用redis的setNX命令实现分布式锁  

1、实现的原理

  Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。redis的SETNX命令可以方便的实现分布式锁。

2、基本命令解析

1)setNX(SET if Not eXists)

语法:

   SETNX key value

  将 key 的值设为 value ,当且仅当 key 不存在。

  若给定的 key 已经存在,则 SETNX 不做任何动作。

  SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写

  返回值:

  设置成功,返回 1 。
  设置失败,返回 0 。

 例子:

  redis> EXISTS job                # job 不存在
  (integer) 0

  redis> SETNX job "programmer"    # job 设置成功
  (integer) 1

  redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败
  (integer) 0

  redis> GET job                   # 没有被覆盖
  "programmer"

 所以我们使用执行下面的命令

  SETNX lock.foo <current Unix time + lock timeout + 1> 
  • 如返回1,则该客户端获得锁,把lock.foo的键值设置为时间值表示该键已被锁定,该客户端最后可以通过DEL lock.foo来释放该锁。

  • 如返回0,表明该锁已被其他客户端取得,这时我们可以先返回或进行重试等对方完成或等待锁超时。

2)getSET

语法:

  GETSET key value

  将给定 key 的值设为 value ,并返回 key 的旧值(old value)。

  当 key 存在但不是字符串类型时,返回一个错误。

返回值:

  返回给定 key 的旧值。

  当 key 没有旧值时,也即是, key 不存在时,返回 nil 。

3)get

语法:

GET key

 返回值:

  当 key 不存在时,返回 nil ,否则,返回 key 的值。

  如果 key 不是字符串类型,那么返回一个错误

四、解决死锁

  上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或崩溃了不能释放锁,该怎么解决

  我们可以通过锁的键对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于lock.foo的值,说明该锁已失效,可以被重新使用。 

  发生这种情况时,可不能简单的通过DEL来删除锁,然后再SETNX一次(讲道理,删除锁的操作应该是锁拥有这执行的,这里只需要等它超时即可),当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞态条件,让我们模拟一下这个场景: 

  C0操作超时了,但它还持有着锁,C1和C2读取lock.foo检查时间戳,先后发现超时了。 
  C1 发送DEL lock.foo 
  C1 发送SETNX lock.foo 并且成功了。 
  C2 发送DEL lock.foo 
  C2 发送SETNX lock.foo 并且成功了。 
  这样一来,C1,C2都拿到了锁!问题大了! 

  幸好这种问题是可以避免的,让我们来看看C3这个客户端是怎样做的: 

  C3发送SETNX lock.foo 想要获得锁,由于C0还持有锁,所以Redis返回给C3一个0 
  C3发送GET lock.foo 以检查锁是否超时了,如果没超时,则等待或重试。 
  反之,如果已超时,C3通过下面的操作来尝试获得锁: 
  GETSET lock.foo <current Unix time + lock timeout + 1> 
  通过GETSET,C3拿到的时间戳如果仍然是超时的,那就说明,C3如愿以偿拿到锁了。 
  如果在C3之前,有个叫C4的客户端比C3快一步执行了上面的操作,那么C3拿到的时间戳是个未超时的值,这时,C3没有如期获得锁,需要再次等待或重试。留意一下,尽管C3没拿到锁,但它改写了C4设置的锁的超时值,不过这一点非常微小的误差带来的影响可以忽略不计。 

  注意:为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作,因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。  

五、代码实现

  expireTimeMsg 锁过期时间,防止线程在入锁之后 无限等待 
  waitTimeMsg    锁等待时间(或者 叫 尝试获得锁的时间),防止线程饥饿

1.Jedis工具类

 1 package com.xbq.redis;
 2 
 3 import redis.clients.jedis.Jedis;
 4 import redis.clients.jedis.JedisPool;
 5 import redis.clients.jedis.JedisPoolConfig;
 6 /**
 7  * Jedis工具类
 8  * @author xbq
 9  * @created:2017-4-19
10  */
11 public class JedisUtil {
12 
13     private JedisPool pool;
14     private static String URL = "192.168.242.130";
15     private static int PORT = 6379;
16     private static String PASSWORD = "xbq123";
17     
18     // ThreadLocal,给每个线程 都弄一份 自己的资源
19     private final static ThreadLocal<JedisPool> threadPool = new ThreadLocal<JedisPool>();
20     private final static ThreadLocal<Jedis> threadJedis = new ThreadLocal<Jedis>();
21     
22     private final static int MAX_TOTAL = 100;   // 最大分配实例
23     private final static int MAX_IDLE = 50;     // 最大空闲数
24     private final static int MAX_WAIT_MILLIS = -1; // 最大等待数
25     
26     /**
27      * 获取 jedis池
28      * @return
29      */
30     public JedisPool getPool(){
31         JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
32         // 控制一个pool可分配多少个jedis实例,通过pool.getResource()来获取,如果赋值为-1,则表示不限制;
33         // 如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)
34         jedisPoolConfig.setMaxTotal(MAX_TOTAL);
35         // 控制一个pool最多有多少个状态为idle(空闲的)的jedis实例
36         jedisPoolConfig.setMaxIdle(MAX_IDLE);
37         // 表示当borrow(引入)一个jedis实例时,最大的等待时间,如果超过等待时间,则直接抛出JedisConnectionException
38         jedisPoolConfig.setMaxWaitMillis(MAX_WAIT_MILLIS);
39         
40         final int timeout = 60 * 1000;
41         pool = new JedisPool(jedisPoolConfig, URL, PORT, timeout);
42         
43         return pool;
44     }
45     
46     /**
47      * 在jedis池中 获取 jedis
48      * @return
49      */
50     public Jedis common(){
51         // 从 threadPool中取出 jedis连接池
52         pool = threadPool.get();
53         // 为空,则重新产生 jedis连接池
54         if(pool == null){
55             pool = this.getPool();
56             // 将jedis连接池维护到threadPool中
57             threadPool.set(pool);
58         }
59         // 在threadJedis中获取jedis实例
60         Jedis jedis = threadJedis.get();
61         // 为空,则在jedis连接池中取出一个
62         if(jedis == null){
63             jedis = pool.getResource();
64             // 验证密码
65             jedis.auth(PASSWORD);
66             // 将jedis实例维护到threadJedis中
67             threadJedis.set(jedis);
68         }
69         return jedis;
70     }
71     
72     /**
73      * 释放资源
74      */
75     public void closeAll(){
76         Jedis jedis = threadJedis.get();
77         if(jedis != null){
78             threadJedis.set(null);
79             JedisPool pool = threadPool.get();
80             if(pool != null){
81                 // 释放连接,归还给连接池
82                 pool.returnResource(jedis);
83             }
84         }
85     }
86 }
View Code

2.分布式锁实现

  1 package com.xbq.redis;
  2 
  3 import org.apache.log4j.Logger;
  4 
  5 import redis.clients.jedis.Jedis;
  6 
  7 /**
  8  * Redis实现分布式锁
  9  * @author xbq
 10  */
 11 public class RedisLock {
 12     
 13     private static final Logger logger = Logger.getLogger(RedisLock.class);
 14 
 15     // 获取jedis实例
 16     private Jedis jedis;
 17     
 18     // 锁 的key
 19     private String lockKey;
 20     
 21     // 锁过期时间,防止线程在入锁之后 无限等待
 22     private int expireTimeMsg = 60 * 1000;
 23     
 24     // 锁等待时间(或者 叫 尝试获得锁的时间),防止线程饥饿
 25     private int waitTimeMsg = 10 * 1000;
 26     
 27     // 系统时间偏移量5秒,服务器间的系统时间差不可以超过5秒,避免由于时间差造成错误的解锁
 28     private final static int offsetTime = 5 * 1000;  // 用毫秒表示
 29     
 30     // 默认减去的时间
 31     private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 100;
 32     
 33     // 锁状态
 34     private volatile boolean lock = false;
 35     
 36     public RedisLock(Jedis jedis, String lockKey){
 37         this.jedis = jedis;
 38         this.lockKey = lockKey + "_lock";
 39     }
 40     
 41     public RedisLock(Jedis jedis, String lockKey, int waitTimeMsg){
 42         this(jedis, lockKey);
 43         this.waitTimeMsg = waitTimeMsg;
 44     }
 45     
 46     public RedisLock(Jedis jedis, String lockKey, int waitTimeMsg, int expireTimeMsg){
 47         this(jedis, lockKey, waitTimeMsg);
 48         this.expireTimeMsg = expireTimeMsg;
 49     }
 50     
 51     /**
 52      * 获取 锁 的key
 53      * @return
 54      */
 55     public String getLockKey(){
 56         return lockKey;
 57     }
 58     
 59     /**
 60      * 获取 key 对应的value
 61      * @param key
 62      * @return
 63      */
 64     private String get(String key){
 65         return jedis.get(key);
 66     }
 67     
 68     /**
 69      * 设置 key value,不存在 key,设置值 成功,返回1;存在key,设置值 失败,返回0
 70      * @param key
 71      * @param value
 72      * @return
 73      */
 74     private long setNx(String key, String value){
 75         return jedis.setnx(key, value);
 76     }
 77     
 78     /**
 79      * 获取旧值,设置 新的 值
 80      * @param key
 81      * @param value
 82      * @return
 83      */
 84     private String getSet(String key, String value){
 85         return jedis.getSet(key, value);
 86     }
 87     
 88     /**
 89      * 获取锁
 90      * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁
 91      *     reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
 92      * 执行过程:
 93      *     1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
 94      *     2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
 95      * @return
 96      * @throws InterruptedException
 97      */
 98     public boolean lock() throws InterruptedException{
 99         int waitTime = waitTimeMsg;
100         // 循环为了多次争夺锁
101         while (waitTime >= 0) {
102             // 过期时间
103             long exquires = System.currentTimeMillis() + expireTimeMsg + 1;
104             logger.info(Thread.currentThread().getName() + "尝试获取锁!");
105             // 得到了 锁
106             if(this.setNx(lockKey, String.valueOf(exquires)) == 1){
107                 logger.info(Thread.currentThread().getName() + "获得了锁,锁 过期时间为:" + exquires);
108                 lock = true;
109                 return true;
110             }
111             // 存在原来的锁,就获取原来锁的过期时间
112             String lastLockTime = this.get(lockKey);
113             // 判断redis中的时间是否为空,获取出的 时间 过期了,则进行下面操作
114             if(lastLockTime != null 
115                     && System.currentTimeMillis() - Long.valueOf(lastLockTime) > (expireTimeMsg + offsetTime)){
116                 // 获取上一个锁的过期时间,并设置现在的锁的过期时间(只有一个线程才能获取上一个线程的设置时间,因为jedis.getSet是同步的)
117                 String oldValue = this.getSet(lockKey, String.valueOf(exquires));
118                 // 防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为相差了很少的时间,所以可以接受
119                 if(oldValue != null && oldValue.equals(lastLockTime)){
120                     // [分布式的情况下]:如果这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
121                     logger.info("------" + Thread.currentThread().getName() + "获得了锁!------");
122                     lock = true;
123                     return true;
124                 }
125             }
126             // 循环一次减去一次
127             waitTime = waitTime - DEFAULT_ACQUIRY_RESOLUTION_MILLIS;
128             // 使用随机的等待时间可以一定程度上保证公平性
129             Thread.sleep((long)(Math.random() * 100));
130         }
131         logger.error("--------" + Thread.currentThread().getName() + "获取锁失败!!");
132         return false;
133     }
134     
135     /**
136      * 释放锁
137      */
138     public void unLock(){
139         // 判断加锁了,才进行删除操作
140         if(lock){
141             jedis.del(lockKey);
142             System.out.println(Thread.currentThread().getName() + "解锁成功!--------------");
143             // 恢复默认值
144             lock = false;
145         }
146     }
147     
148 }
View Code

3.模拟并发测试

 1 package com.xbq.redis;
 2 
 3 import java.util.concurrent.ExecutorService;
 4 import java.util.concurrent.Executors;
 5 import java.util.concurrent.Semaphore;
 6 
 7 import redis.clients.jedis.Jedis;
 8 
 9 /**
10  * 模拟并发环境下 获取锁
11  * @author xbq
12  */
13 public class Main {
14 
15     public static void main(String[] args) {
16 
17         // 定义线程池
18         ExecutorService service = Executors.newCachedThreadPool();
19         
20         // 只能有10个线程同时访问,用来模拟并发
21         final Semaphore semaphore = new Semaphore(10);
22         
23         // 模拟20个客户端访问 
24         for (int i = 0; i < 20; i++) {
25             Runnable runnable = new Runnable() {
26                 String lockKey = "TestLock33";
27                 @Override
28                 public void run() {
29                     try {
30                         // 获取许可 
31                         semaphore.acquire();
32                         // 获取jedis实例
33                         Jedis jedis = new JedisUtil().common();
34 
35                         RedisLock redisLock = new RedisLock(jedis, lockKey, 10000);
36                         if(redisLock.lock()){    // 获取到了锁,然后进行 业务处理
37                             // 业务代码
38                             Thread.sleep(3000);
39                         }
40                         // 释放锁
41                         redisLock.unLock();
42                         // 访问完后,释放 ,如果屏蔽下面的语句,则在控制台只能打印5条记录,之后线程一直阻塞
43                         semaphore.release();
44                     } catch (InterruptedException e) {
45                         e.printStackTrace();
46                     }
47                 }
48             };
49             // 执行线程
50             service.execute(runnable);
51         }
52         // 退出线程池 
53         service.shutdown();
54     }
55 }
View Code

六、一些问题

1、为什么不直接使用expire设置超时时间,而将时间的毫秒数其作为value放在redis中?

  如下面的方式,把超时的交给redis处理:

lock(key, expireSec){
  isSuccess = setnx key
if (isSuccess)
  expire key expireSec
}

  这种方式貌似没什么问题,但是假如在setnx后,redis崩溃了,expire就没有执行,结果就是死锁了。锁永远不会超时。

 2、为什么前面的锁已经超时了,还要用getSet去设置新的时间戳的时间获取旧的值,然后和外面的判断超时时间的时间戳比较呢?

  

  因为是分布式的环境下,可以在前一个锁失效的时候,有两个进程进入到锁超时的判断。如:

    C0超时了,还持有锁,C1/C2同时请求进入了方法里面

    C1/C2获取到了C0的超时时间

    C1使用getSet方法

    C2也执行了getSet方法

  假如我们不加 oldValueStr.equals(currentValueStr) 的判断,将会C1/C2都将获得锁,加了之后,能保证C1和C2只能一个能获得锁,一个只能继续等待。

  注意:这里可能导致超时时间不是其原本的超时时间,C1的超时时间可能被C2覆盖了,但是他们相差的毫秒及其小,这里忽略了。

 七、源码下载

  https://gitee.com/xbq168/DistributedLockByRedis

致谢:感谢您的阅读!转载请加原文链接,谢谢。转载请加上原文链接,谢谢!http://www.cnblogs.com/0201zcr/p/5942748.html

原文地址:https://www.cnblogs.com/xbq8080/p/6757068.html