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

    概述

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

    源码地址: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实现并发抢红包功能

    声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。