redis实现分布式锁

背景:

       假设有这样一个场景:用户在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
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值