springboot实现电商并发秒杀系统,拿走不谢!

「这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战

概述

随着互联网电商的兴起,各种活动层出不穷。秒杀活动作为一种经典活动具有瞬时并发大的特点,同时秒杀设计也是面试常考题之一,本文以单机为示例设计开发秒杀系统。

源码地址: gitee.com/tech-famer/…

效果展示

20211130_144636.gif

系统分析

  1. 秒杀页面静态化
  2. 倒计时时间服务器中获取
  3. 秒杀活动开始前隐藏秒杀链接
  4. 秒杀限流
  5. 秒杀商品redis缓存
  6. 防止超卖

系统设计

表结构设计


create datebase seckill;

use seckill;

CREATE TABLE seckill.`goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `goods_name` varchar(32) NOT NULL COMMENT '商品名称',
  `goods_price` decimal(10,2) NOT NULL COMMENT '商品价格',
  `goods_count` int(11) NOT NULL COMMENT '剩余数量',
  `total_count` int(11) NOT NULL COMMENT '总数量',
  `start_time` datetime NOT NULL COMMENT '开始时间',
  `end_time` datetime NOT NULL COMMENT '结束时间',
  `create_user` varchar(32) NOT NULL COMMENT '创建用户',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_user` varchar(32) NOT NULL COMMENT '更新用户',
  `update_time` datetime NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COMMENT='秒杀商品表';


