redis实现分布式锁

来源:互联网 发布:淘宝关键词排名不稳定 编辑:程序博客网 时间:2024/05/22 13:16

背景:

       假设有这样一个场景:用户在APP上点击下单的时候,会跳到一个地址管理页面,其中保存着自己的地址。这里有2个条件:
       1.如果用户之前没有添加过地址,那么他添加的第一个地址就会被设置为默认地址。
       2.同时,每一个用户有且只有一个默认地址。
       那么,这个添加地址的接口实现应该大致是这样:先判断当前用户在数据库中的地址数量,如果是0,则将当前添加的地址设置为默认地址,否则,则设置为非默认地址。

初始代码

//判断数据库中该uid的地址数量   int nowAddressCount =addressDao.getCount(uid); //1   //如果是0就设置成默认地址    if(nowAddressCount ==0){    //2    addressInfo.setIsDefault(1);    //3    addressdao.insert(addressInfo); //4  }else{  //否则就设置成非默认地址    addressInfo.setIsDefault(0);    addressdao.insert(addressInfo);   }

       这样的一个代码,如果用户点击添加地址的时候,短时间内重复点击了多次,而前端又没有做防重复提交的话,前端短时间内一下子请求了多次该接口。那么其实这段代码是有线程安全问题的。线程安全问题其实就是多个线程对同一份数据进行读取/修改 的时候存在问题,例如一些全局变量。在这里,同一份数据指的就是数据库里面的同一份数据。

       现在假设有线程A,线程B。线程A去读取数据库,发现该用户没有地址,然后接着进行到第2,3处代码,在还没执行第4处,也就是还没插入到数据库中的时候,线程B也执行了第1处代码,发现count=0,然后也进入了2,3处。这样最终就会导致这2个地址都成了默认地址。其实,这里的if(满足什么条件){进行什么什么样的处理} 就是线程安全问题中的竞态条件(先检查,后执行)。

单机环境下线程安全代码

所以代码就变成了这样:

 int nowAddressCount =addressDao.getCount(uid); //1    if(nowAddressCount ==0){    //2      synchronized(this){   //3 加锁         if(addressDao.getCount(uid)==0){   //4 这里可以看成是double-check         addressInfo.setIsDefault(1);   //5            addressdao.insert(addressInfo); //6            }else{            addressInfo.setIsDefault(0);    //7            addressdao.insert(addressInfo);//8            }      }  }else{    addressInfo.setIsDefault(0);    addressdao.insert(addressInfo);   }

       **通过加锁,保证了从3-8每次只有一个线程能访问。第二个线程进入3的时候,第一个线程肯定已经插入完毕了,所以它再次查询count的时候,就不是0,而是1了。这样第二个线程插入的地址也就不会被设置成默认地址。不过这个锁对象(this)的粒度比较大,可以考虑不同UID用不同的锁对象,能提高点性能,参考concurrentHashMap.

分布式环境下

       但是还有一个问题。这段代码在单机环境下是可以正确执行的。如果是部署在多台服务器上呢?假设存在一种情况:有一台服务器A,服务器B。如果同一个用户的多次提交分别被分发到了服务器A和服务器B。然后在服务器A上,线程执行到第6行代码,但是addressInfo还没插入成功的时候,在服务器B上有线程进执行到了第4行代码,得到count=0。然后也将该地址设置为默认地址。 这时候,最终就也还是有2个默认地址。

       有一种比较简单的解决办法就是对UID进行映射,保证相同的UID映射到同一台机器。也可以用分布式锁了。网上查了一下,一般有3种方法,一种是根据zookeeper的,一种用的是mencache,还有就是用redis实现的。本质上都差不多,就是引入一个第三方公共的状态位来表示锁。

       redis实现分布式锁的原理主要参考下面2篇翻译文章:
使用 Redis 实现分布式锁
《Redis官方文档》用Redis构建分布式锁

