secKill项目 --- 限制同一用户只有一个请求生效

完成了redis的库存的补充,redis库存和数据库库存,现在已经可以保证最终一致性了。

但这并不等同于项目的正确性


先来回顾一下秒杀控制器的逻辑(核心部分源代码,其余伪代码。)

此处已实现库存补偿篇所说的优化 : 先检查重复秒杀,再预减库存

	@RequestMapping(value="/{path}/do_miaosha",method=RequestMethod.POST)
	@ResponseBody
	public Result<Integer> doMiaosha() {
        
		//0.判断用户是否非空、秒杀路径是否正确
        
        // 1. 查看内存标记,看是否已结束
        
        // 2.判断这个秒杀订单形成没有,避免重复秒杀
        
		//3 预减少redis的库存
		long stock=redisService.decr(GoodsKey.getMiaoshaGoodsStock,""+goodsId);
		// 4 判断减数量之后的stock
		if (stock < 0) {
            // 库存补充
            redisService.incr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
            // 设置 内存标记
            localOverMap.put(goodsId, true);
            // 返回响应,秒杀已结束
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
        
		//5.正常请求,入队
        
		//返回0代表排队中
		return Result.success(0);
	}

但是还是有缺陷:

redis的库存还是会被同一用户的多个请求给抢掉,后续虽然能补充成功。但需要注意:

		if (stock < 0) {
            // 库存补充
            redisService.incr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
            // 设置 内存标记
            localOverMap.put(goodsId, true);
            // 返回响应,秒杀已结束
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }

这里有个问题:

内存标记设置为true了。那么后续即使把redis库存补充起来了。

对后续用户而言,根本通过不了第一步的内存标记。即:虽然还有库存,但买不到。

简单的解决办法,就是舍弃掉内存标记这一步,根据redis.decr 的结果判断是否入队,这样后续对redis的补充自然就能反馈到此处。

但这就完全抛弃了这里优势:减少对redis的压力。而且这一步对于流量的拦截是相当有力的,省去是很不合理的做法。


解决办法

那么有没有办法从根源解决问题呢?

即:限制同一个用户的秒杀请求最多只有一个生效。这样就能保证一个人最多只能减掉的redis的一个库存。

如果满足了,其实消费端也不会出现重复订单了。
但安全起见,其余代码笔者也不作修改。

这时要使用的就是redis的 setnx命令了。

可能人很多第一反应是"分布式锁",感觉性能很低。

确实"分布式锁"是通过setnx实现的,但那是自旋导致的等待。跟setnx本身没啥关系

而现在要用的,只是最基本的命令:不存在才设置,返回1。存在返回0。

封装函数如下

public <T> boolean setnx(KeyPrefix prefix, String key, T value) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String str = beanToString(value);
            if (str == null || str.length() <= 0) {
                return false;
            }
            //生成真正的key
            String realKey = prefix.getPrefix() + key;

            Long res = jedis.setnx(realKey,str);

            return res == 1;
        } finally {
            returnToPool(jedis);
        }
    }

只要在预减库存前,增加一个setnx的判断,用户id作为key,value随便放个。

如果设置成功,才放行。否则说明已经请求了,只是还没出结果,需要等待

伪代码如下:

	@RequestMapping(value="/{path}/do_miaosha",method=RequestMethod.POST)
	@ResponseBody
	public Result<Integer> doMiaosha() {
        
		//0.判断用户是否非空、秒杀路径是否正确
        
        // 1. 查看内存标记,看是否已结束
        
        // 2.判断这个秒杀订单形成没有,避免重复秒杀
        
        2.5 setnx,判断是否请求过
            // 注意key 要同时有 userId 和 goodsId 
         boolean b = redisService.setnx
            (MiaoshaKey.robRedisStock, "" + user.getId() + "_" + goodsId, true);
        if (!b) {
             return Result.error("已下单,请等待结果");
        }
        
		//3 预减少redis的库存
		// 4 判断减数量之后的stock
		if (stock < 0) {
        }

		return Result.success(0);
	}

这里需要注意 setnx 应该放在重复秒杀的判断后。倒不是正确性的问题。只是返回提示的不同。

因为能满足重复秒杀if的,肯定是满足if(!b)的,如果已经秒杀到了,却返回了个"请等待结果",会让使用者误解。


存在问题

这解决方案这么简洁,自然是有相应的问题存在的。

那就是消息出现异常时,需要删除掉key,才能让用户能重新请求。

项目中的"异常"包括:发送消息重发超限、消费出错等,但因为消费者宕机等很多原因,很容易就会漏删

所以我们需要给该key设置一个较短的过期时间。笔者认为设置为整个业务能完成的时间就比较合理。(我选了10)

至此,最终解决方案就出来了。


拓:@AccessLimit 能否完成任务?

答案是可行的。但是该注解也是由redis实现的。而且其是直接作用在controller的秒杀接口上

	@AccessLimit(seconds = 10, maxCount = 1)
    @RequestMapping(value = {"/{path}/do_miaosha")
    @ResponseBody
    public Result<Integer> miaosha(xxx)

就意味着每个调用该接口的都会先经拦截器处理,然后交由redis处理。再进入接口。

即:无法先经接口的内存标记筛选,也就受益不到内存标记的作用。而这显然是不合理的。


本文完,有误欢迎指出

你可能感兴趣的:(secKill项目纠错/改进,java,redis)