springboot+websocket实现并发抢红包功能

目录
  • 概述
  • 分析
  •  效果展示
  • 设计开发
    • 表结构设计
    • 发红包设计
    • 红包支付成功回调设计
    • 抢红包设计
    • 拆红包设计
    • 获取红包领取记录设计
  • jmeter并发测试抢红包、查红包接口

概述

抢红包功能作为几大高并发场景中典型,应该如何实现?

源码地址:https://gitee.com/tech-famer/farmer-redpacket

分析

参考微信抢红包功能,将抢红包分成一下几个步骤:

  • 发红包;主要填写红包信息,生成红包记录
  • 红包支付回调;用户发红包支付成功后,收到微信支付付款成功的回调,生成指定数量的红包。
  • 抢红包;用户并发抢红包。
  • 拆红包;记录用户抢红包记录,转账抢到的红包金额。

 效果展示

项目使用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));
    }
}

/**
 * 红包随机算法
 *
 * @param amount 红包金额
 * @param num    红包数量
 * @return 随机红包集合
 */
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 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

获取红包领取记录设计

直接充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实现并发抢红包功能的文章就介绍到这了,更多相关springboot+websocket并发抢红包内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • spring boot 集成 websocket

    集成 websocket 的四种方案# 1. 原生注解# pom.xml# <dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-websocket</artifactId> </dependency> WebSocketConfig# /*  * *  *  * blog.coder4j.cn  * 

  • springboot+websocket实现并发抢红包功能

    目录 概述 分析  效果展示 设计开发 表结构设计 发红包设计 红包支付成功回调设计 抢红包设计 拆红包设计 获取红包领取记录设计 jmeter并发测试抢红包.查红包接口 概述 抢红包功能作为几大高并发场景中典型,应该如何实现? 源码地址:https://gitee.com/tech-famer/farmer-redpacket 分析 参考微信抢红包功能,将抢红包分成一下几个步骤: 发红包:主要填写红包信息,生成红包记录 红包支付回调:用户发红包支付成功后,收到微信支付付款成功的回调,生成指定数

  • SpringBoot+Websocket实现一个简单的网页聊天功能代码

    最近做了一个SpringBoot的项目,被SpringBoot那简介的配置所迷住.刚好项目中,用到了websocket.于是,我就想着,做一个SpringBoot+websocket简单的网页聊天Demo. 效果展示: 当然,项目很简单,没什么代码,一眼就能明白 导入websocket的包. 通过使用SpringBoot导入包的时候,我们可以发现,很多包都是以 spring-boot-starter 开头的,对于我这种强迫症 ,简直是福音 <dependency> <groupId>

  • vue+flv.js+SpringBoot+websocket实现视频监控与回放功能

    目录 需求: 思路: 准备工作: 实现: 最后: 需求: vue+springboot的项目,需要在页面展示出海康的硬盘录像机连接的摄像头的实时监控画面以及回放功能. 之前项目里是纯前端实现视频监控和回放功能.但是有局限性.就是ip地址必须固定.新的需求里设备ip不固定.所以必须换一种思路. 通过设备的主动注册,让设备去主动连接服务器后端通过socket推流给前端实现实时监控和回放功能: 思路: 1:初始化设备.后端项目启动时就调用初始化方法.2:开启socket连接.前端页面加载时尝试连接so

  • SpringBoot+WebSocket实现消息推送功能

    目录 背景 WebSocket简介 协议原理 WebSocket与HTTP协议的区别 WebSocket特点 应用场景 系统集成Websocket jar包引入 Websocket配置 具体实现 测试示例 页面请求websocket 测试效果 背景 项目中经常会用到消息推送功能,关于推送技术的实现,我们通常会联想到轮询.comet长连接技术,虽然这些技术能够实现,但是需要反复连接,对于服务资源消耗过大,随着技术的发展,HtML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够

  • Springboot + Mysql8实现读写分离功能

    在实际的生产环境中,为了确保数据库的稳定性,我们一般会给数据库配置双机热备机制,这样在master数据库崩溃后,slave数据库可以立即切换成主数据库,通过主从复制的方式将数据从主库同步至从库,在业务代码中编写代码实现读写分离(让主数据库处理 事务性增.改.删操作,而从数据库处理查询操作)来提升数据库的并发负载能力. 下面我们使用最新版本的Mysql数据库(8.0.16)结合SpringBoot实现这一完整步骤(一主一从). 安装配置mysql 从 https://dev.mysql.com/d

  • Java实现抢红包功能

    本文实例为大家分享了Java实现抢红包功能的具体代码,供大家参考,具体内容如下 关键思想: 1.抢红包涉及多人并发操作,需要做好同步保证多线程运行结果正确. 2.由于同时在线人数大,从性能方面考虑,玩家的发红包请求不必及时响应,而由服务端定时执行发红包队列. 下面是主要的代码和实现逻辑说明 1.创建一个类,表示红包这个实体概念.直接采用原子变量保证增减同步.Java的原子变量是一种精度更细的同步机制,在高度竞争的情况下,锁的性能将超过原子变量的性能,但在更真实的竞争情况,原子变量享有更好的性能.

  • SpringBoot+WebSocket+Netty实现消息推送的示例代码

    上一篇文章讲了Netty的理论基础,这一篇讲一下Netty在项目中的应用场景之一:消息推送功能,可以满足给所有用户推送,也可以满足给指定某一个用户推送消息,创建的是SpringBoot项目,后台服务端使用Netty技术,前端页面使用WebSocket技术. 大概实现思路: 前端使用webSocket与服务端创建连接的时候,将用户ID传给服务端 服务端将用户ID与channel关联起来存储,同时将channel放入到channel组中 如果需要给所有用户发送消息,直接执行channel组的writ

  • 解决SpringBoot webSocket 资源无法加载、tomcat启动报错的问题

    问题描述: 1. 项目集成WebSocket,且打包发布tomcat时出现websocket is already in CLOSING or CLOSE state这样的问题,建议参考"解决方法二",但是"解决方法一"请要了解查看 ,因为解决方法二是在一的基础上进行更正 2. 如果出现javax.websocket.server.ServerContainer not available这样的错误,请参考"解决方法一"中步骤3 解决方法一:(常

  • springboot+websocket+redis搭建的实现

    在多负载环境下使用websocket. 一.原因 在某些业务场景,我们需要页面对于后台的操作进行实时的刷新,这时候就需要使用websocket. 通常在后台单机的情况下没有任何的问题,如果后台经过nginx等进行负载的话,则会导致前台不能准备的接收到后台给与的响应.socket属于长连接,其session只会保存在一台服务器上,其他负载及其不会持有这个session,此时,我们需要使用redis的发布订阅来实现,session的共享. 二.环境准备 在https://mvnrepository.

  • Java Springboot websocket使用案例详解

    什么是WebSocket WebSocket是一种在单个TCP连接上进行全双工通信的协议 - 为什么要实现握手监控管理 如果说,连接随意创建,不管的话,会存在错误,broken pipe 表面看单纯报错,并没什么功能缺陷等,但实际,请求数增加,容易导致系统奔溃.这边画重点. 出现原因有很多种,目前我这边出现的原因,是因为客户端已关闭连接,服务端还持续推送导致. 如何使用 下面将使用springboot集成的webSocket 导入Maven 首先SpringBoot版本 <parent> &l

随机推荐