【仿12306项目】演示令牌大闸核心功能,校验库存和机器人防刷

一. 环境介绍

在仿12306项目中,在高并发抢票时,可以利用令牌大闸,进行库存校验和防止机器人刷票,以此减轻服务器的压力。

二. 校验库存

核心思想

在高并发场景下的余票库存查询,效率较低。因此可以针对某一车次,发布相对应比例的令牌,通过查询令牌是否足够,来校验库存。若令牌为0,则告诉用户余票不足,但此时可能仍然有余票,这种情况可以再手动增加一些令牌;若令牌不为0,则执行售票逻辑,并将令牌减1。

代码示例

    /**
     * 校验令牌
     * @param date
     * @param trainCode
     * @param memberId
     * @return
     */
    public boolean validSkToken(Date date, String trainCode, Long memberId){
        LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
        // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
        int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
        if (updateCount > 0){
            return true;
        }else {
            return false;
        }
    }
    @SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock")
    public void doConfirm(ConfirmOrderDoReq req) {

         // 校验令牌余量
         boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
         if (validSkToken) {
             LOG.info("令牌校验通过");
         } else {
             LOG.info("令牌校验不通过");
             throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
         }
         // 购票逻辑
}

 

三. 使用令牌锁防止机器人刷票

核心思想

通过令牌锁来限制某一用户在某一段时间内仅能拿到一次锁,进行只能一次购票,若在某一时间段多次购票操作,则返回异常。
则令牌锁应设计为: 日期+车次+用户ID

代码示例

    /**
     * 校验令牌
     * @param date
     * @param trainCode
     * @param memberId
     * @return
     */
    public boolean validSkToken(Date date, String trainCode, Long memberId){
        LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);

        // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
             String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
             Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
             if (Boolean.TRUE.equals(setIfAbsent)) {
                 LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
             } else {
                 LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
                 return false;
             }

        // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
        int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
        if (updateCount > 0){
            return true;
        }else {
            return false;
        }
    }

四. 改进——Redis缓存+数据库,减轻数据库压力

问题现象

在高并发抢票环境时,如果每一个请求都去操作数据库,那数据库肯定吃不消,在以下代码段中:

        int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
        if (updateCount > 0){
            return true;
        }else {
            return false;
        }

我们对每个请求的校验都会去操作数据库,因此很容易出现数据库崩溃问题,因此要加入Redis高速缓存,来减轻数据库压力

核心思想

首次查询,往缓存中存放数据,以后每次查询,先减去缓存中的数据,每过一定次数,再去数据库中更新,减轻数据库压力,伪代码如下:

		Object skTokenCount = redisTemplate.opsForValue().get(KEY);
		if (skTokenCount != null) {
           // 操作缓存数据
            if (操作后不满足条件) {
                LOG.error("获取令牌失败");
                return false;
            } else {
                LOG.info("获取令牌成功");
                // 重置过期时间
                redisTemplate.expire(KEY, 60, TimeUnit.SECONDS);
                // 每获取5个令牌更新一次数据库
                if (count % 5 == 0) {
                	// 同步数据库
                }
                return true;
            }
        } else {
            LOG.info("缓存中没有KEY");
            //查数据库

            // 不需要更新数据库,只要放缓存即可
            redisTemplate.opsForValue().set(KEY, String.valueOf(count), 60, TimeUnit.SECONDS);
            return true;
        }

代码示例

    /**
     * 校验令牌
     * @param date
     * @param trainCode
     * @param memberId
     * @return
     */
    public boolean validSkToken(Date date, String trainCode, Long memberId){
        LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);

        // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
        String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
        Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(setIfAbsent)) {
            LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
        } else {
            LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
            return false;
        }

        String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode;
        Object skTokenCount = redisTemplate.opsForValue().get(skTokenCountKey);
        if (skTokenCount != null) {
            LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey);
            Long count = redisTemplate.opsForValue().decrement(skTokenCountKey, 1);
            if (count < 0L) {
                LOG.error("获取令牌失败:{}", skTokenCountKey);
                return false;
            } else {
                LOG.info("获取令牌后,令牌余数:{}", count);
                redisTemplate.expire(skTokenCountKey, 60, TimeUnit.SECONDS);
                // 每获取5个令牌更新一次数据库
                if (count % 5 == 0) {
                    skTokenMapperCust.decrease(date, trainCode, 5);
                }
                return true;
            }
        } else {
            LOG.info("缓存中没有该车次令牌大闸的key:{}", skTokenCountKey);
            // 检查是否还有令牌
            SkTokenExample skTokenExample = new SkTokenExample();
            skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
            List<SkToken> tokenCountList = skTokenMapper.selectByExample(skTokenExample);
            if (CollUtil.isEmpty(tokenCountList)) {
                LOG.info("找不到日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
                return false;
            }

            SkToken skToken = tokenCountList.get(0);
            if (skToken.getCount() <= 0) {
                LOG.info("日期【{}】车次【{}】的令牌余量为0", DateUtil.formatDate(date), trainCode);
                return false;
            }

            // 令牌还有余量
            // 令牌余数-1
            Integer count = skToken.getCount() - 1;
            skToken.setCount(count);
            LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count);
            // 不需要更新数据库,只要放缓存即可
            redisTemplate.opsForValue().set(skTokenCountKey, String.valueOf(count), 60, TimeUnit.SECONDS);
            return true;
        }

}

最终可以实现,每隔五次操作,redis中数据同步到数据库中,减轻了数据库压力。

遇到的问题

Redis报错:

ERR value is not an integer or out of range. channel: [id: 0xed9c342c, L:/192.168.16.107:55421 - R:r-uf6ljbcdaxobsifyctpd.redis.rds.aliyuncs.com/47.103.172.100:6379] command: (DECRBY), promise: java.util.concurrent.CompletableFuture@7b986dc0[Not completed, 1 dependents], params: [[-84, -19, 0, 5, 116, 0, 30, 83, 75, 95, …], 1]

Redis中存放的value值:

\xac\xed\x00\x05t\x00\x019

应该是9,但是有一堆前缀。同时Key也有类似的前缀

解决方法:
添加RedisConfig.java类

@Component
public class RedisConfig {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired(required = false)
    public void setRedisTemplate(RedisTemplate redisTemplate) {
        RedisSerializer stringSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringSerializer);
        redisTemplate.setValueSerializer(stringSerializer);
        redisTemplate.setHashKeySerializer(stringSerializer);
        redisTemplate.setHashValueSerializer(stringSerializer);
        this.redisTemplate = redisTemplate;
    }

}

将redisTemplate默认改为stringRedis序列化方式,问题解决。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值