我们平时在用微信的时候,经常会用到‘抢红包’的功能。那么这样一个需求给我们的话,具体又应该怎么实现呢?
需求分析
1 发红包:在db、cache各新增一条记录
2 抢红包:有人发红包之后,肯定很多人同时去抢,所以应该请求访问cache,剩余红包个数大于0就可以点击拆开红包;反之提醒红包已经被抢完了
3 拆红包:总金额每次都是递减,可以用redis的decreby来做。
4 查看红包记录:用户直接查db即可。
这里面就会涉及到2个问题:
我只发了100个红包,并发下如何保证抢到红包的人数不会超过100.
红包总金额1w元,如何分配才能让金额不超出这个数,如何保证最后一个人一定能抢到钱.
数据库表设计
红包信息表主要字段: 谁发的红包,发红包时间,红包总个数、总金额、剩余红包信息、最后一次被抢红包时间
CREATE TABLE `red_packet_info` ( `id` int(11) NOT NULL AUTO_INCREMENT, `red_packet_id` bigint(11) NOT NULL DEFAULT 0 COMMENT '红包id,采用timestamp+5位随机数', `total_amount` int(11) NOT NULL DEFAULT 0 COMMENT '红包总金额,单位分', `total_packet` int(11) NOT NULL DEFAULT 0 COMMENT '红包总个数', `remaining_amount` int(11) NOT NULL DEFAULT 0 COMMENT '剩余红包金额,单位分', `remaining_packet` int(11) NOT NULL DEFAULT 0 COMMENT '剩余红包个数', `uid` int(20) NOT NULL DEFAULT 0 COMMENT '新建红包用户的用户标识', `create_time` timestamp COMMENT '创建时间', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='红包信息表,新建一个红包插入一条记录';
抢红包记录主要字段: 红包信息、抢红包人信息,抢红包时间 (主要都是查看抢红包记录列表的那些字段)
CREATE TABLE `red_packet_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `amount` int(11) NOT NULL DEFAULT '0' COMMENT '抢到红包的金额', `nick_name` varchar(32) NOT NULL DEFAULT '0' COMMENT '抢到红包的用户的用户名', `img_url` varchar(255) NOT NULL DEFAULT '0' COMMENT '抢到红包的用户的头像', `uid` int(20) NOT NULL DEFAULT '0' COMMENT '抢到红包用户的用户标识', `red_packet_id` bigint(11) NOT NULL DEFAULT '0' COMMENT '红包id,采用timestamp+5位随机数', `create_time` timestamp COMMENT '创建时间', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='抢红包记录表,抢一个红包插入一条记录';
编码实现
发红包
发红包之后,肯定立马会有很多人来抢,如果直接操作数据库会有很大的压力,所以我们把数据放到缓存里面去。
/*** * 发红包 * @param uid 发红包的用户id * @param totalNum 红包金额 * @param totalAmount 红包总个数 * @return */ @GetMapping("/addPacket") public String saveRedPacket(Integer uid, Integer totalNum, Integer totalAmount) { // 组装数据 RedPacketInfo record = new RedPacketInfo(); record.setUid(uid); record.setTotalAmount(totalAmount); record.setTotalPacket(totalNum); record.setCreateTime(new Date()); record.setRemainingAmount(totalAmount); record.setRemainingPacket(totalNum); // 雪花算法生成唯一id long redPacketId = new SnowflakeDistributeId(0, 0).nextId(); record.setRedPacketId(redPacketId); // 红包保存到数据库 redPacketInfoMapper.insert(record); // 红包个数和总金额存入缓存 redisService.set(redPacketId + "_totalNum", totalNum + ""); redisService.set(redPacketId + "_totalAmount", totalAmount + ""); return "success"; }
抢红包
用户点击红包之后,就查看红包数量,如果为0的话,点击拆红包就提示红包被抢完了;反之获取到红包金额数量
/** * 抢红包 * @param redPacketId 红包id * @param uid 用户id * @return */ @GetMapping("/getPacket") public String getRedPacket(long redPacketId, Integer uid) { Object record = redisService.get(uid + RECORD + redPacketId); // 如果用户已经抢过红包了,那点击抢红包就应该是查看抢红包的详细记录 if (StringUtils.isNotBlank((String)record)){ return "红包详细记录"; } // 查询红包剩余个数 String redPacketName = redPacketId + TOTAL_NUM; String num = (String) redisService.get(redPacketName); if (StringUtils.isNotBlank(num)) { return num; } return "0"; }
拆红包(核心)
这是重点也是难点,我们要保证领取红包的人数不能超过设置的红包个数,还要保证每一个人的红包都能抢到钱、还不能超过总金额。这就会涉及到线程安全问题。现在我们就来来想想,如何合理的生成红包随机金额数量。
1. 剩余总金额/剩余总个数 = 红包金额平均数
2. 由于红包是随机金额,我们的红包金额可以在这个平均值左右浮动,总和不变即可
这样设计,才能真正保证每个人拆开都能领到钱,而且总金额不会超支
/** * 拆红包 * @param redPacketId 红包id * @param uid 用户id * @return */ @GetMapping("/getRedPacketMoney") public String getRedPacketMoney(int uid, long redPacketId) { // 抢到的红包金额 Integer randomAmount = 0; String redPacketName = redPacketId + TOTAL_NUM; String totalAmountName = redPacketId + TOTAL_AMOUNT; // 预减获取红包剩余数量,decr原子减来防止领取人数超过红包个数 long decr = redisService.decr(totalAmountName, 1); if (decr<0){ System.out.println(uid+": 抱歉!红包已经抢完了"); return "抱歉!红包已经抢完了"; } // 下面就开始随机分配金额了,并发下可能领取人数的业务逻辑同时走到了这里, // 下面算法最后计算出来的金额就会和总金额有偏差,所以我们可以通过对红包 // id进行路由,放入同一个队列里面,从而保证顺序消费, // 这样金额总和就和总金额不会有偏差 // 剩余总金额(后面所有逻辑,都由下游业务去队列里面执行) Integer totalAmountInt = Integer.parseInt((String) redisService.get(redPacketName)); // 剩余金额 / 剩余红包个数 * 2 = 最大红包金额 Integer maxMoney = (int) (totalAmountInt / (decr + 1) * 2); Random random = new Random(); // 红包取值随机数,不超过最大金额(如果是最后一个红包,金额就是剩下的所有钱) randomAmount = random.nextInt(maxMoney); System.out.println(uid+": 抢到了 "+randomAmount+" 分钱"); // 红包剩余个数减1,同时剩余金额也要减少 redisService.decr(redPacketName,randomAmount); //redis decreby功能 redisService.set(uid + RECORD + redPacketId,randomAmount.toString()); // 数据库插入抢红包记录 updateRacketInDB(uid, redPacketId,randomAmount); return randomAmount + ""; } public void updateRacketInDB(int uid, long redPacketId, int amount) { // 数据库插入抢红包记录 RedPacketRecord redPacketRecord = new RedPacketRecord(); redPacketRecord.setUid(uid); redPacketRecord.setRedPacketId(redPacketId); redPacketRecord.setAmount(amount); redPacketRecord.setCreateTime(new Date()); redPacketRecordMapper.insertSelective(redPacketRecord); // 查询到红包信息 RedPacketInfoExample example = new RedPacketInfoExample (); RedPacketInfoExample.Criteria criteria = example.createCriteria(); criteria.andRedPacketIdEqualTo(redPacketId); RedPacketInfo redPacketInfo = redPacketInfoMapper.selectByExample(example).get(0); // 修改红包剩余信息 redPacketInfo.setRemainingPacket(redPacketInfo.getRemainingPacket()-amount); redPacketInfo.setRemainingAmount(redPacketInfo.getRemainingAmount()-1); redPacketInfo.setCreateTime(new Date()); redPacketInfoMapper.updateByPrimaryKey(redPacketInfo); }