我們平時在用微信的時候,經常會用到‘搶紅包’的功能。那么這樣一個需求給我們的話,具體又應該怎么實現呢?
需求分析
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); }