第26天-秒杀服务(秒杀系统设计与实现)

1.秒杀设计


1.1.秒杀业务

秒杀具有瞬间高并发特点,针对这一特点,必须要做限流+异步+缓存(页面静态化)+独立部署

限流方式:

  • 前端限流,一些高并发的网站直接在前端页面开始限流,例如:小米的验证码设计
  • Nginx限流,直接负载部分请求到错误的静态页面:令牌算法,漏斗算法
  • 网关限流,限流过滤器
  • 代码中使用分布式信号量
  • RabbitMQ限流,chanel.basicQos(1),保证发挥所有服务器的性能

1.2.秒杀流程


在这里插入图片描述

1.3.秒杀系统设计

1.3.1.秒杀(高并发)系统关注的问题

  • 服务单一职责+独立部署
    • 秒杀服务即使自己扛不住压力,宕机,不要影响别的服务
  • 秒杀链接加密
    • 防止恶意攻击,模拟秒杀请求,1000次/s 攻击
    • 防止链接暴露,自己工作人员,提前秒杀商品
  • 库存预热+快速扣减
    • 秒杀读多写少,无需每次实时校验库存,进行库存预热,放到redis中,信号量控制进来秒杀的请求
    • Redis集群+分片高可用
  • 动静分离
    • nginx做好动静分离,保证秒杀和商品详情页的动态请求才打到后端的服务集群
    • 上线可以使用CDN网络,分担本集群压力
  • 恶意请求拦截
    • 识别非法攻击请求并进行拦截,比如登录拦截器
    • 网关层处理
  • 流程错峰
    • 使用各种手段,将流量分担到大宽度的时间点,比如验证码,加入购物车
  • 限流、熔断、降级
    • 前端限流+后端限流(网关)
    • 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
  • 队列削峰
    • 1万个商品,每个1000件秒杀(信号量)
    • 所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可


2.秒杀服务

2.1.构建秒杀服务


在这里插入图片描述

配置域名映射及网关

192.168.139.10 seckill.gmall.com
spring:
  cloud:
	gateway:
	  routes:
	    - id: gmall_seckill_route
		  uri: lb://gmall-seckill
		  predicates:
		  - Host=seckill.gmall.com

2.2.添加秒杀场次

后台管理系统:优惠营销 -> 每日秒杀

在这里插入图片描述

2.3.秒杀场次关联秒杀商品

在这里插入图片描述

2.4.秒杀商品定时上架

  • 定时任务扫描最近三天需要上架的秒杀商品
  • 库存预热,让秒杀商品缓存到Redis中

SeckillSkuScheduled

package com.atguigu.gmall.seckill.scheduled;

import com.atguigu.gmall.seckill.service.SeckillService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
 * 秒杀商品定时上架 {@link SeckillSkuScheduled}
 *  每天凌晨3点,上架最近三天需要秒杀商品
 *  当天 00:00:00 - 23:59:59
 *  明天 00:00:00 - 23:59:59
 *  后天 00:00:00 - 23:59:59
 *
 * @author zhangwen
 * @email: 1466787185@qq.com
 */
@Slf4j
@Service
public class SeckillSkuScheduled {

    private final String UPLOAD_LOCK = "seckill:upload:lock";

    @Autowired
    private SeckillService seckillService;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 秒杀商品定时上架
     *
     * 幂等处理:
     *  - 1.分布式锁
     *  - 2.缓存数据时判断是否已经存在key
     *      - key不存在就缓存
     *      - key存在不做处理
     */
    @Scheduled(cron = "0 0 3 * * ?")
    public void uploadSeckillSkuLast3Days(){
        log.info("秒杀商品定时上架...");
        // 幂等处理,分布式锁
        // 锁的业务执行完成,状态已经更新完成,释放锁以后,其他人获取到的就是最新的状态
        RLock lock = redissonClient.getLock(UPLOAD_LOCK);
        lock.lock(10, TimeUnit.SECONDS);
        try {
            seckillService.uploadSeckillSkuLast3Days();
        } finally {
            lock.unlock();
        }
    }
}

