简易秒杀功能实现以及相关问题

1,043 阅读3分钟

数据库设计

商品表

create table goods
(
    id          bigint auto_increment comment '商品主键id'
        primary key,
    good_name   varchar(1024) default '0'               null comment '商品名称',
    good_intro  varchar(1024)                           not null comment '商品介绍',
    good_type   bigint                                  not null comment '商品类型',
    good_status bigint                                  not null comment '商品状态',
    stock       bigint                                  not null comment '商品库存',
    unit_price  bigint                                  not null comment '单价',
    goods_pic   varchar(1024)                           not null comment '图片url',
    owner       bigint                                  not null comment '发布的单位 商家名称?',
    collects    bigint        default 0                 null comment '收藏数',
    comment     bigint        default 0                 null comment '评论数',
    sell_time   timestamp     default CURRENT_TIMESTAMP not null comment '上架时间',
    create_time timestamp     default CURRENT_TIMESTAMP not null comment '创建时间',
    update_time timestamp     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '修改时间'
)
    comment '商品表' charset = utf8mb4;

秒杀商品表

create table seckill_good
(
    id            bigint auto_increment
        primary key,
    goods_id      bigint   null comment '商品id',
    seckill_price bigint   not null comment '秒杀价格',
    seckill_count bigint   not null comment '秒杀数量',
    begin_time    datetime null comment '开始时间',
    end_time      datetime null comment '结束时间'
)
    comment '秒杀商品表';

订单表

create table order_info
(
    id            bigint auto_increment
        primary key,
    user_id       bigint                              null comment '用户id',
    goods_id      bigint                              null comment '商品id',
    addr_id       bigint                              null comment '收货地址id',
    goods_name    varchar(1024)                       null comment '冗余过来的商品名称',
    goods_count   int                                 null comment '商品数量',
    goods_price   decimal(10, 2)                      null comment '商品价格',
    order_channel int       default 0                 null comment '支付通道:1 PC、2 Android、3 ios',
    goods_status  int                                 null comment '订单状态:0 未支付,1已支付,2 已发货,3 已收货,4 已退款,‘5 已完成',
    create_time   timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
    pay_date      datetime                            null comment '支付时间'
)
    comment '订单表';

秒杀订单表

create table seckill_order
(
    id       bigint auto_increment
        primary key,
    user_id  bigint null comment '用户id',
    goods_id bigint null comment '商品id',
    order_id bigint null comment '订单id'
)
    comment '秒杀订单表';

技术栈

Springboot + Mysql + Redis + RabbitMQ

秒杀流程

存在问题

  1. 超卖问题💚

解决方案:

  • 使用乐观锁
int update = seckillGoodMapper.descSeckillCount(id, seckillCount);
<update id="descSeckillCount">
        update seckill_good
        set seckill_count = seckill_count - 1
        where id = #{id} and seckill_count = #{seckillCount};
    </update>
  • 使用redis预先把库存存入,每次请求都扣减一个即可,该redis操作是原子性的,不用担心线程安全问题
// todo 为了减少 redis的次数,可以使用内存 map来缓冲,减少redis的压力
if (!hashMap.get(seckillGood.getGoodsId())) {
    throw new BusinessException(ErrorCode.SECKILL_OVERSOLD);
}

// todo 解决超卖问题:在redis存储指定商品的缓存,每次有线程来都要递减,如果递减为0,则结束秒杀即可
String key = RedisConstant.SECKILL_KEY + seckillGood.getGoodsId();
Long decrement = redisTemplate.opsForValue().decrement(key);
if (decrement == null || decrement < 0L) {
    // 表示现在不能被秒杀了
    hashMap.put(seckillGood.getGoodsId(), false);
    throw new BusinessException(ErrorCode.SECKILL_BE_OVERDUE);
    // 可以使用rabbitmq通知 用户秒杀失败
}
  1. 实现一人一单💚

在加入订单时查询是否存在该订单,如果是则直接返回错误,如果不是,则加入订单表即可

但是对于同一个用户要实现并发的处理,要加synchronize锁,而不能使用乐观锁,因为查询订单的时候不涉及查询的操作

