数据库脚本
CREATE TABLE red_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL COMMENT '用户id', red_packet varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '红包全局唯一标识串', total int(11) NOT NULL COMMENT '人数', amount decimal(10,2) DEFAULT NULL COMMENT '总金额(单位为分)', is_active tinyint(4) DEFAULT '1', create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8 COMMENT='发红包记录'; CREATE TABLE red_detail ( id int(11) NOT NULL AUTO_INCREMENT, record_id int(11) NOT NULL COMMENT '红包记录id', amount decimal(8,2) DEFAULT NULL COMMENT '金额(单位为分)', is_active tinyint(4) DEFAULT '1', create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB AUTO_INCREMENT=83 DEFAULT CHARSET=utf8 COMMENT='红包明细金额'; CREATE TABLE red_rob_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) DEFAULT NULL COMMENT '用户账号', red_packet varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '红包标识串', amount decimal(8,2) DEFAULT NULL COMMENT '红包金额(单位为分)', rob_time datetime DEFAULT NULL COMMENT '时间', is_active tinyint(4) DEFAULT '1', PRIMARY KEY (id) ) ENGINE=InnoDB AUTO_INCREMENT=72 DEFAULT CHARSET=utf8 COMMENT='抢红包记录';本系统中,红包的随机金额主要是“预生成”的方式产生的,即通过给定的红包的总金额和红包个数,采用某种随机算法生成红包随机列表,并将其放在缓存中,用于拆红包的逻辑 这里随机算法采用二倍均值法
这里为了保证系统每次总金额M和红包个数N生成的小红包金额是随机的且概率相等。即保证每个用户抢到的红包是随机且概率相等的。 二倍均值法的思想: 每次剩余的总金额 :M 剩余人数:N 执行 M 除 N 乘 2 得到边界值:E 然后指定一个[0,E]的随机区间,在这个随机区间产生一个随机数: R 此时将总金额M = M-R 剩余人数 N = N-1 以此类推直到N - 1 = 0; 算法流程图如下
package com.learn.boot.utils; import java.util.ArrayList; import java.util.List; import java.util.Random; /** * 二倍均值算法生成随机红包 */ public class RedBagUtils { /** 发红包算法,金额是以分为单位的 * @param totalBagMoney 发红包总金额 * @param totalBagNumber 抢红包人数 * @return */ public static List<Integer> divideRedBag(Integer totalBagMoney,Integer totalBagNumber) { List<Integer> result = new ArrayList<>(10); if (totalBagMoney > 0 && totalBagNumber > 0) { // 记录红包的总金额,初始化时即为红包的总金额 Integer bagMoneyAmout = totalBagMoney; // 记录抢红包的总人数 Integer bagNumber = totalBagNumber; // 定义随机产生对象 Random random = new Random(); while (bagNumber -1 > 0) { // 产生随机数,[1,剩余人均金额的两倍],这里用的是最小单位分 int randMoney = random.nextInt(bagMoneyAmout/bagNumber * 2 -1 ) + 1; // 更新剩余金额 bagMoneyAmout -= randMoney; // 更新总人数 bagNumber --; result.add(randMoney); } // 循环完毕,最后一个剩余红包要加入结果集 result.add(bagMoneyAmout); } return result; } }测试代码
package com.learn.boot.utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.util.List; public class Test { private static final Logger log= LoggerFactory.getLogger(Test.class); public static void main(String[] args) { Integer money = 1000; Integer num = 10; List<Integer> result = RedBagUtils.divideRedBag(money,num); log.info("总金额 = {}分,抢红包个数={}",money,num); final Integer[] sum = {0}; result.forEach(item -> { log.info("随机金额为:{}分,即 {}元",item,new BigDecimal(item.toString()).divide(new BigDecimal(100))); sum[0] += item; }); log.info("所有随机金额加起来等于{}",sum[0]); } }为了本模块的规范性和可读性,采用MVVM的模式开发相应的模块, M 模型层 V 视图层,这里暂时不用,全是接口的形式 C控制层,接收请求处理, M 中间件,采用中间件辅助
@RequestMapping("sendBag") public ResultVo handOut(@Validated @RequestBody RedPacketDto dto, BindingResult result) { // 参数校验 if (result.hasErrors()) { return ResultVo.error("参数异常"); } return iRedPacketService.handOut(dto); } @Override public ResultVo handOut(RedPacketDto dto) { try { //采用二倍均值法生成随机金额列表(在上一节已经采用代码实现了二倍均值 法) List<Integer> list = RedBagUtils.divideRedBag(dto.getAmount(), dto.getTotal()); //生成红包全局唯一标识串 String timestamp=String.valueOf(System.nanoTime()); //根据缓存Key的前缀与其他信息拼接成一个新的用于存储随机金额列表的Key String redId = new StringBuffer("red:packet:").append(dto. getUserId()).append(":").append(timestamp).toString(); // 将随机金额列表存入缓存列表中 redisTemplate.opsForList().leftPushAll(redId,list); // 设置一天的时间戳 redisTemplate.expire(redId, 65535, TimeUnit.SECONDS); // 根据缓存Key的前缀与其他信息拼接成一个新的用于存储红包总数的Key String redTotalKey = redId+":total"; // 将红包总数存入缓存中 redisTemplate.opsForValue().set(redTotalKey,dto.getTotal()); // 设置一天的时间戳 redisTemplate.expire(redTotalKey, 65535, TimeUnit.SECONDS); // 异步记录红包的全局唯一标识串、红包个数与随机金额列表入数据库 redService.recordRedPacket(dto,redId,list); //将红包的全局唯一标识串返回给前端 return ResultVo.success("发送红包成功",redId); }catch (Exception ex) { log.error("发红包异常"); return ResultVo.error("发红包异常"); } } @Async @Override @Transactional(rollbackFor = Exception.class) public void recordRedPacket(RedPacketDto dto, String redId, List<Integer> list) throws Exception { //定义实体类对象 RedRecord redRecord=new RedRecord(); //设置字段的取值信息 redRecord.setUserId(dto.getUserId()); redRecord.setRedPacket(redId); redRecord.setTotal(dto.getTotal()); redRecord.setAmount(BigDecimal.valueOf(dto.getAmount())); redRecord.setCreateTime(new Date()); //将对象信息插入数据库 redRecordMapper.insertSelective(redRecord); //定义红包随机金额明细实体类对象 RedDetail detail; //遍历随机金额列表,将金额等信息设置到相应的字段中 for (Integer i:list){ detail=new RedDetail(); detail.setRecordId(redRecord.getId()); detail.setAmount(BigDecimal.valueOf(i)); detail.setCreateTime(new Date()); //将对象信息插入数据库 redDetailMapper.insertSelective(detail); } }出现了一个用户抢了多个红包
原因在于
// 先从随机金币列表中随便弹出一个值 Integer money = (Integer)redisTemplate.opsForList().leftPop(dto.getRedId()); if (money == null) { return ResultVo.error("手慢了"); }这段代码存在高并发带来的问题,假设有两个用户id的一样进来了,那么就从随机金币列表取到了两个值,并且入库 解决方法 redis 的setNx ,完好的解决了这个问题,在不存在的时候入缓存,如果之前存在过就返回false