总结一下大致有以下几点:

  1.        jedis有一个setnx(String key,String value)的命令,语意上是给该key设置value,如果该key不存在,则设置成功返回1.若该key已经存在,则设置失败返回0. 且该操作是原子性的。因为redis是单线程工作的,所以不会存在这个线程set的时候被另外一个线程抢先set成功的情况。 如果一个线程能setnx成功,即表示该线程拿到了锁。
  2.        释放锁是通过命令delete(String key)。 一旦释放,其他线程试图setnx的时候,就会有一个线程成功,然后拿到锁。
  3.        我们还需要对key设置过期时间,避免在setnx成功,而delete之前发生一些异常或者故障,导致没有释放锁。若没有设置过期时间,该key会一直占有,导致其他线程一直setnx失败,即拿不到该锁导致死锁。通过命令expire(String key,int seconds)来设置过期时间。
  4.        删除(释放锁)的时候还有一个小处理:假设存在一种情况:A线程拿到锁之后,然后因为网络阻塞,过了过期时间然后锁自动释放了。接着被线程B拿到锁后进行操作。这时候,A线程又尝试删除这个其实已经被B拿到的锁。所以如果用单纯的delete命令可能会导致误删除被其他线程拿到的锁。所以这里做的处理 是setnx(String key,String value)的时候,这个value要是随机的,且不会有任何2个线程的该value值一致。然后在delete的时候,只有get(String key)得到的value和我预期的一样的时候才能删除
  5.        还有一个问题就是如何保证线程A释放锁之后,能被其他线程抢占到了?简单暴力点就是while(true)然后一直setnx(),但是这样对redis压力很大。还有一种就是使用redis的订阅模式。
  6.        setnx方法和expire方法最好是在事务中操作,避免setnx成功而expire失败,导致一直无法过期。但是如果是事务的话,setnx方法又无法快速返回值,只有在事务执行成功之后才能获取到结果,也就会导致是否set成功都会让key的过期时间延长5s。所以考虑还是不用事务,因为setnx成功而expire失败的概率还是很低的。而设置过期时间的主要目的是第3条。两者同时发生的概率就更小了。

       所以有了下面的代码:

//redis操作类public class RedisUtils{............//初始化jedis配置public static Long insert(long uid,String value){String key =uid+"";try{    Long rst =jedis.setnx(key,value);        if(rst==1){//插入成功         try{jedis.expire(key,5);}//设置过期时间5秒            catch (Exception e){                      e.printStackTrace();                      log.error("设置过期时间发生异常",e);                               }                }    return rst;  }catch (Exception e){   e.printStackTrace();   log.error("setnx发生异常,key="+key+",value="+value);    return -1L;}public static void delete(long uid,String exp){String key =uid+"";        String value=jedis.get(key);    if(value !=null && exp.equals(value)){        jedis.del(key);    }}public static String getRandomString(){    String all="123456qwertyuioplkhgsvbQWERTYUIOPLKJHGFDSAZXCVBNM7890";    int lenth =all.length();    StringBuilder sb =new StringBuilder("");    for(int i=0;i<20;i++){//随机生成20位长的字符串,满足前面的第4点,避免错误删除        int loc =new Random.nextInt(lenth);        sb.append(all.charAt(loc));    }}}原代码: int nowAddressCount =addressDao.getCount(uid);     if(nowAddressCount ==0){            String random=RedisUtils.getRandomString();        long rst =RedisUtils.insert(uid,random);    while(rst ==0){    Thread.sleep(2);//休眠2ms后继续获取,因为日志打印的一次插入操作是2ms左右,没有用订阅模式,    rst =RedisUtils.insert(uid,random);//试图获取锁        if(rst ==1) break;//获取成功        if(rst ==-1){log.error("发生异常,uid="+uid);return}         }       //进入这里说明已经获取到锁了    if(addressDao.getCount(uid) ==0){    addressInfo.setIsDefault(1);        addressdao.insert(addressInfo);     RedisUtils.delete(uid,random);//释放锁    return;     }else{    addressInfo.setIsDefault(0);    addressdao.insert(addressInfo);    return;    }  }else{    addressInfo.setIsDefault(0);    addressdao.insert(addressInfo);    return;   }    

测试发现这段代码部署在2台机器上,然后同时各有300个线程请求服务器A和服务器B,最终只会有一个默认地址。多次测试结果保持一致。
原文地址:http://lumingfeng.xyz/2016/12/04/redis%E5%AE%9E%E7%8E%B0%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E7%9A%84%E4%B8%80%E6%AC%A1%E5%AE%9E%E8%B7%B5/

0 0
原创粉丝点击