SeckillServcieImpl

	/**
     * 上架最近三天的秒杀商品
     */
    @Override
    public void uploadSeckillSkuLast3Days() {
        // 扫描最近三天需要参与的秒杀活动与商品信息
        R r = couponFeignService.getLast3DaySession();
        if (r.getCode() == 0) {
            // 需要上架的商品
            List<SeckillSessionsWithSkusVO> sessions = r.getData("data",
                    new TypeReference<List<SeckillSessionsWithSkusVO>>() {
            });
            // 缓存秒杀活动信息
            saveSessions(sessions);
            // 缓存秒杀活动关联的商品信息
            saveSessionSkus(sessions);
        } else {
            log.error("远程调用 gmall-coupon 获取秒杀活动失败");
        }
    }
    
    /**
     * 缓存秒杀活动信息
     * @param sessions
     */
    private void saveSessions(List<SeckillSessionsWithSkusVO> sessions) {
        if (sessions != null && sessions.size() > 0) {
            sessions.stream().forEach(session -> {
                Long startTime = session.getStartTime().getTime();
                long endTime = session.getEndTime().getTime();
                String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
                Boolean hasKey = redisTemplate.hasKey(key);
                if (!hasKey) {
                    // 缓存秒杀活动信息
                    List<String> skuIds = session.getRelationSkus().stream()
                            .map(item -> item.getPromotionSessionId() + "_" + item.getSkuId())
                            .collect(Collectors.toList());
                    redisTemplate.opsForList().leftPushAll(key, skuIds);
                }
            });
        }
    }

    /**
     * 缓存秒杀活动关联的商品信息
     * @param sessions
     */
    private void saveSessionSkus(List<SeckillSessionsWithSkusVO> sessions){
        if (sessions != null && sessions.size() > 0) {
            sessions.stream().forEach(session -> {
                BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(SECKILL_SKU_CACHE_PREFIX);
                session.getRelationSkus().stream().forEach(seckillSkuVO -> {
                    String hashKey = seckillSkuVO.getPromotionSessionId() + "_" + seckillSkuVO.getSkuId();
                    if (!hashOps.hasKey(hashKey)) {
                        // 缓存商品
                        SeckillSkuRedisTO redisTO = new SeckillSkuRedisTO();
                        // 1.sku基本信息
                        R r = productFeignService.getSkuInfo(seckillSkuVO.getSkuId());
                        if (r.getCode() == 0) {
                            SkuInfoVO skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {
                            });
                            redisTO.setSkuInfo(skuInfo);
                        }

                        // 2.sku秒杀信息
                        BeanUtils.copyProperties(seckillSkuVO, redisTO);

                        // 3.设置当前商品秒杀时间
                        redisTO.setStartTime(session.getStartTime().getTime());
                        redisTO.setEndTime(session.getEndTime().getTime());

                        // 4.设置随机码,防止恶意攻击
                        String token = UUID.randomUUID().toString().replace("-", "");
                        redisTO.setRandomCode(token);

                        String json = JsonUtils.objectToJson(redisTO);
                        hashOps.put(hashKey, json);
**加粗样式**
                        // 5.使用秒杀商品库存作为分布式的信号量,限流
                        RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                        semaphore.trySetPermits(seckillSkuVO.getSeckillCount().intValue());
                    }
                });
            });
        }
    }

2.5.展示秒杀商品(首页)

index.html

