背景:
假设有这样一个场景:用户在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构建分布式锁
总结一下大致有以下几点:
- jedis有一个setnx(String key,String value)的命令,语意上是给该key设置value,如果该key不存在,则设置成功返回1.若该key已经存在,则设置失败返回0. 且该操作是原子性的。因为redis是单线程工作的,所以不会存在这个线程set的时候被另外一个线程抢先set成功的情况。 如果一个线程能setnx成功,即表示该线程拿到了锁。
- 释放锁是通过命令delete(String key)。 一旦释放,其他线程试图setnx的时候,就会有一个线程成功,然后拿到锁。
- 我们还需要对key设置过期时间,避免在setnx成功,而delete之前发生一些异常或者故障,导致没有释放锁。若没有设置过期时间,该key会一直占有,导致其他线程一直setnx失败,即拿不到该锁导致死锁。通过命令expire(String key,int seconds)来设置过期时间。
- 删除(释放锁)的时候还有一个小处理:假设存在一种情况:A线程拿到锁之后,然后因为网络阻塞,过了过期时间然后锁自动释放了。接着被线程B拿到锁后进行操作。这时候,A线程又尝试删除这个其实已经被B拿到的锁。所以如果用单纯的delete命令可能会导致误删除被其他线程拿到的锁。所以这里做的处理 是setnx(String key,String value)的时候,这个value要是随机的,且不会有任何2个线程的该value值一致。然后在delete的时候,只有get(String key)得到的value和我预期的一样的时候才能删除。
- 还有一个问题就是如何保证线程A释放锁之后,能被其他线程抢占到了?简单暴力点就是while(true)然后一直setnx(),但是这样对redis压力很大。还有一种就是使用redis的订阅模式。
- 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/