synchronized (userId.toString().intern()) {  // 加锁实现同一个用户  为什么不用乐观锁?
            // 3、实现一人一单
            LambdaQueryWrapper<SeckillOrder> seckillOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
            seckillOrderLambdaQueryWrapper.eq(SeckillOrder::getUserId, userId);
            seckillOrderLambdaQueryWrapper.eq(SeckillOrder::getGoodsId, seckillGood.getGoodsId());
            Integer count = seckillOrderMapper.selectCount(seckillOrderLambdaQueryWrapper);
            if (count > 0) {
                throw new BusinessException(ErrorCode.SECKILL_ON_ORDER, "您已经下单了");
            }

            // todo 为了减少 redis的次数,可以使用内存 map来缓冲,减少redis的压力
            if (!hashMap.get(seckillGood.getGoodsId())) {
                throw new BusinessException(ErrorCode.SECKILL_OVERSOLD);
            }

            // todo 解决超卖问题:在redis存储指定商品的缓存,每次有线程来都要递减,如果递减为0,则结束秒杀即可
            String key = RedisConstant.SECKILL_KEY + seckillGood.getGoodsId();
            Long decrement = redisTemplate.opsForValue().decrement(key);
            if (decrement == null || decrement < 0L) {
                // 表示现在不能被秒杀了
                hashMap.put(seckillGood.getGoodsId(), false);
                throw new BusinessException(ErrorCode.SECKILL_BE_OVERDUE);
                // 可以使用rabbitmq通知 用户秒杀失败
            }

            // 4、成功就减库存数据库的update 自身带缓存
            int update = seckillGoodMapper.descSeckillCount(id, seckillCount);
            if (update < 0) {
                throw new BusinessException(ErrorCode.UPDATE_ERROR);
            }

            // 获取商品的具体信息
            Long goodsId = seckillGood.getGoodsId();
            Goods goods = goodsMapper.selectById(goodsId);

            // 异步下单 为什么要异步下单?
            SeckillMessage seckillMessage = new SeckillMessage();
            seckillMessage.setGoods(goods);
            seckillMessage.setUserId(userId);
            seckillMessage.setSeckillId(Long.getLong(id));
            seckillMessage.setSeckillCounts(seckillCount);
            String seckillMessageStr = GsonUtils.gson.toJson(seckillMessage);
            seckillSender.sendSeckillMessage(seckillMessageStr);

            // 返回等待的消息
            return ResultUtils.success(SuccessCode.SECKILL_LINEUP);
        }

😐因为是异步下单,这里处理库存的时候可能会被多扣减了库存,但最终只生成了一个订单,暂时不知道怎么解决

👌解决方法:在异步下单的时候才更新库存,不要在将信息发送给mq之前就更新缓冲,会出现问题

  1. 实现秒杀接口的隐藏(接口防刷)💚
  • 按下按钮的时候先请求获取秒杀路径的接口
/**
*	 	获取秒杀的接口
* 		@return
*/
    @GetMapping("/path")
    public BaseResponse getSeckillPath(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        // 判断其没有登录
        if (StringUtils.isBlank(token)) {
            return ResultUtils.error(ErrorCode.NOT_LOGIN);
        }
        return seckillGoodService.getPath(token);
    }

 /**
     * 根据token获取秒杀的路径
     *
     * @param token
     * @return
     */
    @Override
    public BaseResponse getPath(String token) {
        Long useId = UserHolder.getId();
        // 对token进行加密
        String path = token + useId;

        // 存入redis
        String key = SECKILL_PATH_KEY + useId + ":" + path;

        // 存在时常为1分钟
        redisTemplate.opsForValue().set(key, path, SECKILL_PATH_KEY_TTL, TimeUnit.MINUTES);
        return ResultUtils.success(path);
    }
  • 然后根据获取的路径的接口获取请求秒杀的接口,开始请求秒杀
    /**
     * 检查秒杀的路径
     *
     * @param path
     * @return
     */
    @Override
    public boolean checkSeckillPath(String path) {
        // 获取用户id
        Long userId = UserHolder.getId();
        // 获取key
        String key = SECKILL_PATH_KEY + userId + ":" + path;
        String oldPath = (String) redisTemplate.opsForValue().get(key);

        // 判断是否过期
        if (StringUtils.isBlank(oldPath)) {
            return false;
        }
        // 判断是否合法
        if (!oldPath.equals(path)) {
            return false;
        }
        return true;
    }

将请求的路径存储在redis中即可,并且判断其是否过期或者相同即可

  1. 保证订单的幂等性,只能有一位用户只能下一次单💚

注意:幂等性返回的结果要是一样的

  • 添加唯一索引
  • 下订单前先判断是否已经存在订单了(insert 前先 select)注意:因为我这里只有一台mq所以下单的时候不会有并发问题,但是有了多台mq之后,就会存在并发的问题,此时要配合其他方法进行解决,我这里在update的时候使用了乐观锁的模式进行解决。 也就是在使用这种方法的时候,要配合上其他的手段进行解决,大部分情况下不是单独使用的
// 1、先查是否已经存在了
        LambdaQueryWrapper<OrderInfo> orderInfoLambdaQueryWrapper = new LambdaQueryWrapper<>();
        orderInfoLambdaQueryWrapper.eq(OrderInfo::getGoodsId, goods.getId());
        orderInfoLambdaQueryWrapper.eq(OrderInfo::getUserId, userId);
        Integer count = orderInfoService.count(orderInfoLambdaQueryWrapper);
        if (count > 0) {
            return;
        }
  • 加锁
    • 乐观锁:CAS法即可,或者添加版本号
    • 悲观锁:使用select ... for update 锁住这一行,然后判断业务的要求,最后再进行更改即可
  1. 异步下单的好处💚

如果不使用MQ异步下单,那么就会有很多线程去请求数据库,数据库的链接就会开很多链接,就会对数据库产生很大的压力。

但是我们如果使用MQ异步下单,将请求一个一个地写入数据库中,比起多线程同步修改数据库的操作,大大缓解了数据库的链接压力

  • 同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减。
  • 异步方式:一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)。