//获取当前秒杀场次商品
$.get('http://seckill.gmall.com/currentSeckillSkus', function(resp){
	if(resp.data.length > 0){
		resp.data.forEach(function(item){
			$('<li onclick="toItemPage('+item.skuId+')"> </li>')
				.append($('<img src="'+item.skuInfo.skuDefaultImg+'"
				style="width: 130px;height: 130px">'))
				.append($('<p>'+item.skuInfo.skuTitle+'</p>'))
				.append($('<span>'+item.seckillPrice+'</span>'))
				.append($('<s>'+item.skuInfo.price+'</s>'))
				.appendTo($('#seckillSkus'))
		})
	}
})

function toItemPage(skuId){
	location.href = `http://item.gmall.com/${skuId}.html`
}

2.6.商品秒杀预告(详情页)

SkuInfoServiceImpl

   /**
    * 商品详情
    * @param skuId
    * @return
    */
   @Override
   public SkuItemVO item(Long skuId) throws Exception {
       SkuItemVO skuItemVO = new SkuItemVO();

       // 异步编排
       CompletableFuture<SkuInfoEntity> skuInfoFuture = CompletableFuture.supplyAsync(() -> {
           // 1.sku基本信息 pms_sku_info
           SkuInfoEntity skuInfo = getById(skuId);
           skuItemVO.setSkuInfo(skuInfo);
           return skuInfo;
       }, executor);

       CompletableFuture<Void> saleAttrFuture = skuInfoFuture.thenAcceptAsync((res) -> {
           // 2.spu销售属性组合
           List<SkuSaleAttrVO> saleAttrs = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
           skuItemVO.setSaleAttrs(saleAttrs);
       }, executor);

       CompletableFuture<Void> descFuture = skuInfoFuture.thenAcceptAsync((res) -> {
           // 3.spu商品介绍
           SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
           skuItemVO.setSpuDesc(spuInfoDescEntity);
       }, executor);

       CompletableFuture<Void> baseAttrFuture = skuInfoFuture.thenAcceptAsync((res) -> {
           // 4.spu规格参数
           List<SpuAttrGroupVO> groupAttrs = attrGroupService.getAttrGroupWithAttrsBySpuId(
                   res.getCatalogId(), res.getSpuId());
           skuItemVO.setGroupAttrs(groupAttrs);
       }, executor);

       CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
           // 5.sku图片信息 pms_sku_images
           List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
           skuItemVO.setImages(images);
       }, executor);

       CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
           // 查询当前sku是否参与秒杀优惠
           R r = seckillFeignService.getSkuSeckillInfo(skuId);
           if (r.getCode() == 0) {
               SeckillSkuVO seckillSkuVO = r.getData("data", new TypeReference<SeckillSkuVO>() {
               });
               skuItemVO.setSeckillInfo(seckillSkuVO);
           } else {
               log.error("远程调用 gmall-seckill 获取商品秒杀信息失败");
           }
       }, executor);


       // 等待所有任务执行完
       CompletableFuture.allOf(saleAttrFuture, descFuture, baseAttrFuture, imageFuture, seckillFuture).get();

       // TODO 查询库存
       skuItemVO.setHasStock(true);

       return skuItemVO;
   }

item.html

