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

2023-01-21 0 405

目录

概述

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

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

分析

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

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

 效果展示

项目使用sessionId模拟用户,示例打开俩个浏览器窗口模拟两个用户。

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

设计开发

结构设计

红包记录在 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;
}

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

红包支付成功回调设计

用户支付成功后,系统接收到微信回调接口

更新红包支付状态
二倍均值法生成指定数量红包,并批量入库。 红包算法参考: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实现并发抢红包功能

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

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

:本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可, 转载请附上原文出处链接。
1、本站提供的源码不保证资源的完整性以及安全性,不附带任何技术服务!
2、本站提供的模板、软件工具等其他资源,均不包含技术服务,请大家谅解!
3、本站提供的资源仅供下载者参考学习,请勿用于任何商业用途,请24小时内删除!
4、如需商用,请购买正版,由于未及时购买正版发生的侵权行为,与本站无关。
5、本站部分资源存放于百度网盘或其他网盘中,请提前注册好百度网盘账号,下载安装百度网盘客户端或其他网盘客户端进行下载;
6、本站部分资源文件是经压缩后的,请下载后安装解压软件,推荐使用WinRAR和7-Zip解压软件。
7、如果本站提供的资源侵犯到了您的权益,请邮件联系: 442469558@qq.com 进行处理!

猪小侠源码-最新源码下载平台 Java教程 springboot+websocket实现并发抢红包功能 http://www.20zxx.cn/463128/xuexijiaocheng/javajc.html

猪小侠源码,优质资源分享网

常见问题
  • 本站所有资源版权均属于原作者所有,均只能用于参考学习,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担
查看详情
  • 最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,建议提前注册好百度网盘账号,使用百度网盘客户端下载
查看详情

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务