springboot+websocket實(shí)現(xiàn)并發(fā)搶紅包功能
概述
搶紅包功能作為幾大高并發(fā)場(chǎng)景中典型,應(yīng)該如何實(shí)現(xiàn)?
源碼地址:https://gitee.com/tech-famer/farmer-redpacket
分析
參考微信搶紅包功能,將搶紅包分成一下幾個(gè)步驟:
- 發(fā)紅包;主要填寫(xiě)紅包信息,生成紅包記錄
- 紅包支付回調(diào);用戶發(fā)紅包支付成功后,收到微信支付付款成功的回調(diào),生成指定數(shù)量的紅包。
- 搶紅包;用戶并發(fā)搶紅包。
- 拆紅包;記錄用戶搶紅包記錄,轉(zhuǎn)賬搶到的紅包金額。
?效果展示
項(xiàng)目使用sessionId模擬用戶,示例打開(kāi)倆個(gè)瀏覽器窗口模擬兩個(gè)用戶。
設(shè)計(jì)開(kāi)發(fā)
表結(jié)構(gòu)設(shè)計(jì)
紅包記錄在 redpacket
表中,用戶領(lǐng)取紅包詳情記錄在 redpacket_detail
表中。
CREATE DATABASE `redpacket`; use `redpacket`; CREATE TABLE `redpacket`.`redpacket` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID', `packet_no` varchar(32) NOT NULL COMMENT '訂單號(hào)', `amount` decimal(5,2) NOT NULL COMMENT '紅包金額最高10000.00元', `num` int(11) NOT NULL COMMENT '紅包數(shù)量', `order_status` int(4) NOT NULL DEFAULT '0' COMMENT '訂單狀態(tài):0初始、1待支付、2支付成功、3取消', `pay_seq` varchar(32) DEFAULT NULL COMMENT '支付流水號(hào)', `create_time` datetime NOT NULL COMMENT '創(chuàng)建時(shí)間', `user_id` varchar(32) NOT NULL COMMENT '用戶ID', `update_time` datetime NOT NULL COMMENT '更新時(shí)間', `pay_time` datetime DEFAULT NULL COMMENT '支付時(shí)間', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='紅包訂單表'; CREATE TABLE `redpacket`.`redpacket_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID', `packet_id` bigint(20) NOT NULL COMMENT '紅包ID', `amount` decimal(5,2) NOT NULL COMMENT '紅包金額', `received` int(1) NOT NULL DEFAULT '0' COMMENT '是否領(lǐng)取0未領(lǐng)取、1已領(lǐng)取', `create_time` datetime NOT NULL COMMENT '創(chuàng)建時(shí)間', `update_time` datetime NOT NULL COMMENT '更新時(shí)間', `user_id` varchar(32) DEFAULT NULL COMMENT '領(lǐng)取用戶', `packet_no` varchar(32) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='紅包詳情表';
發(fā)紅包設(shè)計(jì)
用戶需要填寫(xiě)紅包金額、紅包數(shù)量、備注信息等,生成紅包記錄,微信收銀臺(tái)下單,返回用戶支付。
public RedPacket generateRedPacket(ReqSendRedPacketsVO data,String userId) { final BigDecimal amount = data.getAmount(); //紅包數(shù)量 final Integer num = data.getNum(); //初始化訂單 final RedPacket redPacket = new RedPacket(); redPacket.setPacketNo(UUID.randomUUID().toString().replace("-", "")); redPacket.setAmount(amount); redPacket.setNum(num); redPacket.setUserId(userId); Date now = new Date(); redPacket.setCreateTime(now); redPacket.setUpdateTime(now); int i = redPacketMapper.insertSelective(redPacket); if (i != 1) { throw new ServiceException("生成紅包出錯(cuò)", ExceptionType.SYS_ERR); } //模擬收銀臺(tái)下單 String paySeq = UUID.randomUUID().toString().replace("-", ""); //拿到收銀臺(tái)下單結(jié)果,更新訂單為待支付狀態(tài) redPacket.setOrderStatus(1);//待支付 redPacket.setPaySeq(paySeq); i = redPacketMapper.updateByPrimaryKeySelective(redPacket); if (i != 1) { throw new ServiceException("生成紅包出錯(cuò)", ExceptionType.SYS_ERR); } return redPacket; }
紅包支付成功回調(diào)設(shè)計(jì)
用戶支付成功后,系統(tǒng)接收到微信回調(diào)接口。
更新紅包支付狀態(tài)
二倍均值法生成指定數(shù)量紅包,并批量入庫(kù)。 紅包算法參考:Java實(shí)現(xiàn)4種微信搶紅包算法
紅包總數(shù)入redis,設(shè)置紅包過(guò)期時(shí)間24小時(shí)
websocket通知在線用戶收到新的紅包
@Transactional(rollbackFor = Exception.class) public void dealAfterOrderPayCallback(String userId,ReqOrderPayCallbackVO data) { RedPacketExample example = new RedPacketExample(); final String packetNo = data.getPacketNo(); final String paySeq = data.getPaySeq(); final Integer payStatus = data.getPayStatus(); example.createCriteria().andPacketNoEqualTo(packetNo) .andPaySeqEqualTo(paySeq) .andOrderStatusEqualTo(1);//待支付狀態(tài) //更新訂單支付狀態(tài) Date now = new Date(); RedPacket updateRedPacket = new RedPacket(); updateRedPacket.setOrderStatus(payStatus); updateRedPacket.setUpdateTime(now); updateRedPacket.setPayTime(now); int i = redPacketMapper.updateByExampleSelective(updateRedPacket, example); if (i != 1) { throw new ServiceException("訂單狀態(tài)更新失敗", ExceptionType.SYS_ERR); } if (payStatus == 2) { RedPacketExample query = new RedPacketExample(); query.createCriteria().andPacketNoEqualTo(packetNo) .andPaySeqEqualTo(paySeq) .andOrderStatusEqualTo(2); final RedPacket redPacket = redPacketMapper.selectByExample(query).get(0); final List<BigDecimal> detailList = getRedPacketDetail(redPacket.getAmount(), redPacket.getNum()); final int size = detailList.size(); if (size <= 100) { i = detailMapper.batchInsert(detailList, redPacket); if (size != i) { throw new ServiceException("生成紅包失敗", ExceptionType.SYS_ERR); } } else { int times = size % 100 == 0 ? size / 100 : (size / 100 + 1); for (int j = 0; j < times; j++) { int fromIndex = 100 * j; int toIndex = 100 * (j + 1) - 1; if (toIndex > size - 1) { toIndex = size - 1; } final List<BigDecimal> subList = detailList.subList(fromIndex, toIndex); i = detailMapper.batchInsert(subList, redPacket); if (subList.size() != i) { throw new ServiceException("生成紅包失敗", ExceptionType.SYS_ERR); } } } final String redisKey = REDPACKET_NUM_PREFIX + redPacket.getPacketNo(); String lua = "local i = redis.call('setnx',KEYS[1],ARGV[1])\r\n" + "if i == 1 then \r\n" + " local j = redis.call('expire',KEYS[1],ARGV[2])\r\n" + "end \r\n" + "return i"; //優(yōu)化成lua腳本 final Long execute = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), Arrays.asList(redisKey), size, 3600 * 24); if (execute != 1L) { throw new ServiceException("生成紅包失敗", ExceptionType.SYS_ERR); } //websocket通知在線用戶收到新的紅包 Websocket.sendMessageToUser(userId, JSONObject.toJSONString(redPacket)); } } /** * 紅包隨機(jī)算法 * * @param amount 紅包金額 * @param num 紅包數(shù)量 * @return 隨機(jī)紅包集合 */ private List<BigDecimal> getRedPacketDetail(BigDecimal amount, Integer num) { List<BigDecimal> redPacketsList = new ArrayList<>(num); //最小紅包金額 final BigDecimal min = new BigDecimal("0.01"); //最少需要紅包金額 final BigDecimal bigNum = new BigDecimal(num); final BigDecimal atLastAmount = min.multiply(bigNum); //出去最少紅包金額后剩余金額 BigDecimal remain = amount.subtract(atLastAmount); if (remain.compareTo(BigDecimal.ZERO) == 0) { for (int i = 0; i < num; i++) { redPacketsList.add(min); } return redPacketsList; } final Random random = new Random(); final BigDecimal hundred = new BigDecimal("100"); final BigDecimal two = new BigDecimal("2"); BigDecimal redPacket; for (int i = 0; i < num; i++) { if (i == num - 1) { redPacket = remain; } else { //100內(nèi)隨機(jī)獲得的整數(shù) final int rand = random.nextInt(100); redPacket = new BigDecimal(rand).multiply(remain.multiply(two).divide(bigNum.subtract(new BigDecimal(i)), 2, RoundingMode.CEILING)).divide(hundred, 2, RoundingMode.FLOOR); } if (remain.compareTo(redPacket) > 0) { remain = remain.subtract(redPacket); } else { remain = BigDecimal.ZERO; } redPacketsList.add(min.add(redPacket)); } return redPacketsList; }
頁(yè)面加載成功后初始化websocket,監(jiān)聽(tīng)后端新紅包生成成功,動(dòng)態(tài)添加紅包到聊天窗口。
$(function (){ var websocket; if('WebSocket' in window) { console.log("此瀏覽器支持websocket"); websocket = new WebSocket("ws://127.0.0.1:8082/websocket/${session.id}"); } else if('MozWebSocket' in window) { alert("此瀏覽器只支持MozWebSocket"); } else { alert("此瀏覽器只支持SockJS"); } websocket.onopen = function(evnt) { console.log("鏈接服務(wù)器成功!") }; websocket.onmessage = function(evnt) { console.log(evnt.data); var json = eval('('+evnt.data+ ')'); obj.addPacket(json.id,json.packetNo,json.userId) }; websocket.onerror = function(evnt) {}; websocket.onclose = function(evnt) { console.log("與服務(wù)器斷開(kāi)了鏈接!") } });
搶紅包設(shè)計(jì)
搶紅包設(shè)計(jì)高并發(fā),本地單機(jī)項(xiàng)目,通過(guò)原子Integer控制搶紅包接口并發(fā)限制為20,
private AtomicInteger receiveCount = new AtomicInteger(0); @PostMapping("/receive") public CommonJsonResponse receiveOne(@Validated @RequestBody CommonJsonRequest<ReqReceiveRedPacketVO> vo) { Integer num = null; try { //控制并發(fā)不要超過(guò)20 if (receiveCount.get() > 20) { return new CommonJsonResponse("9999", "太快了"); } num = receiveCount.incrementAndGet(); final String s = orderService.receiveOne(vo.getData()); return StringUtils.isEmpty(s) ? CommonJsonResponse.ok() : new CommonJsonResponse("9999", s); } finally { if (num != null) { receiveCount.decrementAndGet(); } } }
對(duì)于沒(méi)有領(lǐng)取過(guò)該紅包的用戶,在紅包沒(méi)有過(guò)期且紅包還有剩余的情況下,搶紅包成功,記錄成功標(biāo)識(shí)入redis,設(shè)置標(biāo)識(shí)過(guò)期時(shí)間為5秒。
public String receiveOne(ReqReceiveRedPacketVO data) { final Long redPacketId = data.getPacketId(); final String redPacketNo = data.getPacketNo(); final String redisKey = REDPACKET_NUM_PREFIX + redPacketNo; if (!redisTemplate.hasKey(redisKey)) { return "紅包已經(jīng)過(guò)期"; } final Integer num = (Integer) redisTemplate.opsForValue().get(redisKey); if (num <= 0) { return "紅包已搶完"; } RedPacketDetailExample example = new RedPacketDetailExample(); example.createCriteria().andPacketIdEqualTo(redPacketId) .andReceivedEqualTo(1) .andUserIdEqualTo(data.getUserId()); final List<RedPacketDetail> details = detailMapper.selectByExample(example); if (!details.isEmpty()) { return "該紅包已經(jīng)領(lǐng)取過(guò)了"; } final String receiveKey = REDPACKET_RECEIVE_PREFIX + redPacketNo + ":" + data.getUserId(); //優(yōu)化成lua腳本 String lua = "local i = redis.call('setnx',KEYS[1],ARGV[1])\r\n" + "if i == 1 then \r\n" + " local j = redis.call('expire',KEYS[1],ARGV[2])\r\n" + "end \r\n" + "return i"; //優(yōu)化成lua腳本 final Long execute = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), Arrays.asList(receiveKey), 1, 5); if (execute != 1L) { return "太快了"; } return ""; }
拆紅包設(shè)計(jì)
在用戶搶紅包成功標(biāo)識(shí)未過(guò)期的狀態(tài)下,且紅包未過(guò)期紅包未領(lǐng)完時(shí),從數(shù)據(jù)庫(kù)中領(lǐng)取一個(gè)紅包,領(lǐng)取成功將領(lǐng)取記錄寫(xiě)入redis以供查詢(xún)過(guò)期時(shí)間為48小時(shí)。
@Transactional(rollbackFor = Exception.class) public String openRedPacket(ReqReceiveRedPacketVO data) { final Long packetId = data.getPacketId(); final String packetNo = data.getPacketNo(); final String userId = data.getUserId(); final String redisKey = REDPACKET_NUM_PREFIX + packetNo; Long num = null; try { final String receiveKey = REDPACKET_RECEIVE_PREFIX + packetNo + ":" + userId; if (!redisTemplate.hasKey(receiveKey)) { log.info("未獲取到紅包資格,packet:{},user:{}", packetNo, userId); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } redisTemplate.delete(receiveKey); if (!redisTemplate.hasKey(redisKey)) { log.info("紅包過(guò)期了,packet:{}", packetNo); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } num = redisTemplate.opsForValue().increment(redisKey, -1); if (num < 0L) { log.info("紅包領(lǐng)完了,packet:{}", packetNo); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } final int i = detailMapper.receiveOne(packetId, packetNo, userId); if (i != 1) { log.info("紅包真的領(lǐng)完了,packet:{}", packetNo); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } RedPacketDetailExample example = new RedPacketDetailExample(); example.createCriteria().andPacketIdEqualTo(packetId) .andReceivedEqualTo(1) .andUserIdEqualTo(userId); final List<RedPacketDetail> details = detailMapper.selectByExample(example); if (details.size() != 1) { log.info("已經(jīng)領(lǐng)取過(guò)了,packet:{},user:{}", packetNo, userId); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } //處理加款 log.info("搶到紅包金額{},packet:{},user:{}", details.get(0).getAmount(), packetNo, userId); final String listKey = REDPACKET_LIST_PREFIX + packetNo; redisTemplate.opsForList().leftPush(listKey,details.get(0)); redisTemplate.expire(redisKey, 48, TimeUnit.HOURS); return "" + details.get(0).getAmount(); } catch (Exception e) { if (num != null) { redisTemplate.opsForValue().increment(redisKey, 1L); } log.warn("打開(kāi)紅包異常", e); throw new ServiceException("紅包飛走了", ExceptionType.SYS_ERR); } }
其中 detailMapper.receiveOne(packetId, packetNo, userId);
sql如下,將指定紅包記錄下未領(lǐng)取的紅包更新一條未當(dāng)前用戶已經(jīng)領(lǐng)取,若成功更新一條則表示領(lǐng)取成功,否則領(lǐng)取失敗。
update redpacket_detail d set received = 1,update_time = now(),user_id = #{userId,jdbcType=VARCHAR} where received = 0 and packet_id = #{packetId,jdbcType=BIGINT} and packet_no = #{packetNo,jdbcType=VARCHAR} and user_id is null limit 1
獲取紅包領(lǐng)取記錄設(shè)計(jì)
直接充redis中獲取用戶領(lǐng)取記錄,沒(méi)有則直接獲取數(shù)據(jù)庫(kù)并同步至redis。
public RespReceiveListVO receiveList(ReqReceiveListVO data) { //紅包記錄redisKey final String packetNo = data.getPacketNo(); final String redisKey = REDPACKET_LIST_PREFIX + packetNo; if (!redisTemplate.hasKey(redisKey)) { RedPacketDetailExample example = new RedPacketDetailExample(); example.createCriteria().andPacketNoEqualTo(packetNo) .andReceivedEqualTo(1); final List<RedPacketDetail> list = detailMapper.selectByExample(example); redisTemplate.opsForList().leftPushAll(redisKey, list); redisTemplate.expire(redisKey, 24, TimeUnit.HOURS); } List retList = redisTemplate.opsForList().range(redisKey, 0, -1); final Object collect = retList.stream().map(item -> { final JSONObject packetDetail = (JSONObject) item; return ReceiveRecordVO.builder() .amount(packetDetail.getBigDecimal("amount")) .receiveTime(packetDetail.getDate("updateTime")) .userId(packetDetail.getString("userId")) .packetId(packetDetail.getLong("redpacketId")) .packetNo(packetDetail.getString("redpacketNo")) .build(); }).collect(Collectors.toList()); return RespReceiveListVO.builder().list((List) collect).build(); }
jmeter并發(fā)測(cè)試搶紅包、查紅包接口
設(shè)置jmeter參數(shù)1秒中并發(fā)請(qǐng)求50個(gè)搶11個(gè)紅包,可以看到,前面的請(qǐng)求都是成功的,中間并發(fā)量上來(lái)后有部分達(dá)到并發(fā)上限被攔截,后面紅包搶完請(qǐng)求全部失敗。
到此這篇關(guān)于springboot+websocket實(shí)現(xiàn)并發(fā)搶紅包功能的文章就介紹到這了,更多相關(guān)springboot+websocket并發(fā)搶紅包內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java日常練習(xí)題,每天進(jìn)步一點(diǎn)點(diǎn)(29)
下面小編就為大家?guī)?lái)一篇Java基礎(chǔ)的幾道練習(xí)題(分享)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧,希望可以幫到你2021-07-07基于Spring Mvc實(shí)現(xiàn)的Excel文件上傳下載示例
本篇文章主要介紹了基于Spring Mvc實(shí)現(xiàn)的Excel文件上傳下載示例,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-02-02Spring Boot 如何使用Liquibase 進(jìn)行數(shù)據(jù)庫(kù)遷移(操作方法)
在Spring Boot應(yīng)用程序中使用Liquibase進(jìn)行數(shù)據(jù)庫(kù)遷移是一種強(qiáng)大的方式來(lái)管理數(shù)據(jù)庫(kù)模式的變化,本文重點(diǎn)講解如何在Spring Boot應(yīng)用程序中使用Liquibase進(jìn)行數(shù)據(jù)庫(kù)遷移,從而更好地管理數(shù)據(jù)庫(kù)模式的變化,感興趣的朋友跟隨小編一起看看吧2023-09-09java中String與StringBuilder的區(qū)別
本篇文章介紹了,java中String與StringBuilder的區(qū)別。需要的朋友參考下2013-04-04關(guān)于SpringBoot攔截器中Bean無(wú)法注入的問(wèn)題
這兩天遇到SpringBoot攔截器中Bean無(wú)法注入問(wèn)題。下面介紹關(guān)于SpringBoot攔截器中Bean無(wú)法注入的問(wèn)題,感興趣的朋友一起看看吧2021-10-10SpringBoot中實(shí)現(xiàn)分布式的Session共享的詳細(xì)教程
這篇文章主要介紹了SpringBoot中實(shí)現(xiàn)分布式的Session共享,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-06-06