<li th:if="${skuItemVO.seckillInfo!=null}" style="color: red">
	<span th:if="${#dates.createNow().getTime() <skuItemVO.seckillInfo.startTime}">
		商品将会在[[${#dates.format(new java.util.Date(skuItemVO.seckillInfo.startTime), 'yyyy-MM-dd HH:mm:ss')}]]进行秒杀
	</span>
	<span th:if="${#dates.createNow().getTime() >= skuItemVO.seckillInfo.startTime && #dates.createNow().getTime() <= skuItemVO.seckillInfo.endTime}">
	    秒杀价:[[${#numbers.formatDecimal(skuItemVO.seckillInfo.seckillPrice,1,2)}]]
	</span>
</li>
<div class="box-btns-two" th:if="${skuItemVO.seckillInfo!=null && (#dates.createNow().getTime() >= skuItemVO.seckillInfo.startTime && #dates.createNow().getTime() <= skuItemVO.seckillInfo.endTime)}">
	<a href="#" id="toSeckill" th:attr="skuId=${skuItemVO.skuInfo.skuId},sessionId=${skuItemVO.seckillInfo.promotionSessionId},code=${skuItemVO.seckillInfo.randomCode}">立即抢购</a>
</div>
<div class="box-btns-two" th:if="${skuItemVO.seckillInfo==null || (#dates.createNow().getTime() < skuItemVO.seckillInfo.startTime || #dates.createNow().getTime() > skuItemVO.seckillInfo.endTime)}">
	<a href="#" id="addToCart" th:attr="skuId=${skuItemVO.skuInfo.skuId}">加入购物车</a>
</div>

<script>
// 立即抢购
	$('#toSeckill').click(function(){
		let isLogin = [[${session.loginUser!=null}]]
		if(isLogin){
			let killId = $(this).attr('sessionId') + "_" + $(this).attr('skuId')
			let code = $(this).attr('code')
			let num = $('#num').val()
			location.href = `http://seckill.gmall.com/seckill?killId=${killId}&key=${code}&num=${num}`
		} else {
			alert('秒杀商品,请先登录!')
			location.href = 'http://auth.gmall.com/login.html'
		}

		return false
	})
</script>

2.7.秒杀核心业务实现

2.7.1.核心流程

在这里插入图片描述

2.7.2.秒杀业务

SeckillController

/**
 * 秒杀
 * @param killId sessionId_skuId
 * @param key 商品随机码
 * @param num 秒杀数量
 * @return
 */
@GetMapping("/seckill")
public String seckill(@RequestParam("killId") String killId,
                      @RequestParam("key") String key,
                      @RequestParam("num") Integer num,
                      Model model){
    String orderSn = seckillService.seckill(killId, key, num);
    model.addAttribute("orderSn", orderSn);

    return "success";
}

SeckillServcieImpl

/**
 * 秒杀
 * @param killId 秒杀场次id_商品id
 * @param key 随机码
 * @param num 商品数量
 * @return
 */
@Override
public String seckill(String killId, String key, Integer num) {
    MemberVO memberVO = LoginInterceptor.threadLocal.get();

    // 获取当前秒杀商品信息
    BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_SKU_CACHE_PREFIX);
    String json = hashOps.get(killId);
    if (StringUtils.isEmpty(json)) {
        return null;
    }

    SeckillSkuRedisTO redisTO = JsonUtils.jsonToPojo(json, SeckillSkuRedisTO.class);

    // 校验合法性
    // 1.校验时间
    long currentTime = System.currentTimeMillis();
    if (currentTime >= redisTO.getStartTime() && currentTime <= redisTO.getEndTime()) {
        // 2.校验随机码和商品id
        String randomCode = redisTO.getRandomCode();
        String skuId = redisTO.getPromotionSessionId() + "_" + redisTO.getSkuId();
        if (randomCode.equals(key) && skuId.equals(killId)) {
            // 3.校验购物数量
            if (num <= redisTO.getSeckillLimit().intValue()) {
                // 4.验证是否购买过
                // 幂等性,只要秒杀成功,就去redis占位 SETNX,userId_sessionId_skuId
                String redisKey = memberVO.getId() + "_" + skuId;
                Long ttl = redisTO.getEndTime() - redisTO.getStartTime();
                Boolean ifAbsent = redisTemplate.opsForValue()
                        .setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
                if (ifAbsent) {
                    // 占位成功,说明没有购买过
                    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
                    // 快速尝试
                    boolean acquire = semaphore.tryAcquire(num);
                    if (acquire) {
                        // 秒杀成功,快速下单,发消息到MQ
                        String orderSn = IdWorker.getTimeId();
                        SeckillOrderTO seckillOrderTO = new SeckillOrderTO();
                        seckillOrderTO.setOrderSn(orderSn);
                        seckillOrderTO.setMemberId(memberVO.getId());
                        seckillOrderTO.setNum(num);
                        seckillOrderTO.setPromotionSessionId(redisTO.getPromotionSessionId());
                        seckillOrderTO.setSkuId(redisTO.getSkuId());
                        seckillOrderTO.setSeckillPrice(redisTO.getSeckillPrice());

                        rabbitTemplate.convertAndSend(SECKILL_ORDER_EVENT_EXCHANGE,
                                SECKILL_ORDER_QUEUE_ROUTING_KEY, seckillOrderTO);

                        return  orderSn;
                    }
                }
            }
        }
    }

    return null;
}

2.7.3.秒杀响应页面

  • 秒杀完成后,跳转到success页面,显示秒杀结果
  • 秒杀成功,则自动跳转到支付页面进行支付
  • 秒杀成功,订单服务消费秒杀消息,进行订单处理

success.html

   <div class="main">
       <div class="success-wrap">
            <div class="w" id="result">
               <div class="m succeed-box">
                   <div th:if="${orderSn!=null}" class="mc success-cont">
                        <h3 style="margin: 20px 0px">恭喜,秒杀成功,订单号:[[${orderSn}]]</h3>
                        <p>
                            <a th:href="'http://order.gmall.com/payOrder?orderSn='+${orderSn}" id="pay">
                                正在准备订单数据,请您耐心等待 <span id="payTime">10</span> 秒后进行支付!
                            </a>
                        </p>
                    </div>
                    <div th:if="${orderSn==null}" class="mc success-cont">
                        <h3 style="margin: 20px 0px">手气不佳,秒杀失败,下次再来!</h3>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script type="text/javascript">
        // 倒计时跳转支付
        $(function () {
            let href = $('#pay').attr('href')
            $('#pay').removeAttr('href')
            $('#pay').attr('disabled', true)
            let orderSn = [[${orderSn}]]
            let count = 10
            let countdown = setInterval(CountDown, 1000)
            function CountDown() {
                $("#payTime").text(count)
                if (count == 0) {
                    clearInterval(countdown)
                    $('#pay').text('支付订单')
                    $('#pay').attr('href', href)
                    $('#pay').removeAttr('disabled')
                }
                count--;
            }
        });
    </script>


3.定时任务

3.1.cron表达式

语法: 秒 分 时 日 月 周 年 (年,Spring不支持)

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html

在这里插入图片描述
特殊字符:

  1. , :枚举
    corn=“7,9,23 * * * * ?” :任意时刻的 7,9,23 秒启动这个任务
  2. - :范围
    corn=“7-20 * * * * ?” :任意时刻的7-20秒之间,每秒启动一次
  3. *:任意
    指定位置的任意时刻都可以
  4. / :步长
    corn=“7/5 * * * * ?” :第7秒启动,每5秒一次
    corn=“*/5 * * * * ?” :任意秒启动,每5秒一次
  5. ? :出现在日和周几的位置,为了防止日和周冲突,在日和周上如果要写通配符使用?
    corn=" * * * 1 * ?" :每月的1号,而且必须是周二,然后启动这个任务
  6. L :出现在日和周的位置
    Last:最后一个
    corn=" * * * ? * 3L" :每月的最后一个周二
  7. W
    Work Day:工作日
    cron=“* * * W * ?” :每个月的工作日触发
    cron=“* * * LW * ?” :每个月的最后一个工作日触发
  8. #:第几个
    cron=“* * * ? * 5#2” :每个月的第2个周4

3.2.cron示例

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html

在这里插入图片描述

3.3.Spring Boot整合定时任务

@Slf4j
@Component
@EnableScheduling //开启定时任务
public class MyScheduled {
	/**
	 * 1.Spring中6位组成,不允许第7位的年
	 * 2.在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)
	 */
	@Scheduled(cron = "* * * ? * 5")
	public void hello(){
		log.info("hello ю ")
	}
}

注意:

  • Spring中6位组成,不允许第7位的年

  • 在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)

  • 解决定时任务不阻塞,默认是阻塞的

    • 可以让业务方法以异步的方式运行,自己提交到线程池
@Scheduled(cron = "* * * ? * 5")
public void hello(){
	log.info("hello ю ");
	//异步方式运行
	CompletableFuture.runAsync(() Ѷ ۏ {
		xxxServcie.method();
	});
}
    • 让定时任务异步执行
@Slf4j
@Component
@EnableAsync //开启异步任务
@EnableScheduling //开启定时任务
public class MyScheduled {
	/**
	 * 1.Spring中6位组成,不允许第7位的年
	 * 2.在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)
	 */
	@Async
	@Scheduled(cron = "* * * ? * 5")
	public void hello(){
		log.info("hello ... ");
	}
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值