暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Redis锁防重一记

源成蹊 2019-06-12
329

最近客户反馈了个很奇怪的现象:个别用户注册完再登录老是提示error!项目都上线这么久了还有如此错误感觉事情并不辣么简单。

遂追踪下各个服务日志:

注意到:有些注册的日志请求都是在同一毫秒级别的,一丝不差。这也没啥,并发嘛~再看数据库,查到如下用户数据:

同样的数据,同样的时刻,同时入库~

推测该场景是:同一用户在同一毫秒级别:发送了4次注册请求

1毫秒4次,即1秒4000次,单身60载手速?观其生日才不过一个20多级魔法师而已啊。。。加藤鹰二世,老哥看好你奥

我们用户不可能这么丧心病狂的,排除了这是人为操作。快速分析问题:nginx同一时刻转发4次?不存在的,spring框架问题?单例模式下同时接受了4次请求,4个实例,同时进库查,没有此数据,然后同时入库。由于数据库没有做外键限制,遂都入库成功~

我并不怀疑市场上成熟的框架是会有这样的问题的~不然nginx也不会直追Apache了,Spring会有问题?我想都不会去想

和群友一波吹水,总结原因有2:

1.网络抖动,导致请求延时后同一时刻发送

2.手机触摸屏,用户按着没送。前端事件没处理好

事后在查一波数据库,发现有7、8条脏数据。一波分析,原因不甚明朗,遂先解决问题吧。

若是单体项目下,此问题不难解决:

synchronized (redisTemplate){
   boolean resubmit = MyUtils.checkReSubmit(redisTemplate,".../regist",idNumber);
if(resubmit){
return new Response<>(ResultCode.NOT_REPEAT_SUBMISSION); //不能重复提交
   }
}

无非就是synchronized加锁咯,大家一个一个来~性能降低是肯定的,但是此乃数据入口,数据可靠性比性能的重要性更高~

但玩的是分布式系统,synchronized只能锁住本项目的,可锁不住其他集群的服务,故弃用

分布式项目肯定用分布式锁,看到这个字眼就想到了reids~ 或许还有其他中间件,但是项目中本就集成了redis故不再引入多余jar包了。开搞~

为什么redis可以做分布式锁呢?

因为Redis是线程安全的,其setnx指令保证只有在key不存在时才设置value,并返回1,否则返回0且不做更新

利用该指令很容易解决该问题嘛~

/**
 * 验证是否重复提交
 * @param key     唯一键
* @return true:重复提交了,false:不需要防重
*/
public static boolean checkReSubmit(StringRedisTemplate redisTemplate, String key, String value) {
if (MyUtils.isEmpty(key) || MyUtils.isEmpty(value)) return false; //不用防重
String newKey = MD5Utils.encoderByMd5(key + value);
boolean noExist = redisTemplate.opsForValue().setIfAbsent(newKey, newKey);
    if (noExist) {      //Redis保存成功后,设置超时时间[此处有死锁隐患]
redisTemplate.expire("resubmit" + newKey, 2, TimeUnit.SECONDS);
return false;
}
return true; //后续保存的,都被视为重复提交
}

简单工具类,其中setIfAbsent()是redisTemplate提供的api:对应redis的setnx指令,保证同一时刻只有单个线程进来。这样就可以用此方法来给相应的业务加锁了

但仔细审查下,当代码中刚获取到noExixt该boolean值时,项目重启了或者Redis服务突然于此时断开。导致没有进入下面的判断去设置超时时间,那么此key将会永久保存在Redis中了!这问题太大了,以后同个key来的请求都会被视为重复提交了==凭本事写的bug啊

说白了就是保存到redis与设置超时时间这两个动作不是原子操作。该如何解决?

1.使用redis的事务

2.写个lua脚本,让保存key与设置超时统一发给redis服务器,让它执行

3.重写setIfAbsent()方法

我觉得都挺麻烦的,面向搜索引擎编程半天后,找到最优解:

即Redis的2.1版本已经有此对应api了,上图第二个方法即同时设置k-v及超时时间的。不用费事儿,美滋滋

导包吧:spring-data-redis: 2.1.0

compile group: 'org.springframework.data', name: 'spring-data-redis', version: '2.1.0.RELEASE'

项目启动,即报错:

18:18:41.819 RBACServer [main] ERROR o.s.boot.SpringApplication - Application startup failed
java.lang.NoSuchMethodError: org.springframework.data.repository.config.RepositoryConfigurationSource.getAttribute(Ljava/lang/String;)Ljava/util/Optional;
at org.springframework.data.redis.repository.configuration.RedisRepositoryConfigurationExtension.registerBeansForRoot(RedisRepositoryConfigurationExtension.java:88)

