【商城实战】12-进阶篇-秒杀服务
【商城实战】12-进阶篇-秒杀服务
Last edited 2022-9-27
date
Jul 8, 2022
type
Post
status
Published
slug
msb-mac-mall-seckill
summary
概要:1 商城部分商品业务介绍;2 秒杀活动解决方案;
tags
微服务
spring-cloud-alibaba
商城
category
商城实战
password
Property
Sep 27, 2022 02:57 AM
icon

秒杀服务

一、商品上架

秒杀活动的结构图
notion image
通过定时任务触发:
/** * 定时上架秒杀商品信息 */ @Slf4j @Component public class SeckillSkuSchedule {    @Autowired    SeckillService seckillService;    @Autowired    RedissonClient redissonClient;    /**     *     */    @Async    @Scheduled(cron = "*/5 * * * * *")    public void uploadSeckillSku3Days(){        log.info("定时上架秒杀商品执行了...." + new Date());        // 分布式锁        RLock lock = redissonClient.getLock("seckill:upload:lock");        lock.lock(10, TimeUnit.SECONDS);        try {            // 调用上架商品的方法            seckillService.uploadSeckillSku3Days();       }catch (Exception e){            lock.unlock();       }   } }
进入到Service中处理
@Override    public void uploadSeckillSku3Days() {        // 1. 通过OpenFegin 远程调用Coupon服务中接口来获取未来三天的秒杀活动的商品        R r = couponFeignService.getLates3DaysSession();        if(r.getCode() == 0){            // 表示查询操作成功            String json = (String) r.get("data");            List<SeckillSessionEntity> seckillSessionEntities = JSON.parseArray(json,SeckillSessionEntity.class);            // 2. 上架商品 Redis数据保存            // 缓存商品            // 2.1 缓存每日秒杀的SKU基本信息            saveSessionInfos(seckillSessionEntities);            // 2.2 缓存每日秒杀的商品信息            saveSessionSkuInfos(seckillSessionEntities);       }   } /**     * 保存每日活动的信息到Redis中     * @param seckillSessionEntities     */    private void saveSessionInfos(List<SeckillSessionEntity> seckillSessionEntities) {        for (SeckillSessionEntity seckillSessionEntity : seckillSessionEntities) {            // 循环缓存每一个活动 key: start_endTime            long start = seckillSessionEntity.getStartTime().getTime();            long end = seckillSessionEntity.getEndTime().getTime();            // 生成Key            String key = SeckillConstant.SESSION_CHACE_PREFIX+start+"_"+end;            Boolean flag = redisTemplate.hasKey(key);            if(!flag){// 表示这个秒杀活动在Redis中不存在,也就是还没有上架,那么需要保存                // 需要存储到Redis中的这个秒杀活动涉及到的相关的商品信息的SKUID                List<String> collect = seckillSessionEntity.getRelationEntities().stream().map(item -> {                    // 秒杀活动存储的 VALUE是 sessionId_SkuId                    return item.getPromotionSessionId()+"_"+item.getSkuId().toString();               }).collect(Collectors.toList());                redisTemplate.opsForList().leftPushAll(key,collect);           }       }   }    /**     * 存储活动对应的 SKU信息     * @param seckillSessionEntities     */    private void saveSessionSkuInfos(List<SeckillSessionEntity> seckillSessionEntities) {        seckillSessionEntities.stream().forEach(session -> {            // 循环取出每个Session,然后取出对应SkuID 封装相关的信息            BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(SeckillConstant.SKU_CHACE_PREFIX);            session.getRelationEntities().stream().forEach(item->{                String skuKey = item.getPromotionSessionId()+"_"+item.getSkuId();                Boolean flag = redisTemplate.hasKey(skuKey);                if(!flag){                    SeckillSkuRedisDto dto = new SeckillSkuRedisDto();                    // 1.获取SKU的基本信息                    R info = productFeignService.info(item.getSkuId());                    if(info.getCode() == 0){                        // 表示查询成功                        String json = (String) info.get("skuInfoJSON");                        dto.setSkuInfoVo(JSON.parseObject(json,SkuInfoVo.class));                   }                    // 2.获取SKU的秒杀信息                    /*dto.setSkuId(item.getSkuId());                    dto.setSeckillPrice(item.getSeckillPrice());                    dto.setSeckillCount(item.getSeckillCount());                    dto.setSeckillLimit(item.getSeckillLimit());                    dto.setSeckillSort(item.getSeckillSort());*/                    BeanUtils.copyProperties(item,dto);                    // 3.设置当前商品的秒杀时间                    dto.setStartTime(session.getStartTime().getTime());                    dto.setEndTime(session.getEndTime().getTime());                    // 4. 随机码                    String token = UUID.randomUUID().toString().replace("-","");                    dto.setRandCode(token);                    // 分布式信号量的处理 限流的目的                    RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);                    // 把秒杀活动的商品数量作为分布式信号量的信号量                    semaphore.trySetPermits(item.getSeckillCount().intValue());                    hashOps.put(skuKey,JSON.toJSONString(dto));               }           });       });   }
启动服务,数据会被保存到Redis中
notion image
notion image

二、秒杀商品查询

通过当前时间获取对应的秒杀活动及对应的SKU信息。
  /**     * 查询出当前时间内的秒杀活动及对应的商品SKU信息     * @return     */    @Override    public List<SeckillSkuRedisDto> getCurrentSeckillSkus() {        // 1.确定当前时间是属于哪个秒杀活动的        long time = new Date().getTime();        // 从Redis中查询所有的秒杀活动        Set<String> keys = redisTemplate.keys(SeckillConstant.SESSION_CHACE_PREFIX + "*");        for (String key : keys) {            //seckill:sessions1656468000000_1656469800000            String replace = key.replace(SeckillConstant.SESSION_CHACE_PREFIX, "");            // 1656468000000_1656469800000            String[] s = replace.split("_");            Long start = Long.parseLong(s[0]); // 活动开始的时间            Long end = Long.parseLong(s[1]); // 活动结束的时间            if(time > start && time < end){                // 说明的秒杀活动就是当前时间需要参与的活动                // 取出来的是SKU的ID 2_9                List<String> range = redisTemplate.opsForList().range(key, -100, 100);                BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SeckillConstant.SKU_CHACE_PREFIX);                List<String> list = ops.multiGet(range);                if(list != null && list.size() > 0){                    List<SeckillSkuRedisDto> collect = list.stream().map(item -> {                        SeckillSkuRedisDto seckillSkuRedisDto = JSON.parseObject(item, SeckillSkuRedisDto.class);                        return seckillSkuRedisDto;                   }).collect(Collectors.toList());                    return collect;               }           }       }        return null;   }
然后定义相关的Controller接口就可以访问了
@RestController @RequestMapping("/seckill") public class SeckillController {    @Autowired    SeckillService seckillService;    @GetMapping("/currentSeckillSessionSkus")    public R getCurrentSeckillSessionSkus(){        List<SeckillSkuRedisDto> currentSeckillSkus = seckillService.getCurrentSeckillSkus();        return R.ok().put("data", JSON.toJSONString(currentSeckillSkus));   } }
然后对应的访问效果:
notion image

三、页面渲染

1. 网关配置

首先在host中配置域名
notion image
然后在网关中配置路由信息
notion image
然后重启服务访问:
notion image
能访问到数据就表示域名配置成功

2.首页配置

通过Ajax来访问获取秒杀的相关信息
 $.get("http://seckill.msb.com/seckill/currentSeckillSessionSkus",function(resp){     if(resp.data.length > 0){        // 说明有秒杀的数据        console.log($.parseJSON(resp.data))       $.parseJSON(resp.data).forEach(function(item){         $("<li></li>").append("<img width='130px' height='130px' src='"+item.skuInfoVo.skuDefaultImg+"'/>")                 .append("<p>"+item.skuInfoVo.skuSubtitle+"</p>")                 .append("<span>"+item.seckillPrice+"</span>")                 .append("<s>"+item.skuInfoVo.price+"</s>")                 .appendTo("#seckillSessionContent");       })       /*<li>       <img src="/static/index/img/section_second_list_img1.jpg" alt="">               <p>花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千</p>       <span>¥83.9</span><s>¥99.9</s>     </li>*/     } })
展示的效果
notion image

3.商品详情

在购买商品的时候,进入到商品详情页,如果该商品也参与了秒杀活动,那么对应的需要展示相关的信息
notion image
首先我们需要在秒杀服务中提供一个根据SKUID查询相关的秒杀活动的接口
  /**     * 根据SKUID查询秒杀活动对应的信息     * @param skuId     * @return     */   @Override   public SeckillSkuRedisDto getSeckillSessionBySkuId(Long skuId) {       // 1.找到所有需要参与秒杀的商品的sku信息       BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SeckillConstant.SKU_CHACE_PREFIX);       Set<String> keys = ops.keys();       if(keys != null && keys.size() > 0){           String regx = "\\d_"+ skuId;           for (String key : keys) {               boolean matches = Pattern.matches(regx, key);               if(matches){                   // 说明找到了对应的SKU的信息                   String json = ops.get(key);                   SeckillSkuRedisDto dto = JSON.parseObject(json, SeckillSkuRedisDto.class);                   return dto;               }           }       }       return null;   }
然后在查询商品详情的时候异步查询出对应的秒杀活动信息
notion image
然后在模板页面中展示相关的信息
<div class="box-summary clear">                    <ul>                        <li>京东价</li>                        <li>                            <span>¥</span>                            <span th:text="${#numbers.formatDecimal(item.info.price,3,2)}">4499.00</span>                        </li>                        <li style="color: red">                            <span th:if="${#dates.createNow().getTime() < item.seckillVO.startTime}">                               商品将在:[[${#dates.format(new java.util.Date(item.seckillVO.startTime),'yyyy-MM-dd HH:mm:ss')}]] 开始秒杀                            </span>                            <span th:if="${#dates.createNow().getTime() > item.seckillVO.startTime                             && #dates.createNow().getTime() < item.seckillVO.endTime }">                               秒杀价: [[${#numbers.formatDecimal(item.seckillVO.seckillPrice,1,2)}]]                            </span>                        </li>                        <li>                            <a href="/static/item/">                               预约说明                            </a>                        </li>                    </ul>                </div>
首页调整到商品详情页
function goItem(skuId){ location.href="http://item.msb.com/"+skuId+".html" } $.get("http://seckill.msb.com/seckill/currentSeckillSessionSkus",function(resp){ if(resp.data.length > 0){ // 说明有秒杀的数据 console.log($.parseJSON(resp.data)) $.parseJSON(resp.data).forEach(function(item){ $("<li onclick='goItem("+item.skuId+")'></li>") .append("<img width='130px' height='130px' src='"+item.skuInfoVo.skuDefaultImg+"'/>") .append("<p>"+item.skuInfoVo.skuSubtitle+"</p>") .append("<span>"+item.seckillPrice+"</span>") .append("<s>"+item.skuInfoVo.price+"</s>") .appendTo("#seckillSessionContent"); }) /*<li> <img src="/static/index/img/section_second_list_img1.jpg" alt=""> <p>花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千</p> <span>¥83.9</span><s>¥99.9</s> </li>*/ } })

四、秒杀活动

1.秒杀活动关注点

秒杀活动的最大特点就是高并发而且是短时间内的高并发,那么对我们的服务要求就非常高,针对这种情况所产生的共性问题,对应的解决方案:
notion image

2. 秒杀服务前端

当我们点击 秒杀抢购按钮后,对应我们需要把当前的商品信息提交到后端服务。活动编号+"_"+SkuId,Code随机码,抢购商品的数量。
<div class="box-btns-two" th:if="${#dates.createNow().getTime() < item.seckillVO.startTime                            || #dates.createNow().getTime() > item.seckillVO.endTime }">                        <a href="#" id="addCart" th:attr="skuId=${item.info.skuId}">                           加入购物车                        </a>                    </div>                    <div class="box-btns-two" th:if="${#dates.createNow().getTime() > item.seckillVO.startTime                             && #dates.createNow().getTime() < item.seckillVO.endTime }">                        <a href="#" id="seckillId" th:attr="skuId=${item.info.skuId},sessionId=${item.seckillVO.promotionSessionId},code=${item.seckillVO.randCode}">                           抢购商品                        </a>                    </div>
对应的js操作
$("#seckillId").click(function(){       var isLogin = [[${session.loginUser !=null}]]       if(isLogin){           // 1. 获取活动编号和SkuId 2_10           var killId = $(this).attr("sessionId") + "_" + $(this).attr("skuId");           // 2. 获取对应的随机码           var code = $(this).attr("code");           // 3. 获取秒杀的商品数量           var num = $("#numInput").val();           location.href="http://seckill.msb.com/seckill/kill?killId="+killId + "&code="+code+"&num="+num;       }else{           alert("请先登录才能参加秒杀活动!!!");       }       return false;   });
访问测试:
notion image

3.后端逻辑处理

前端提交的秒杀请求,在后端具体的处理

3.1 登录校验

秒杀活动必须是在登录状态下进行的,如果没有认证就不让秒杀。这时我们需要整合进来SpringSession。
       <dependency>            <groupId>org.springframework.session</groupId>            <artifactId>spring-session-data-redis</artifactId>        </dependency>
然后添加对应的配置信息
notion image
然后添加拦截器
/** * 秒杀活动的拦截器 确认是杂登录的状态下操作的 */ public class AuthInterceptor implements HandlerInterceptor {    public static ThreadLocal threadLocal = new ThreadLocal();    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        // 通过HttpSession获取当前登录的用户信息        HttpSession session = request.getSession();        Object attribute = session.getAttribute(AuthConstant.AUTH_SESSION_REDIS);        if(attribute != null){            MemberVO memberVO = (MemberVO) attribute;            threadLocal.set(memberVO);            return true;       }        // 如果 attribute == null 说明没有登录,那么我们就需要重定向到登录页面        session.setAttribute(AuthConstant.AUTH_SESSION_MSG,"请先登录");        response.sendRedirect("http://auth.msb.com/login.html");        return false;   } }
配置拦截器
@Configuration public class MyWebInterceptorConfig implements WebMvcConfigurer {    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/seckill/kill");   } }
设置Cookie的配置
@Configuration public class MySessionConfig {    /**     * 自定义Cookie的配置     * @return     */    @Bean    public CookieSerializer cookieSerializer(){        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();        cookieSerializer.setDomainName("msb.com"); // 设置session对应的一级域名        cookieSerializer.setCookieName("msbsession");        return cookieSerializer;   }    /**     * 对存储在Redis中的数据指定序列化的方式     * @return     */    @Bean    public RedisSerializer<Object> redisSerializer(){        return new GenericJackson2JsonRedisSerializer();   } }
最后在启动类中开启
notion image

3.2 秒杀活动流程

notion image
登录校验
通过拦截器处理:在秒杀活动中并不是所有的请求都是需要在登录状态下的,所有这个拦截器应该只需要拦截部分的请求。
/** * 秒杀活动的拦截器 确认是杂登录的状态下操作的 */ public class AuthInterceptor implements HandlerInterceptor {    public static ThreadLocal threadLocal = new ThreadLocal();    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        // 通过HttpSession获取当前登录的用户信息        HttpSession session = request.getSession();        Object attribute = session.getAttribute(AuthConstant.AUTH_SESSION_REDIS);        if(attribute != null){            MemberVO memberVO = (MemberVO) attribute;            threadLocal.set(memberVO);            return true;       }        // 如果 attribute == null 说明没有登录,那么我们就需要重定向到登录页面        session.setAttribute(AuthConstant.AUTH_SESSION_MSG,"请先登录");        response.sendRedirect("http://auth.msb.com/login.html");        return false;   } }
配置拦截部分请求
@Configuration public class MyWebInterceptorConfig implements WebMvcConfigurer {    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/seckill/kill");   } }
合法性校验
校验的内容有四块:时效性,随机码是否合法,是否满足限购条件,还有幂等性
notion image
信号量处理
通过信号量来控制秒杀的商品数量。降低了对库存商品操作,提升了处理能力
if(aBoolean){                            // 表示数据插入成功 是第一次操作                            RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE+randCode);                            try {                                boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);                                if(b){                                    // 表示秒杀成功                                    String orderSN = UUID.randomUUID().toString().replace("-", "");                                    // 继续完成快速下订单操作 --> RocketMQ                                    SeckillOrderDto orderDto = new SeckillOrderDto() ;                                    orderDto.setOrderSN(orderSN);                                    orderDto.setSkuId(skuId);                                    orderDto.setSeckillPrice(dto.getSeckillPrice());                                    orderDto.setMemberId(id);                                    orderDto.setNum(num);                                    orderDto.setPromotionSessionId(dto.getPromotionSessionId());                                    // 通过RocketMQ 发送异步消息                                    rocketMQTemplate.sendOneWay(OrderConstant.ROCKETMQ_SECKILL_ORDER_TOPIC                                           ,JSON.toJSONString(orderDto));                                    return orderSN;                               }                           } catch (InterruptedException e) {                                return null;                           }                       }
MQ异步下单
秒杀成功后给RocketMQ发送消息,订单服务订阅消息,实现异步下单,从而降低了对秒杀系统的影响。
notion image
然后在订单服务中订阅对应的信息
@RocketMQMessageListener(topic = OrderConstant.ROCKETMQ_SECKILL_ORDER_TOPIC,consumerGroup = "test") @Component public class SeckillOrderConsumer implements RocketMQListener<String> {    @Autowired    OrderService orderService;    @Override    public void onMessage(String s) {        // 订单关单的逻辑实现        SeckillOrderDto orderDto = JSON.parseObject(s,SeckillOrderDto.class);        orderService.quickCreateOrder(orderDto);   } }
秒杀成功跳转到成功页面:
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head>    <meta charset="utf-8"/>    <title></title>    <script type="text/javascript" src="/static/cart/js/jquery-3.1.1.min.js"></script>    <script type="text/javascript" src="/static/cart/bootstrap/js/bootstrap.js"></script>    <script type="text/javascript" src="/static/cart/js/swiper.min.js"></script>    <script src="js/swiper.min.js"></script>    <link rel="stylesheet" type="text/css" href="/static/cart/css/swiper.min.css"/>    <link rel="stylesheet" type="text/css" href="/static/cart/bootstrap/css/bootstrap.css"/>    <link rel="stylesheet" type="text/css" href="/static/cart/css/success.css"/> </head> <body> <!--头部--> <div class="alert-info">    <div class="hd_wrap_top">        <ul class="hd_wrap_left">            <li class="hd_home"><i class="glyphicon glyphicon-home"></i>                <a href="http://mall.msb.com/home">马士兵商城首页</a>            </li>        </ul>        <ul class="hd_wrap_right">            <li th:if="${session.loginUser == null}"><a href="http://auth.msb.com/login.html" style="color: red;">你好,请登录</a></li>            <li th:if="${session.loginUser != null}"><span style="color: red;">[[${session.loginUser.nickname}]]</span></li>            <li class="spacer"></li>            <li>                <a href="/javascript:;">我的订单</a>            </li>        </ul>    </div> </div> <div class="nav-tabs-justified">    <div class="nav_wrap">        <div class="nav_top">            <div class="nav_top_one">                <a href="http://mall.msb.com/home"><img src="/static/cart/img/logo1.jpg"                                                        style="height: 60px;width:180px;"/></a>            </div>            <div class="nav_top_two"><input type="text"/>                <button>搜索</button>            </div>        </div>    </div> </div> <div class="main">    <div class="success-wrap">        <div class="w" id="result">            <div class="m succeed-box">                <div class="mc success-cont" th:if="${orderSn!=null}">                    <h1>恭喜您!秒杀成功,订单号[[${orderSn}]]</h1>                    <h2>正在准备订单数据...10秒后跳转到支付页面</h2>                    <a href="#">去支付</a>                </div>                <div class="mc success-cont" th:if="${orderSn==null}">                    <h1>很遗憾~没有抢购到!欢迎下次再来参与....</h1>                </div>            </div>        </div>    </div> </div> </body> <script type="text/javascript" src="/static/cart/js/success.js"></script> </html>
notion image
notion image
好了~到这儿秒杀活动搞定!
  • 微服务
  • spring-cloud-alibaba
  • 商城
  • 【架构设计】01-重构代码【商城实战】11-进阶篇-分布式事务