抢红包功能开发

接近过年的时候,公司的app需要开发一个功能,该功能有点类似微信的发红包,但是我们发的不是钱,是铜板,所以,开发的时候,我定义为铜板红包;
开发铜板红包的时候,需要考虑到很多,其中,铜板是可转换为人民币的,也就是程序是不能出现bug的,比如说用户5个铜板,可以发一个10个铜板红包的低级错误,出现bug造成公司的亏损,那么对于个人也是很重大的影响。

以下列出我考虑的问题和解决方案
1,领取红包,随机获取的铜板的数量的算法
这个算法我是完全参考微信红包来做的,微信红包的算法
随机,额度在0.01和(剩余平均值x2)之间。
例如:发100块钱,总共10个红包,那么平均值是10块钱一个,那么发出来的红包的额度在0.01元~20元之间波动。
当前面3个红包总共被领了40块钱时,剩下60块钱,总共7个红包,那么这7个红包的额度在:0.01~(60/7*2)=17.14之间。
注意:这里的算法是每被抢一个后,剩下的会再次执行上面的这样的算法;
本人对这个算法,进行稍微的改造,最低的额度为剩余的金额除以100得出的金额;
代码实现:

private BigDecimal getRandomRedPacketAmount(BigDecimal surplusAmount, int surplusCount) {
    if (surplusCount == 1) {
        return surplusAmount;
    }
    BigDecimal minAmount = surplusAmount.divide(BigDecimal.valueOf(100), 6, BigDecimal.ROUND_HALF_UP);
    BigDecimal maxAmount = surplusAmount.divide(BigDecimal.valueOf(surplusCount), 6, BigDecimal.ROUND_HALF_UP).multiply(BigDecimal.valueOf(2));
    double value = maxAmount.subtract(minAmount).add(minAmount).doubleValue();
    BigDecimal redPacketAmount = BigDecimal.valueOf(random.nextDouble() * value);
    return redPacketAmount.setScale(6, BigDecimal.ROUND_HALF_UP);
}

2,如何解决并发抢红包的问题
通过开发这个功能,让我明白了一个知识点;像秒杀商品和抢红包的并发点不在于并发请求的数量有多大,而是竞争的资源有多少;
怎么理解这句话?假设某个商品库存只有一件,现在有1百万个并发过来抢这个商品(故意描述这么大的数量),那么只有一个请求会请求到数据库这一层,而99999的请求会被缓存这一层就被拦截了,更新对数据库没哟影响,但是如果有100的库存,那么就有100的请求同时进入到数据库这一层,并发去请求竞争同一个资源,试想一下,同一时刻,有100个请求同时修改某一个商品的库存数量,这个并发就很大了;所以说并发的问题不在于请求的数量,根本原因在于商品库存的数量。

如何解决这个问题;
上面的描述已经说了,可以使用缓存,拦截一些无效的请求,比如100个商品,1000个人同时请求,那么,肯定有900个人不能请求到的,缓存层就可以直接过滤掉这一些请求了;
我这边使用redis的decr命令来对铜板红包数量进行减操作,如果decr返回的值小于0则说明已经没有红包可以领取了

String tokenRedpacketStockKey = String.format(TOKEN_REDPACKET_STOCK_REDIS_KEY, tokenRedPacketId);
if (!redisUtils.exists(tokenRedpacketStockKey) || redisUtils.get(tokenRedpacketStockKey).equals(0) || redisUtils.decr(tokenRedpacketStockKey, 1) < 0) {
    throw new ServiceException("铜板红包已领取完");
}

那么接下的问题就是解决这穿透缓存层到数据库这一层的请求了;当时想的方案有很多:
一个是将并发的请求转换为同步的请求,使用队列;这个是一个废弃的方案,队列能实现削峰,但是并发转为同步,这样肯定是对性能不友好的,后面请求的就是一直等,而且把一些可以并发请求的代码也做出同步,这样就有点类似在代码加了一块很大的同步代码块,总的来说性能不好,而且队列是方式实现起来很多小细节也不太好处理;

乐观锁,说白了,这些并发的问题就是同一时刻更新商品库存,导致会出现并发读取更新的问题。所以我们主要需要解决的也是这个并发更新的问题;
这里先简单的引出锁的优化,锁的优化无非就是两点,减少锁持有的时间和减少锁的粒度。
为了减少锁持有的时间,我一般都是先添加一条数据再更新数量,因为更新操作是加锁的,所以,为了减少加锁的时间,尽量把更新操作防在插入操作后面;
下面给出具体的代码实现

