最近客户反馈了个很奇怪的现象:个别用户注册完再登录老是提示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 failedjava.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连接池
@Configurationpublic 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;@Beanpublic 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?可以再斟酌下}}
步骤三:逻辑代码中调用
@Autowiredprivate 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!符合我预期的结果~




