springboot+websocket怎样实现并发抢红包功能
这期内容当中小编将会给大家带来有关springboot+websocket怎样实现并发抢红包功能,文章内容丰富且以专业的角度为大家分析和叙述,阅读完这篇文章希望大家可以有所收获。
概述
抢红包功能作为几大高并发场景中典型,应该如何实现?
分析
参考微信抢红包功能,将抢红包分成一下几个步骤:
发红包;主要填写红包信息,生成红包记录
红包支付回调;用户发红包支付成功后,收到微信支付付款成功的回调,生成指定数量的红包。
抢红包;用户并发抢红包。
拆红包;记录用户抢红包记录,转账抢到的红包金额。
效果展示
项目使用sessionId模拟用户,示例打开俩个浏览器窗口模拟两个用户。
设计开发
表结构设计
红包记录在 redpacket
表中,用户领取红包详情记录在 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 '订单号', `amount` decimal(5,2) NOT NULL COMMENT '红包金额最高10000.00元', `num` int(11) NOT NULL COMMENT '红包数量', `order_status` int(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0初始、1待支付、2支付成功、3取消', `pay_seq` varchar(32) DEFAULT NULL COMMENT '支付流水号', `create_time` datetime NOT NULL COMMENT '创建时间', `user_id` varchar(32) NOT NULL COMMENT '用户ID', `update_time` datetime NOT NULL COMMENT '更新时间', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', 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 '是否领取0未领取、1已领取', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', `user_id` varchar(32) DEFAULT NULL COMMENT '领取用户', `packet_no` varchar(32) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='红包详情表';
发红包设计
用户需要填写红包金额、红包数量、备注信息等,生成红包记录,微信收银台下单,返回用户支付。
public RedPacket generateRedPacket(ReqSendRedPacketsVO data,String userId) { final BigDecimal amount = data.getAmount(); //红包数量 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("生成红包出错", ExceptionType.SYS_ERR); } //模拟收银台下单 String paySeq = UUID.randomUUID().toString().replace("-", ""); //拿到收银台下单结果,更新订单为待支付状态 redPacket.setOrderStatus(1);//待支付 redPacket.setPaySeq(paySeq); i = redPacketMapper.updateByPrimaryKeySelective(redPacket); if (i != 1) { throw new ServiceException("生成红包出错", ExceptionType.SYS_ERR); } return redPacket;}
红包支付成功回调设计
用户支付成功后,系统接收到微信回调接口。
更新红包支付状态
二倍均值法生成指定数量红包,并批量入库。 红包算法参考:Java实现4种微信抢红包算法
红包总数入redis,设置红包过期时间24小时
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);//待支付状态 //更新订单支付状态 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("订单状态更新失败", 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"; //优化成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)); }}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内随机获得的整数 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;}
页面加载成功后初始化websocket,监听后端新红包生成成功,动态添加红包到聊天窗口。
$(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("链接服务器成功!") }; 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("与服务器断开了链接!") }});
抢红包设计
抢红包设计高并发,本地单机项目,通过原子Integer控制抢红包接口并发限制为20,
private AtomicInteger receiveCount = new AtomicInteger(0);@PostMapping("/receive")public CommonJsonResponse receiveOne(@Validated @RequestBody CommonJsonRequest<ReqReceiveRedPacketVO> vo) { Integer num = null; try { //控制并发不要超过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(); } }}
对于没有领取过该红包的用户,在红包没有过期且红包还有剩余的情况下,抢红包成功,记录成功标识入redis,设置标识过期时间为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 "红包已经过期"; } 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 "该红包已经领取过了"; } final String receiveKey = REDPACKET_RECEIVE_PREFIX + redPacketNo + ":" + data.getUserId(); //优化成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"; //优化成lua脚本 final Long execute = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), Arrays.asList(receiveKey), 1, 5); if (execute != 1L) { return "太快了"; } return "";}
拆红包设计
在用户抢红包成功标识未过期的状态下,且红包未过期红包未领完时,从数据库中领取一个红包,领取成功将领取记录写入redis以供查询过期时间为48小时。
@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("红包过期了,packet:{}", packetNo); throw new ServiceException("红包飞走了", ExceptionType.SYS_ERR); } num = redisTemplate.opsForValue().increment(redisKey, -1); if (num < 0L) { log.info("红包领完了,packet:{}", packetNo); throw new ServiceException("红包飞走了", ExceptionType.SYS_ERR); } final int i = detailMapper.receiveOne(packetId, packetNo, userId); if (i != 1) { log.info("红包真的领完了,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("已经领取过了,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("打开红包异常", e); throw new ServiceException("红包飞走了", ExceptionType.SYS_ERR); }}
其中 detailMapper.receiveOne(packetId, packetNo, userId);
sql如下,将指定红包记录下未领取的红包更新一条未当前用户已经领取,若成功更新一条则表示领取成功,否则领取失败。
update redpacket_detail dset received = 1,update_time = now(),user_id = #{userId,jdbcType=VARCHAR}where received = 0and packet_id = #{packetId,jdbcType=BIGINT}and packet_no = #{packetNo,jdbcType=VARCHAR}and user_id is nulllimit 1
获取红包领取记录设计
直接充redis中获取用户领取记录,没有则直接获取数据库并同步至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并发测试抢红包、查红包接口
设置jmeter参数1秒中并发请求50个抢11个红包,可以看到,前面的请求都是成功的,中间并发量上来后有部分达到并发上限被拦截,后面红包抢完请求全部失败。
上述就是小编为大家分享的springboot+websocket怎样实现并发抢红包功能了,如果刚好有类似的疑惑,不妨参照上述分析进行理解。如果想知道更多相关知识,欢迎关注编程网行业资讯频道。
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341