不用想原因了,版本冲突

具体是:SpringBoot1.x与Redis2.x的冲突。要么升SpringBoot的版本到2,要么降Redis的版本到1。SpringBoot牵一发而动全身,升是不可能升的。那么这个最简单的解决方式是使用不了了。

下来过程就不bb了,直接贴我最终选择的解决方式吧。给大家参考下

步骤一:配置Jedis连接池

@Configuration
public class JedisConfig {
//这些@Value的属性,是配置Redis的常用属性,赶着吃饭就不贴了
@Value("${spring.redis.host}")
    private String host;  
@Value("${spring.redis.port}")
    private int port;
@Value("${spring.redis.timeout}")
    private int timeout;
@Value("${spring.redis.pool.max-active}")
    private int maxActive;
@Value("${spring.redis.pool.max-idle}")
    private int maxIdle;
@Value("${spring.redis.pool.min-idle}")
    private int minIdle;
@Value("${spring.redis.pool.max-wait}")
    private long maxWaitMillis;
@Bean
public JedisPool redisPoolFactory(){
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle); //最大空闲连接
jedisPoolConfig.setMaxWaitMillis(maxWaitMillis); //最大阻塞等待时间
        jedisPoolConfig.setMaxTotal(maxActive); //连接池最大连接数
jedisPoolConfig.setMinIdle(minIdle); //最小空闲连接
        JedisPool jedisPool = new JedisPool(jedisPoolConfig,host,port,timeout,null);
return jedisPool;
    }
}

步骤二:编写Jedis工具类:主要就是个提供锁的方法

public class JedisUtils {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX"; //nx指令:即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作
    private static final String SET_WITH_EXPIRE_TIME = "PX"//px指令:给这个key加一个过期的设置,具体时间由第五个参数决定。
/**
     * 尝试获取Redis锁
* @param jedisPool jedis客户端连接池
* @param lockUrl 要锁的请求URL
* @param lockKey 请求标识,存到Value中
* @param expireTime 过期时间,单位:毫秒
* @return true:无需防重 false:redis中存在该key,则需防止重复
*/
public static boolean tryGetDistributedLock(JedisPool jedisPool, String lockUrl, String lockKey, int expireTime) {
    if (MyUtils.isEmpty(lockUrl) || MyUtils.isEmpty(lockKey)) return true;   //不用防重
String newKey = MD5Utils.encoderByMd5(lockUrl + lockKey);
try (Jedis jedis = jedisPool.getResource()) {
String result = jedis.set(newKey, lockKey, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return LOCK_SUCCESS.equals(result);  //result = OK || result=null
    } catch (JedisConnectionException e) { 
        return true;    //关闭资源时出现异常,返回true还是false?可以再斟酌下
}
}

步骤三:逻辑代码中调用

@Autowired
private JedisPool jedisPool;


@GetMapping("/test")
public Response testLock(String idNumber) {
    //设置过期时长为2秒
    boolean isLock = JedisUtils.tryGetDistributedLock(".../regist",idNumber,2000);
if(!isLock){
return new Response<>(ResultCode.NOT_REPEAT_SUBMISSION); //不能重复提交
}
return new Response<>();
}

步骤四:测试

使用Jmeter或者PostMan测压时,发现其很少模拟出同一毫秒级别的多并发(更别说人了),遂手敲多线程代码测试:

public class MyTest {
  public static void main(String[ ] args) throws Exception{
long a = System.currentTimeMillis(); //开始时间
TestClient t1 = new TestClient();
TestClient t2 = new TestClient();
TestClient t3 = new TestClient();
t1.start();
        t2.start();
        Thread.sleep(2100);     //main主线程休眠2.1秒
        log.info(">>>>>>>>>>use time:"+(System.currentTimeMillis()-a));
        t3.start();             //再去发请求,查看返回码,发现锁已经解开了
  }
   
private static class TestClient extends Thread {
   public TestClient() {}
public void run() {
      int i = 0;
      while(i<50) {  //3个线程同时各请求50次
Map<String, Object> result;
      try {
         String api ="http://localhost:20018/test?idNumber=1111111111";
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Map> resp = restTemplate.exchange(api, HttpMethod.GET, null, Map.class);
int statusCode = resp.getStatusCode().value();
         if(statusCode==200){
result= resp.getBody();
             log.info(">>>>>>>>>>resp code:"+result.get("code"));
          }
       } catch (Exception e) {
e.printStackTrace();
}
i++;
  }}}
}

结果:3个线程,对同一接口同时请求150次,只返回了两次success!符合我预期的结果~

   


文章转载自源成蹊,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论