CREATE TABLE eckill.`secorder` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `order_no` varchar(32) NOT NULL COMMENT '订单号',
  `goods_id` bigint(20) NOT NULL COMMENT '商品ID',
  `goods_name` varchar(32) NOT NULL COMMENT '商品名称',
  `goods_num` int(11) NOT NULL COMMENT '商品数量',
  `amount` decimal(10,2) NOT NULL COMMENT '订单总价',
  `pay_seq` varchar(32) NOT NULL COMMENT '支付流水号',
  `order_status` varchar(2) NOT NULL COMMENT '订单状态',
  `goods_snapshots` text NOT NULL COMMENT '商品快照',
  `user_id` varchar(32) NOT NULL COMMENT '购买用户',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_user` varchar(32) NOT NULL COMMENT '更新用户',
  `update_time` datetime NOT NULL COMMENT '更新时间',
  `pay_time` datetime DEFAULT NULL COMMENT '支付时间',
  `expire_time` datetime NOT NULL COMMENT '过期时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COMMENT='秒杀订单表'
复制代码

页面静态化

大型电商平台的页面一般在做活动的时候会租用CDN服务,将静态资源放到CDN服务来避免每次用户刷新页面请求服务器带来压力。静态页面会利用浏览器缓存静态资源,在用户持续刷新页面时,浏览器直接读取缓存而非直接请求链接获取。

单机具体做法如下:

1. 项目配置文件中添加静态资源相关配置
spring:
  resources:
    static-locations: classpath:/static/
  mvc:
    static-path-pattern: /static/**
复制代码
2. 将静态资源放入静态资源目录下

微信截图_20211130154257.png

倒计时设计

活动页面倒计时一般是活动开始时间-当前时间计算得出,前端页面通过setTimeout函数动态定时更新倒计时。此方法中当前时间取得是客户端时间,聪明的用户通过修改系统时间绕过倒计时直接参加活动,为避免发生这种情况当前时间也从服务器获取即可。具体做法如下: 在服务端直接计算好活动开始时间-当前时间值,前端只需要将服务器计算好的差值通过setTimeout函数倒数至0即可开始活动。

@PostMapping("/goodsDetail")
    public CommonJsonResponse<Object> goodsDetail(@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request){
        final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + request.getData().getId();
        if(!redisTemplate.hasKey(redisKey)){
            return new CommonJsonResponse("9999","秒杀活动已经结束");
        }
        final Map<Object, Object> map = redisTemplate.opsForHash().entries(redisKey);
        final Long startTime = (Long) map.get("startTime");
        map.put("startTimeLong",startTime - System.currentTimeMillis());
        return CommonJsonResponse.ok(map);
    }
复制代码

活动开始前隐藏秒杀链接

为避免黄牛党通过机器刷秒杀接口,在活动开始前不暴露秒杀接口,所有参与秒杀的用户只能在活动开始后通过页面秒杀按钮抢购。为此做两点设计。

1. 增加获取秒杀链接的接口

所有用户只能通过此接口获取秒杀链接,此接口只在活动开始才返回秒杀链接,秒杀链接包含加密信息

@PostMapping("/goodsOrderUrl")
public CommonJsonResponse goodsOrderUrl(@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request){
    final Long id = request.getData().getId();
    final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + id;
    if(!redisTemplate.hasKey(redisKey)){
        return new CommonJsonResponse("9999","秒杀活动已经结束");
    }
    final Long startTime = (Long) redisTemplate.opsForHash().get(redisKey, "startTime");
    if(startTime > System.currentTimeMillis()){
        return new CommonJsonResponse("9999","秒杀活动未开始");
    }
    final String md5 = DigestUtils.md5DigestAsHex((id + TotalConstants.SECKILL_MD5_SALT).getBytes());
    return CommonJsonResponse.ok("/seckill/" + md5 + "/order");
}
复制代码
2. 对前端js进行加密混淆

秒杀限流设计

1. 前端秒杀按钮在秒杀开始前置灰,且不可连续点击

其中变量canBuy用来标识按钮是否可点击,默认为false,活动倒计时结束置为true,用户点击后置为false

//用户点击秒杀按钮,获取秒杀链接
var canBuy = false;
function doBuy(){
    if(!canBuy){
        return
    }
    canBuy = false;
    $('button').css({background:'grey'})
    $.ajax({
        url:'/seckill/goodsOrderUrl',
        data:JSON.stringify({reqTime:111,sign:'111',data:{id:queryParam.id}}),
        type:'post',
        contentType: 'application/json',
        dataType:'json',
    }).success(function(data){
        if(data.respCode === "0000"){
            var url = data.data
            doOrder(queryParam.id,url)
        }else{
            showMsg(data.respMsg)
        }
    }).error(function(){

    });
}

//倒计时函数
var addTime = 0;
function updateTime(time){
    setTimeout(function(){
        addTime += 1000;
        if(time - addTime <= 0){
            $('button').css({background:'red'})
            $('span').remove()
            canBuy = true
        }else{
            $('#time').text(calcuTimeStr(time - addTime))
            updateTime(time)
        }
    },1000)
}
复制代码
2. 服务器端限流

通常服务器端的限流月前置越好,若在系统层限流测服务器系统可用连接在可承受范围就限流了;若限流在应用层,只会对应用服务高并发坐限流,系统还是会有大量的连接,当大量连接占满系统后,则系统不可用,此时服务也不可用。所以在nginx层等更高层都要做好限流配置。

本机演示只在应用服务成做限流,基于guava的RateLimiter利用spring的aop原理做封装。

1. 定义自定义注解 @FarmerLimiter

其中value默认值20,表示并限流数。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value= METHOD)
public @interface FarmerLimiter {
    int value() default 20;
}
复制代码
2. 编写aop处理逻辑

达到限流提示“活动太火爆,稍后再试”

@Component
@Aspect
public class LimiterAop {


    private static final ConcurrentHashMap<String, RateLimiter> map = new ConcurrentHashMap<>();


    @Pointcut("@annotation(FarmerLimiter)")
    public void cut(){}

    @Around("cut()")
    public Object deal(ProceedingJoinPoint point) throws Throwable {
        final MethodSignature signature = (MethodSignature) point.getSignature();
        final FarmerLimiter annotation = signature.getMethod().getAnnotation(FarmerLimiter.class);
        final int value = annotation.value();
        final String name = signature.getMethod().toString();
        RateLimiter rateLimiter;
        if(map.containsKey(name)){
            rateLimiter = map.get(name);
        }else{
            rateLimiter = RateLimiter.create(value);
            map.put(name, rateLimiter);
        }

        if(rateLimiter.tryAcquire()){
            return point.proceed();
        }else{
            return new CommonJsonResponse("0000","活动太火爆,稍后再试");
        }
    }
}
复制代码
3. 秒杀接口添加限流功能
@PostMapping("/{md5}/order")
@FarmerLimiter(50)
public CommonJsonResponse getBuyUrl(@PathVariable("md5") String md5,@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request, HttpSession session){
    final Long id = request.getData().getId();
    final String newMd5 = DigestUtils.md5DigestAsHex((id + TotalConstants.SECKILL_MD5_SALT).getBytes());
    if (!newMd5.equals(md5)) {
        return  new CommonJsonResponse("9999","请求不合法");
    }
    final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + id;
    final Long endTime = (Long) redisTemplate.opsForHash().get(redisKey, "endTime");
    if(!redisTemplate.hasKey(redisKey) || endTime < System.currentTimeMillis()){
        return new CommonJsonResponse("9999","秒杀活动已经结束");
    }
    final String limitKey = TotalConstants.SECKILL_GOODS_LIMIT_PREFIX + id + ":" + session.getId();
    if(redisTemplate.hasKey(limitKey)){
        return new CommonJsonResponse("9999","您已经达到最大购买次数");
    }
    if(redisTemplate.opsForHash().increment(redisKey, "goodsCount", -1L) < 0){
        return new CommonJsonResponse("9999","秒杀商品已经抢完");
    }
    final SecOrder order = orderService.generateSecOrder(id, session);
    redisTemplate.opsForValue().set(limitKey,1);
    redisTemplate.expire(redisKey,(endTime - System.currentTimeMillis())/1000L + (long) new Random().nextInt(3600), TimeUnit.SECONDS);
    return CommonJsonResponse.ok(order);
}
复制代码

秒杀商品redis缓存

为秒杀系统设计三种缓存:

1. 秒杀活动列表缓存,使用redis的list数据结构存储

通过定时任务,定时将有效的活动添加到redis缓存中,失效的删除。

@Scheduled(cron = "0/5 * * * * *")
public void loadIntoRedis(){
    //添加未过期的商品
    final List<Goods> goodsList = goodsService.listSeckillGoods();
    if(!goodsList.isEmpty()){
        StringBuilder sb = new StringBuilder("redis.call('lpush',KEYS[1]");
        Object[] argv = new Object[goodsList.size() + 1];
        argv[0] = goodsList.size() - 1;
        for (int i = 0; i < goodsList.size(); i++) {
            RedisGoods redisGoods = new RedisGoods();
            BeanUtils.copyProperties(goodsList.get(i), redisGoods);
            argv[i + 1] = redisGoods;
            sb.append(",").append("ARGV[").append(i + 2).append("]");
        }
        sb.append(")\r\n");
        sb.append("if redis.call('ltrim',KEYS[1],0,ARGV[1]) == 'OK' then return 1 else return 0 end");
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(sb.toString(), Long.class);
        redisTemplate.execute(redisScript, Arrays.asList(TotalConstants.SECKILL_GOODS_LIST_PREFIX), argv);
    }
}
复制代码
2. 秒杀活动详情缓存,使用redis的hash数据结构存储

在后台添加秒杀活动时,将秒杀商品信息添加redis缓存,其中hash key为字段名称,方便查询秒杀商品详情及对商品库存hincrby原子减扣

@PostMapping("add")
@ResponseBody
public CommonJsonResponse add(Goods goods, HttpSession session){
    if(goods.getGoodsName() == null){
        return new CommonJsonResponse("9999","商品名称不能为空");
    }
    if(goods.getGoodsPrice() == null || goods.getGoodsPrice().compareTo(BigDecimal.ZERO) <= 0 ){
        return new CommonJsonResponse("9999","商品价格不合法");
    }
    if(goods.getGoodsCount() == null || goods.getGoodsCount() <= 0){
        return new CommonJsonResponse("9999","商品数量不合法");
    }
    if(goods.getStartTime() == null || goods.getStartTime().before(new Date())){
        return new CommonJsonResponse("9999","开始时间不合法");
    }
    if(goods.getEndTime() == null || goods.getEndTime().before(new Date())){
        return new CommonJsonResponse("9999","结束时间不合法");
    }

    if(goods.getStartTime().after(goods.getEndTime())){
        return new CommonJsonResponse("9999","开始时时间不能再结束时间之后");
    }
    final Integer i = goodsService.addGoods(goods, session);
    if(i > 0){
        JSONObject goodsJson = (JSONObject)JSONObject.toJSON(goods);
        redisTemplate.opsForHash().putAll(TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + goods.getId(), goodsJson.getInnerMap());
        return CommonJsonResponse.ok();
    }
    return new CommonJsonResponse("9999","添加商品失败");
}
复制代码
3. 秒杀活动用户已购商品数量缓存,使用redis的string数据结构

设置过期时间到活动结束+一个小时以内随机,避免大量redis的key过期引起redis卡顿。

redisTemplate.opsForValue().set(limitKey,1);
redisTemplate.expire(redisKey,(endTime - System.currentTimeMillis())/1000L + (long) new Random().nextInt(3600), TimeUnit.SECONDS);
复制代码

防止超卖

利用mysql数据库事务隔离性,及乐观锁减扣库存来防治超卖。

<update id="decrement">
    update goods set update_time = now(),goods_count = goods_count - 1
    where id = #{id,jdbcType = BIGINT}
    and goods_count >= 1
</update>
复制代码

Jmeter并发测试

微信截图_20211130165615.png

微信截图_20211130165629.png

微信截图_20211130165711.png

微信截图_20211130165737.png

猜你喜欢

转载自juejin.im/post/7036285668619714573