try {
    RedisLock.tryLock(updateTokenRedPacketKey, 5000L);
    while (true) {
        //判断红包是否还可以继续领取
        this.checkTokenRedPacketStock(tokenRedPacketDO);

        //根据不同的红包类型,计算用户可以领取的铜板数量
        if (tokenRedPacketDO.getReceiveType().equals(TokenRedPacketReceiveEnum.AVERAGE.code)) {
            amount = tokenRedPacketDO.getAvgAmount();
        } else {
            amount = this.getRandomRedPacketAmount(tokenRedPacketDO.getSurplusAmount(), tokenRedPacketDO.getSurplusQuantity());
        }
        //如果用户的领取的红包铜板数量大于红包剩余的数量,则把剩余数量全部给用户
        if (tokenRedPacketDO.getSurplusAmount().compareTo(amount) < 0) {
            amount = tokenRedPacketDO.getSurplusAmount();
        }
        BigDecimal newAmount = tokenRedPacketDO.getSurplusAmount().subtract(amount);
        tokenRedPacketDO.setSurplusAmount(newAmount);
        tokenRedPacketDO.setSurplusQuantity(tokenRedPacketDO.getSurplusQuantity() - 1);
        tokenRedPacketDO.setReceiveQuantity(tokenRedPacketDO.getReceiveQuantity() + 1);
        if (tokenRedPacketDO.getQuantity().equals(tokenRedPacketDO.getReceiveQuantity())) {
            log.info("铜板红包已经领取领取完毕!");
            tokenRedPacketDO.setEndTime(currentTime);
            tokenRedPacketDO.setReceiveFinishTime(currentTime);
            tokenRedPacketDO.setStatus(TokenRedPacketStatusEnum.END.code);

            //删除缓存的key
            String tokenReadPacketStockRediskey = String.format(TOKEN_REDPACKET_STOCK_REDIS_KEY, tokenRedPacketDO.getId());
            String tokenRedPacketRecordRedisHashKey = String.format(TOKEN_REDPACKET_RECORD_REDIS_HASH_KEY, tokenRedPacketDO.getId());
            redisUtils.del(tokenReadPacketStockRediskey);
            redisUtils.expire(tokenRedPacketRecordRedisHashKey, TimeUnit.MINUTES.toSeconds(30));
        }
        //更新的时候加上了版本号作为乐观锁的控制
        boolean isSuccess = this.update(tokenRedPacketDO, new QueryWrapper<TokenRedPacketDO>().eq("id", tokenRedPacketId));
        if (isSuccess) {
            //这里使用了mybatis的乐观锁,更新成功之后,tokenRedPacketDOde version会自动加1
            redisUtils.set(tokenRedPacketKey, tokenRedPacketDO, TimeUnit.MINUTES.toSeconds(5));
            log.info("更新铜板红包数量成功!");
            break;
        }
        log.info("出现并发!当前并发重试次数为{}", concurrencyTimes);
        tokenRedPacketDO = this.findTokenRedPacketByDBAndCache(tokenRedPacketId);
        if (MAX_CONCURRENCY_TIMES == concurrencyTimes) {
            redisUtils.incr(tokenRedpacketStockKey, 1);
            log.info("并发次数超过了{}", MAX_CONCURRENCY_TIMES);
            throw new ServiceException(6000, "领取红包失败", "60002", "系统繁忙,请稍后再试");
        }
        concurrencyTimes++;
    }
} finally {
    RedisLock.unlock(updateTokenRedPacketKey);
}

3,如何实现抢红包的性能
乐观锁,redis缓存

4,创建红包的一些相关的安全操作
做好幂等性的校验是其一,其二就是判断用户创建的铜板红包的数量不能大于用户实际钱包里面的数量;

5,避免同一个用户领取多次
用户领取过就会在redis里面添加一条记录,对应为map的哈希结构;
redisUtils.hset(tokenRedpacketRecordHashKey, content.getPhone(), tokenRedPacketReceiveRecordVO);

String tokenRedpacketRecordHashKey = String.format(TOKEN_REDPACKET_RECORD_REDIS_HASH_KEY, tokenRedPacketId);
if (redisUtils.hget(tokenRedpacketRecordHashKey, content.getPhone()) != null) {
    throw new ServiceException(6000, "领取红包失败", "60001", "不能重复领取");
}

最后说明:
为何铜板,暂且就把他当做区块链里面的虚拟货币吧,而且还会增值;

Add a Comment

电子邮件地址不会被公开。 必填项已用*标注