Springcloud 微服务 高并发(实战1):第1版秒杀

疯狂创客圈 Java 高并发【 亿级流量聊天室实战】实战系列之15 【博客园总入口html


前言

前言

疯狂创客圈(笔者尼恩建立的高并发研习社群)Springcloud 高并发系列文章,将为你们介绍三个版本的 高并发秒杀:前端

1、版本1 :springcloud + zookeeper 秒杀java

2、版本2 :springcloud + redis 分布式锁秒杀mysql

3、版本3 :springcloud + Nginx + Lua 高性能版本秒杀程序员

以及有关Springcloud 几篇核心、重要的文章web

1、Springcloud 配置, 史上最全 一文全懂面试

2、Springcloud 中 SpringBoot 配置全集 , 收藏版redis

3、Feign Ribbon Hystrix 三者关系 , 史上最全 深度解析算法

4、SpringCloud gateway 详解 , 史上最全spring

本文:是第一个版本 springcloud + zookeeper 秒杀 实现,文章比较长,你们能够挑选感兴趣的部分,选择性阅读。

1 为什么要以秒杀作为高并发实战案例?

时间调到在单体架构仍是主流的年代,那时候,你们学习J2EE技术的综合性实战案例,通常来讲,就是从0开始实现,一行一行代码的,磊出来一个购物车应用。这个案例能对J2EE有一个全面的练习,包括前台的脚本、MVC框架、事务、数据库等各个方法的技术。

时代在变,技术的复杂度变了,先后的分工也在变。

如今和之前不一样了,如今已经进入到微服务的时代,先后台程序员已经有比较明确的分工,在先后台分离的团队,后台程序员专门作Java开发,前台程序员专门作前台的开发。后台程序员能够不须要懂前台的技术如 Vue、TypeScript 等等,前台的程序员就更不必定须要懂后台技术了。

对于后台来讲,如今的分布式开发场景,在技术难度上,要比单体服务时代大多了。首先面临一大堆分布式、高性能中间件的学习,好比 Netty 、Zookeeper、RabbitMq、SpringCloud、Redis 等等。并且,在分布式环境下,要掌握如何发现解决数据一致性、高可靠性等问题,由于在高并发场景下,原本很正常的代码,也会跑出不少的性能相关的问题,因此,像Jmeter这类压力测试,也已经成为每个后台程序员所必须掌握的工具。

因此,这里以秒杀程序做为实战案例,简单来讲就是继往开来。继承单体架构时代的购物车应用的知识体系,开启高并发时代的Netty 、Zookeeper、RabbitMq、SpringCloud、Redis、Jmeter等新技术体系的学习。

1.1 业务场景和特色

秒杀案例在生活中几乎随处可见:好比商品抢购,好比春运抢票,仍是就是随处可见的红包也是相似的。

另外,在跳槽很频繁的IT行业,你们都会有面试的准备要求。在面试中, 秒杀业务或者秒杀中所用到的分布式锁、分布式ID、数据一致性、高并发限流等问题,通常都是成为重点题目和热门题目,为面试官和应聘者津津乐道.

从下单的角度来讲,秒杀业务很是简单:根据前后顺序,下订单减库存。

秒杀的特色:(1)瞬时大流量:秒杀时网站的面临访问量瞬时大增;(2)只有部分用户可以成功,秒杀时购买的请求数量远远大于库存。

1.1.1 详解:秒杀系统的业务流程

从系统角度来讲,秒杀系统的业务流程如图1所示,分红两大维度:

(1)商户维度的业务流程;

(2)用户维度的业务流程。
在这里插入图片描述

​ 图1 秒杀系统的业务流程

1、商户维度的业务流程,主要涉及两个操做:

(1)增长秒杀

经过后台的管理界面,增长特定商品、特定数量、特定时段的秒杀。

(2)暴露秒杀

将符合条件的秒杀,暴露给用户,以便互联网用户能参与商品的秒杀。这个操做能够是商户手动完成,更合理的方式是系统自动维护。

2、用户维度的业务流程,主要涉及两个操做:

(1)减库存

减小库存,简单说就是减小被秒杀到的商品的库存数量,这也是秒杀系统中一个处理难点的地方。为何呢? 这不只仅须要考虑如何避免同一用户重复秒杀的行为,并且在多个微服务并发状况下,须要保障库存数据的一致性,避免超卖的状况发生。

(2)下订单

减库存后,须要下订单,也就是在订单表中添加订单记录,记录购买用户的姓名、手机号、购买的商品ID等。与减库存相比,下订单相对比较简单。

特别说明下:为了聚焦高并发技术知识体系的学习,这里对秒杀的业务进行了馊身,去掉了一些其余的、可是也很是重要的功能,好比支付功能、提醒功能等等。

1.1.2 难点:秒杀系统面临的技术难题

秒杀业务通常就是下订单减库存,流程比较简单。那么,难点在哪里呢?

(1)秒杀通常是访问请求数量远远大于库存数量,只有少部分用户可以秒杀成功,这种场景下,须要借助分布式锁等保障数据一致性。

(2)秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。这就须要进行削峰和限流。

整体来讲,秒杀系统面临的技术难题,大体有以下几点:

(1)限流:

鉴于只有少部分用户可以秒杀成功,因此要限制大部分流量,只容许少部分流量进入服务后端。

(2)削峰

对于秒杀系统瞬时会有大量用户涌入,因此在抢购一开始会有很高的瞬间峰值。高峰值流量是压垮系统很重要的缘由,因此如何把瞬间的高流量变成一段时间平稳的流量也是设计秒杀系统很重要的思路。实现削峰的经常使用的方法有利用缓存和消息中间件等技术。

(3)异步处理

秒杀系统是一个高并发系统,采用异步处理模式能够极大地提升系统并发量,其实异步处理就是削峰的一种实现方式。

(4)内存缓存

秒杀系统最大的瓶颈通常都是数据库读写,因为数据库读写属于磁盘IO,性能很低,若是可以把部分数据或业务逻辑转移到内存缓存,效率会有极大地提高。

(5)可拓展

秒杀系统,必定是能够弹性拓展。若是流量来了,能够按照流量预估,进行服务节点的动态增长和摘除。好比淘宝、京东等双十一活动时,会增长大量机器应对交易高峰。

1.2 基于Zuul和Zookeeper的秒杀架构

从能力提供的角度来讲,基于Zuul和Zookeeper的秒杀架构,大体如所示。
在这里插入图片描述
​ 图2 从能力提供的角度展现Zuul和Zookeeper的秒杀架构

在基于Zuul和Zookeeper的秒杀架构中,Zuul网关负责路由和限流,而Zookeeper 做为幕后英雄,提供分布式计数器、分布式锁、分布式ID的生成器的基础能力。

分布式计数器、分布式锁、分布式ID的生成器等基础的能力,也是你们所必须系统学习和掌握的知识,超出了这里介绍的范围,若是对这一块不了解,请翻阅尼恩所编著的另外一本高并发基础书籍《Netty、Zookeeper、Redis 高并发实战》。

1.2.1 分层详解:基于微服务的秒杀架构

从分层的角度来讲,基于Zuul和Zookeeper的微服务秒杀系统,在架构上能够分红三层,如图3所示:

(1)客户端

(2)微服务接入层

(3)微服务业务层

1、客户端的功能

(1)秒杀页面静态化展现:

在桌面浏览器、移动端APP展现秒杀的商品。不论在哪一个屏幕展现,秒杀的活动元素,须要尽量所有静态化,并尽可能减小动态元素。这样,就能够经过CDN来抗峰值。

(2)禁止重复秒杀

用户在客户端操做过程当中,客户端须要具有用户行为的控制能力。好比,在用户提交秒杀以后,能够将用户秒杀的按钮置灰,禁止重复提交。

2、微服务接入层功能

(1)将请求拦截在系统上游,下降下游压力

秒杀系统特色是并发量极大,但实际秒杀成功的请求数量却不多,因此若是不在前端拦截极可能形成数据库读写锁冲突,甚至致使死锁,最终请求超时。

拦截用户的限流方式,有不少种。这里是秒杀的第一个版本,出于学习目的,本版仅仅介绍使用Zookeeper 的计数器能力进行限流,在后面的第二个秒杀版本,将会详细介绍如何使用Redis+Lua进行更高效率的限流,在更加后面的第三个秒杀版本,将会详细介绍使用Nginx+Lua 进行更加更加(两个更加)高效率的限流。

(2)消息队列削峰

上面只拦截了一部分访问请求,当秒杀的用户量很大时,即便每一个用户只有一个请求,到服务层的请求数量仍是很大。好比咱们有100W用户同时抢100台手机,服务层并发请求压力至少为100W。

使用消息队列能够削峰,将为后台缓冲大量并发请求,这也是一个异步处理过程,后台业务根据本身的处理能力,从消息队列中主动的拉取秒杀消息进行业务处理。

这个版本,不作消息队列削峰的介绍。在更加后面的第三个秒杀版本,将会详细介绍使用RabbitMq进行秒杀的削峰。

在这里插入图片描述

​ 图3 Zuul和Zookeeper的秒杀架构分层示意

3、微服务业务层功能

单体的秒杀服务,完成到达后台的秒杀下单的前台请求。而后,基于Springcloud的服务编排能力,进行多个单体服务的集群,使得整个系统具有能够动态扩展的能力。

其实,上面的图中,没有将数据库层列出,由于这是众所周知的。数据库层,也是最脆弱的一层,数据库层只承担“能力范围内”的访问请求。因此,须要在上游的接入层、服务层引入队列机制和缓存机制,让最底层的数据库高枕无忧。

1.2.2 简介:整体的项目结构

分红两个部分,介绍基于Zuul和Zookeeper的秒杀系统项目结构:

(1)Zuul网关与微服务基础能力的项目结构

(2)秒杀服务的项目结构

一:Zuul网关与微服务基础能力的项目结构

网关的路由能力,由Zuul和Eureka整合起来的微服务基础框架Ribben提供;网关的限流能力,主要在Zuul的过滤器类 —— ZkRateLimitFilter类中提供。

Zuul网关与微服务基础能力的项目结构如图4所示,具体请参见源码。
在这里插入图片描述
​ 图4 Zuul网关与微服务基础能力的项目结构

二:秒杀微服务的项目结构

秒杀微服务是一个标准的SpringBoot项目,分红controller、service、dao三层,如图5所示。,更加具体的项目结构学习,请参见源码。
在这里插入图片描述

​ 图5 秒杀服务的项目结构

1.2.3 接入层:使用Zuul进行路由

前面详细介绍Zuul的使用,这里不作大多的技术介绍。仅仅介绍一下,Zuul和seckill-provider秒杀服务的路由配置,具体以下:

#服务网关配置
zuul:
  ribbonIsolationStrategy: THREAD
  host:
    connect-timeout-millis: 60000
    socket-timeout-millis: 60000
  #路由规则
  routes:
#    user-service:
#      path: /user/**
#      serviceId: user-provider
    seckill-provider:
      path: /seckill-provider/**
      serviceId: seckill-provider
    message-provider:
      path: /message-provider/**
      serviceId: message-provider
    urlDemo:
      path: /user-provider/**
      url: http://127.0.0.1/user-provider

1.2.4 接入层:使用Zookeeper分布式计数器进行限流

理论上,接入层的限流有多个维度:

(1)用户维度限流:

在某一时间段内只容许用户提交一次请求,好比能够采起IP或者UserID限流。采起IP限流,能够拦截了浏览器访问的请求,但针对某些恶意攻击或其它插件,在接入层须要针对同一个访问UserID,限制访问频率。

(2)商品维度的限流

对于同一个抢购,在某一时间段内只容许必定数量的请求进入,利用这种简单的方式,防止后台的秒杀服务雪崩。

不管是那个维度的限流,掌握其中的一个,其余维度的限流,在技术实现上都是差很少的。这里,仅仅实现商品维度的限流,用户维度限流,你们能够本身去实现。

这里,为了完成商品维度的限流,实现了一个Zuul的过滤器类 —— ZkRateLimitFilter类,经过对秒杀的请求 "/seckill-provider/api/seckill/do/v1" 进行拦截,而后经过Zookeeper计数器,对当前的参与商品的秒杀人数进行判断,若是超出,则进行拦截。

ZkRateLimitFilter类的源码以下:

package com.crazymaker.springcloud.cloud.center.zuul.filter;


import com.crazymaker.springcloud.common.distribute.rateLimit.RateLimitService;
import com.crazymaker.springcloud.seckill.contract.constant.SeckillConstants;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.recipes.atomic.DistributedAtomicInteger;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * zookeeper 秒杀限流
 */
@Slf4j
@Component
public class ZkRateLimitFilter extends ZuulFilter {

    @Resource(name="zkRateLimitServiceImpl")
    RateLimitService rateLimitService;

    @Override
    public String filterType() {
//      pre:路由以前
//      routing:路由之时
//      post: 路由以后
//      error:发送错误调用
        return "pre";
    }

    /**
     * 过滤的顺序
     */
    @Override
    public int filterOrder() {
        return 0;
    }
    /**
     * 这里能够写逻辑判断,是否要过滤,true为永远过滤。
     */
    @Override
    public boolean shouldFilter() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        if(request.getRequestURI().startsWith("/seckill-provider/api/seckill/do/v1"))
        {
            return true;
        }

        return false;
    }

    /**
     * 过滤器的具体逻辑
     */
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        String goodId = request.getParameter("goodId");
        if (goodId != null) {

            DistributedAtomicInteger counter= rateLimitService.getZookeeperCounter(goodId);
            try {

                log.info( "参与抢购的人数:" + counter.get().preValue());
                if(counter.get().preValue()> SeckillConstants.MAX_ENTER)
                {
                    String msg="参与抢购的人太多,请稍后再试一试";
                    errorhandle(ctx, msg);
                    return null;
                }
            } catch (Exception e) {
                e.printStackTrace();

                String msg="计数异常,监控到商品是"+goodId;
                errorhandle(ctx, msg);
                return null;
            }

            return null;
        }else {

            String msg="必须输入抢购的商品";
            errorhandle(ctx, msg);
            return null;
        }

    }


    /**
     * 统一的异常拦截
     */

    private void errorhandle(RequestContext ctx, String msg) {
        ctx.setSendZuulResponse(false);
        try {
            ctx.getResponse().setContentType("text/html;charset=utf-8");
            ctx.getResponse().getWriter().write(msg);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


}

1.2.5 数据层:数据表和PO实体设计

秒杀系统的表设计仍是相对简单清晰的,主要涉及两张表:

(1)秒杀商品表

(2)订单表

固然实际状况确定不止这两张表(好比付款相关表),出于学习技术的目的,这里咱们只考虑秒杀系统的业务表,不考虑实际系统所涉及其余的表,并且,实际系统中,也不止表中的这些字段。

与商品表和订单表相对应,有设计两个PO实体类。啰嗦一下这里的系统命名规范,实体类统一使用PO后缀,传输类统一使用DTO后缀。这里的两个PO类分别为:

(1)SeckillGoodPO 类,对应到秒杀商品表

(2)SeckillOrderPO 类,对应到订单表

这里的两个PO类,和两个表,是严格的一一对应的。这种状况下,在基于JPA的实际开发中,习惯上经常能够基于PO类,逆向的生成数据库的表。因此,这里就不对数据表的结构作展开说明,而是以PO类进行替代。

SeckillGoodPO类的代码以下:

package com.crazymaker.springcloud.seckill.dao.po;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.format.annotation.DateTimeFormat;

import javax.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * 秒杀商品PO
 * 说明: 秒杀商品表和主商品表不一样
 *
 */

@Entity
@Table(name = "SECKILL_GOOD")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SeckillGoodPO implements Serializable {

    //商品ID
    @Id
    @GenericGenerator(
            name = "SeckillGoodIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillGoodIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY,generator = "SeckillGoodIdentityGenerator")
    @Column(name = "GOOD_ID", unique = true, nullable = false, length = 8)
    private Long id;

    //商品标题
    @Column(name = "GOOD_TITLE", length = 400)
    private String title;

    //商品标题
    @Column(name = "GOOD_IMAGE", length = 400)
    private String image;

    //商品原价格
    @Column(name = "GOOD_PRICE")
    private BigDecimal price;

    //商品秒杀价格
    @Column(name = "COST_PRICE")
    private BigDecimal costPrice;

    //建立时间
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "CREATE_TIME")
    private Date createTime;

    //秒杀开始时间
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "START_TIME")
    private Date startTime;

    //秒杀结束时间
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "END_TIME")
    private Date endTime;


    //剩余库存数量
    @Column(name = "STOCK_COUNT")
    private long stockCount;


}

秒杀订单PO类SeckillOrderPO的代码以下:

package com.crazymaker.springcloud.seckill.dao.po;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.format.annotation.DateTimeFormat;

import javax.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * 秒杀订单PO  对应到 秒杀订单表
 */

@Entity
@Table(name = "SECKILL_ORDER")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SeckillOrderPO implements Serializable {

    //订单ID
    @Id
    @GenericGenerator(
            name = "SeckillOrderIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillOrderIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "SeckillOrderIdentityGenerator")
    @Column(name = "ORDER_ID", unique = true, nullable = false, length = 8)
    private Long id;


    //支付金额
    @Column(name = "PAY_MONEY")
    private BigDecimal money;


    //秒杀用户的用户ID
    @Column(name = "USER_ID")
    private Long userId;

    //建立时间
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "CREATE_TIME")
    private Date createTime;


    //支付时间
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "PAY_TIME")
    private Date payTime;


    //秒杀商品,和订单是一对多的关系
    @Column(name = "GOOD_ID")
    private Long goodId;

    //订单状态, -1:无效 0:成功 1:已付款
    @Column(name = "STATUS")
    private Short status ;

}

想要说明的是,这里的订单SECKILL_ORDER表中的GOOD_ID商品ID字段,和商品表SECKILL_GOOD的GOOD_ID字段,是多对一的关系,可是,在建表的时候,不建议在数据库层面使用外键关系,这种一对多的逻辑关系,建议在Java代码中计算,而不是在数据库维度解决。

为何呢? 由于若是订单量巨大,会存在分库的可能,SECKILL_ORDER表和SECKILL_GOOD 表的相关联的数据,可能保存在不一样的数据库中,数据库层的关联关系,可能会致使系统出现致命的问题。

1.2.6 数据层:使用分布式ID生成器

实际的开发中,不少的项目为了应付交付和追求速度,对于数据的ID,简单粗暴的使用了Java的UUID。实际上,这种ID,项目初期会比较简单,可是项目后期会致使性能上的问题,具体的缘由,笔者在《Netty、Zookeeper、Redis高并发实战》一书中,作了很是细致的总结。

这里使用主流的基于Zookeeper+Snowflake算法,高效率的生成Long类型的数据,而且在源码中,分别为商品表和订单表封装了两个Hibernate的定制化ID生成器。订单表的Hibernate的定制化ID生成器类名称为 SeckillOrderIdentityGenerator ,使用的具体代码以下:

//订单ID
    @Id
    @GenericGenerator(
            name = "SeckillOrderIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillOrderIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "SeckillOrderIdentityGenerator")
    @Column(name = "ORDER_ID", unique = true, nullable = false, length = 8)
    private Long id;

SeckillOrderIdentityGenerator生成器类,继承了Hibernate内置的自增式IncrementGenerator 生成器类,代码以下:

package com.crazymaker.springcloud.seckill.idGenerator;
import com.crazymaker.springcloud.common.idGenerator.IdService;
import com.crazymaker.springcloud.standard.basicFacilities.CustomAppContext;
import org.hibernate.HibernateException;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.IncrementGenerator;

import java.io.Serializable;

/**
 * hibernate 的自定义ID生成器
 */
public class SeckillOrderIdentityGenerator extends IncrementGenerator {
    /**
     * 生成ID
     */
    @Override
    public Serializable generate(SharedSessionContractImplementor sessionImplementor, Object object) throws HibernateException {
        Serializable id = null;
        /**
         * 调用自定义的snowflake 算法,结合Zookeeper 生成ID
         */
        IdService idService = (IdService) CustomAppContext.getBean("seckillOrderIdentityGenerator");
        if (null != idService) {
            id = idService.nextId();
            return id;
        }

        id = sessionImplementor.getEntityPersister(null, object)
                .getClassMetadata().getIdentifier(object, sessionImplementor);
        return id != null ? id : super.generate(sessionImplementor, object);
    }
}

SeckillOrderIdentityGenerator生成器类的generate方法中,经过自定义的一个生成ID的Spring bean,生产一个新的ID。这个bean的名称为 seckillOrderIdentityGenerator,在自定义的配置文件中进行配置,代码以下:

package com.crazymaker.springcloud.standard.config;

import com.crazymaker.springcloud.common.distribute.rateLimit.impl.ZkRateLimitServiceImpl;
import com.crazymaker.springcloud.common.distribute.rateLimit.RateLimitService;
import com.crazymaker.springcloud.common.distribute.idService.impl.SnowflakeIdGenerator;
import com.crazymaker.springcloud.common.distribute.lock.LockService;
import com.crazymaker.springcloud.common.distribute.lock.impl.ZkLockServiceImpl;
import com.crazymaker.springcloud.common.distribute.zookeeper.ZKClient;
import com.crazymaker.springcloud.common.idGenerator.IdService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

@Configuration
@ConditionalOnProperty(prefix = "zookeeper", name = "address")
public class ZookeeperDistributeConfig {
    @Value("${zookeeper.address}")
    private String zkAddress;

    /**
     * 自定义的ZK客户端bean
     *
     * @return
     */
    @Bean(name = "zKClient")
    public ZKClient zKClient() {
        return new ZKClient(zkAddress);
    }

    /**
     * 获取 ZK 限流器的 bean
     */
    @Bean
    @DependsOn("zKClient")
    public RateLimitService zkRateLimitServiceImpl() {
        return new ZkRateLimitServiceImpl();
    }

    /**
     * 获取 ZK 分布式锁的 bean
     */

    @Bean
    @DependsOn("zKClient")
    public LockService zkLockServiceImpl() {
        return new ZkLockServiceImpl();
    }


    /**
     * 获取秒杀商品的分布式ID 生成器
     */
    @Bean
    @DependsOn("zKClient")
    public IdService seckillGoodIdentityGenerator() {
        return new SnowflakeIdGenerator("seckillGoodIdentityGenerator");
    }


    /**
     * 获取秒杀订单的分布式ID 生成器
     */
    @Bean
    @DependsOn("zKClient")
    public IdService seckillOrderIdentityGenerator() {
        return new SnowflakeIdGenerator("seckillOrderIdentityGenerator");
    }

}

能够看到,这里配置两个ID生成器,一个对应到商品表、一个对应到订单表。为啥须要配置两个呢? 具体缘由和Zookeeper分布式命名的机制有关,因为篇幅缘由,这里不作赘述,请参考《Netty、Zookeeper、Redis高并发实战》一书。

若是表的数据量比较多,能够进行生成器的优化,将多个生成器合并成一个,具体的优化工做,还请你们本身完成。

1.1 秒杀服务Controller控制层实现

本小节首先介绍API的接口设计,而后介绍其SeckillController 类的控制层实现逻辑。

1.1.1 Rest风格的API接口设计

SpringBoot 框架很早就支持开发REST资源,能够完美的支持Restful风格的API Url地址的解析。在SpringBoot 框架上,能够在Controller中定义这样一个由动态的数据拼接组成的、而不是将全部的资源所有映射到一个路径下的、动态的URL映射地址,好比:/{id}/detail 。

这种URL结构的优点:咱们能很容易从URL地址上判断出该地址所展现的页面是什么?好比:/good/1/detail就可能表示ID为1的商品的详情页,看起来设计的很清晰。

在Controller层,若是解析Url中的变量呢?能够在对应的映射方法上,添加@PathVariable注解,这个注解,填在对应的Java 参数的前面,若是:@PathVariable("id") Long id,就能将的Restful风格的API Url地址/good/{id}/detail中,{id}所指定的数据并赋值给这个id参数。

秒杀的Rest API 定义在SeckillController 类中,而且,能够经过Swagger UI的进行互动交互,秒杀的Rest API列表,如图6所示。
在这里插入图片描述

​ 图6 秒杀的Rest API清单

1.1.2 Controller控制层方法定义

秒杀的控制层类叫作SeckillController 类,而且使用了@RestController注解标识的类,Spring会将其下的全部方法return的Java类型的数据都转换成JSON格式,且不会被Spring视图解析器扫描到,也就是此类下面的全部方法都不可能返回一个视图页面。啰嗦一句,@RestController注解只能用在类上,不能用在方法体上。

SeckillController 类的代码以下:

package com.crazymaker.springcloud.seckill.controller;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;
import com.crazymaker.springcloud.seckill.service.SeckillService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.math.BigDecimal;


@RestController
@RequestMapping("/api/seckill/")
@Api(tags = "秒杀")
public class SeckillController {
    @Resource
    SeckillService seckillService;


    /**
     * 查询商品信息
     *
     * @param goodId 商品id
     * @return 商品 dto
     */
    @GetMapping("/good/{id}/detail/v1")
    @ApiOperation(value = "查看商品信息")
    Result<SeckillGoodDTO> goodDetail(
            @PathVariable(value = "id") String goodId) {
        Result<SeckillGoodDTO> r = seckillService.findGoodByID(Long.valueOf(goodId));
        return r;
    }


    /**
     * 获取全部的秒杀商品列表
     *
     * @param pageReq 当前页 ,从1 开始,和 页的元素个数
     * @return
     */
    @PostMapping("/list/v1")
    @ApiOperation(value = "获取全部的秒杀商品列表")
    Result<PageView<SeckillGoodDTO>> findAll(@RequestBody PageReq pageReq) {
        PageView<SeckillGoodDTO> page = seckillService.findAll(pageReq);
        Result<PageView<SeckillGoodDTO>> r = Result.success(page);
        return r;

    }


    /**
     * 秒杀开始时输出暴露秒杀的地址
     * 否者输出系统时间和秒杀时间
     *
     * @param gooId 商品id
     */
    @GetMapping("/good/expose/v1")
    @ApiOperation(value = "暴露秒杀商品")
    Result<SeckillGoodDTO> exposeSeckillGood(
            @RequestParam(value = "goodId", required = true) long gooId) {

        Result<SeckillGoodDTO> r = seckillService.exposeSeckillGood(gooId);
        return r;

    }

    /**
     * 执行秒杀的操做
     *
     * @param goodId 商品id
     * @param money  钱
     * @param userId 用户id
     * @param md5    校验码
     * @return
     */
    @ApiOperation(value = "秒杀")
    @GetMapping("/do/v1")
    Result<SeckillOrderDTO> executeSeckill(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        Result<SeckillOrderDTO> r = seckillService.executeSeckillV1(goodId, money, userId, md5);
        return r;
    }

    /**
     * 执行秒杀的操做
     *
     * @param goodId 商品id
     * @param money  钱
     * @param userId 用户id
     * @param md5    校验码
     * @return
     */
    @ApiOperation(value = "秒杀")
    @GetMapping("/do/v2")
    Result<SeckillOrderDTO> executeSeckillV2(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        SimpleOrderDTO inDto = new SimpleOrderDTO();
        inDto.setGoodId(goodId);
        inDto.setMd5(md5);
        inDto.setUserId(userId);
        SeckillOrderDTO dto = seckillService.executeSeckillV2(inDto);
        return Result.success(dto).setMsg("秒杀成功");

    }

    /**
     * 增长秒杀的商品
     *
     * @param stockCount 库存
     * @param title      标题
     * @param price      商品原价格
     * @param costPrice  价格
     * @return
     */
    @GetMapping("/good/add/v1")
    @ApiOperation(value = "增长秒杀的商品")
    Result<SeckillGoodDTO> executeSeckill(
            @RequestParam(value = "stockCount", required = true) long stockCount,
            @RequestParam(value = "title", required = true) String title,
            @RequestParam(value = "price", required = true) BigDecimal price,
            @RequestParam(value = "costPrice", required = true) BigDecimal costPrice) {
        Result<SeckillGoodDTO> r = seckillService.addSeckillGood(stockCount, title, price, costPrice);
        return r;
    }

    /**
     * 保存秒杀到缓存
     */
    @GetMapping("/good/cache/v1")
    @ApiOperation(value = "保存秒杀到缓存")
    public Result<Integer> loadSeckillToCache() {
        Result<Integer> r = seckillService.loadSeckillToCache();
        return r;
    }
}

1.1.3 Result 类是什么?

为Controller层能返回格式一致的JSON结果数据,这里,手动建立了Result 类类来封装一些通用的结果信息,好比status状态码、好比msg文本消息。Result 类是一个泛型类,真正的返回结果,封装在data成员中。

Result 类的代码以下:

package com.crazymaker.springcloud.seckill.controller;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;
import com.crazymaker.springcloud.seckill.service.SeckillService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.math.BigDecimal;


@RestController
@RequestMapping("/api/seckill/")
@Api(tags = "秒杀")
public class SeckillController {
    @Resource
    SeckillService seckillService;


    /**
     * 查询商品信息
     *
     * @param goodId 商品id
     * @return 商品 dto
     */
    @GetMapping("/good/{id}/detail/v1")
    @ApiOperation(value = "查看商品信息")
    Result<SeckillGoodDTO> goodDetail(
            @PathVariable(value = "id") String goodId) {
        Result<SeckillGoodDTO> r = seckillService.findGoodByID(Long.valueOf(goodId));
        return r;
    }


    /**
     * 获取全部的秒杀商品列表
     *
     * @param pageReq 当前页 ,从1 开始,和 页的元素个数
     * @return
     */
    @PostMapping("/list/v1")
    @ApiOperation(value = "获取全部的秒杀商品列表")
    Result<PageView<SeckillGoodDTO>> findAll(@RequestBody PageReq pageReq) {
        PageView<SeckillGoodDTO> page = seckillService.findAll(pageReq);
        Result<PageView<SeckillGoodDTO>> r = Result.success(page);
        return r;

    }


    /**
     * 秒杀开始时输出暴露秒杀的地址
     * 否者输出系统时间和秒杀时间
     *
     * @param gooId 商品id
     */
    @GetMapping("/good/expose/v1")
    @ApiOperation(value = "暴露秒杀商品")
    Result<SeckillGoodDTO> exposeSeckillGood(
            @RequestParam(value = "goodId", required = true) long gooId) {

        Result<SeckillGoodDTO> r = seckillService.exposeSeckillGood(gooId);
        return r;

    }

    /**
     * 执行秒杀的操做
     *
     * @param goodId 商品id
     * @param money  钱
     * @param userId 用户id
     * @param md5    校验码
     * @return
     */
    @ApiOperation(value = "秒杀")
    @GetMapping("/do/v1")
    Result<SeckillOrderDTO> executeSeckill(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        Result<SeckillOrderDTO> r = seckillService.executeSeckillV1(goodId, money, userId, md5);
        return r;
    }

    /**
     * 执行秒杀的操做
     *
     * @param goodId 商品id
     * @param money  钱
     * @param userId 用户id
     * @param md5    校验码
     * @return
     */
    @ApiOperation(value = "秒杀")
    @GetMapping("/do/v2")
    Result<SeckillOrderDTO> executeSeckillV2(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        SimpleOrderDTO inDto = new SimpleOrderDTO();
        inDto.setGoodId(goodId);
        inDto.setMd5(md5);
        inDto.setUserId(userId);
        SeckillOrderDTO dto = seckillService.executeSeckillV2(inDto);
        return Result.success(dto).setMsg("秒杀成功");

    }

    /**
     * 增长秒杀的商品
     *
     * @param stockCount 库存
     * @param title      标题
     * @param price      商品原价格
     * @param costPrice  价格
     * @return
     */
    @GetMapping("/good/add/v1")
    @ApiOperation(value = "增长秒杀的商品")
    Result<SeckillGoodDTO> executeSeckill(
            @RequestParam(value = "stockCount", required = true) long stockCount,
            @RequestParam(value = "title", required = true) String title,
            @RequestParam(value = "price", required = true) BigDecimal price,
            @RequestParam(value = "costPrice", required = true) BigDecimal costPrice) {
        Result<SeckillGoodDTO> r = seckillService.addSeckillGood(stockCount, title, price, costPrice);
        return r;
    }

    /**
     * 保存秒杀到缓存
     */
    @GetMapping("/good/cache/v1")
    @ApiOperation(value = "保存秒杀到缓存")
    public Result<Integer> loadSeckillToCache() {
        Result<Integer> r = seckillService.loadSeckillToCache();
        return r;
    }
}

1.2 秒杀服务Service层的实现

开始着手编写业务层接口,而后编写业务层接口的实现类并编写业务层的核心逻辑。

1.2.1 SeckillService 秒杀服务接口定义

设计业务层接口,应该站在使用者角度上设计,如咱们应该作到:

1.定义业务方法的颗粒度要细。

2.方法的参数要明确简练,不建议使用相似Map这种类型,让使用者能够封装进Map中一堆参数而传递进来,尽可能精确到哪些参数。

3.方法的return返回值,除了应该明确返回值类型,还应该指明方法执行可能产生的异常(RuntimeException),并应该手动封装一些通用的异常处理机制。

SeckillService秒杀接口的定义以下:

package com.crazymaker.springcloud.seckill.service;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;

import java.math.BigDecimal;

/**
 * 秒杀接口
 */
public interface SeckillService
{
    /**
     * 查询商品信息
     * @param id  商品id
     * @return  商品 dto
     */
    Result<SeckillGoodDTO> findGoodByID(Long id);



    /**
     * 获取全部的秒杀商品列表
     *
     * @return
     * @param pageReq  当前页 ,从1 开始,和 页的元素个数
     */
    PageView<SeckillGoodDTO> findAll(PageReq pageReq);



    /**
     * 秒杀开始时输出暴露秒杀的地址
     * 否者输出系统时间和秒杀时间
     *
     * @param gooId  商品id
     */
    Result<SeckillGoodDTO> exposeSeckillGood(long gooId);

    /**
     * 执行秒杀的操做
     *  @param goodId 商品id
     * @param money  钱
     * @param userId  用户id
     * @param md5  校验码
     * @return
     */
    Result<SeckillOrderDTO> executeSeckillV1(
            long goodId,
            BigDecimal money,
            long userId,
            String md5);

    SeckillOrderDTO executeSeckillV2(SimpleOrderDTO inDto);
    SeckillOrderDTO executeSeckillV3(SimpleOrderDTO inDto);

    /**
     * 增长秒杀的商品
     *
     * @param stockCount  库存
     * @param title  标题
     * @param price   商品原价格
     * @param costPrice   价格
     * @return
     */
    Result<SeckillGoodDTO> addSeckillGood(
            long stockCount,
            String title,
            BigDecimal price,
            BigDecimal costPrice);

    /**
     * 保存秒杀到缓存
     *
     */
    Result<Integer> loadSeckillToCache();
}

1.2.2 findGoodByID和findAll方法

首先看最简单的两个方法:findGoodByID和findAll方法。

findById(): 顾名思义根据ID主键查询。按照接口的设计,咱们须要指定参数是秒杀商品的ID值。返回值是查询到的秒杀商品的SeckillGoodDTO的包装类Result 类。

findGoodByID()方法的源码以下:

@Override
    public Result<SeckillGoodDTO> findGoodByID(Long id) {

        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(id);

        if (optional.isPresent()) {
            SeckillGoodDTO dto = new SeckillGoodDTO();
            BeanUtils.copyProperties(optional.get(), dto);
            return Result.success(dto).setMsg("查找成功");
        }
        return Result.error("未找到指定秒杀商品");

    }

findAll(): 顾名思义是查询数据库中全部的秒杀商品表的数据,由于记录数不止一条,因此通常就用List集合接收,并制定泛型是List ,表示从数据库中查询到的列表数据都是Seckill实体类对应的数据,并以Seckill实体类的结构将列表数据封装到List集合中。

findAll()的源码以下:

/**
     * 获取全部的秒杀商品列表
     *
     * @param pageReq 当前页 ,从1 开始,和 页的元素个数
     * @return
     */
    @Override
    public PageView<SeckillGoodDTO> findAll(PageReq pageReq) {
        Specification<SeckillGoodPO> specification = getSeckillGoodPOSpecification();

        Page<SeckillGoodPO> page = seckillGoodDao.findAll(specification, PageRequest.of(pageReq.getJpaPage(), pageReq.getPageSize()));

        PageView<SeckillGoodDTO> pageView = PageAdapter.adapter(page, SeckillGoodDTO.class);

        return pageView;

    }

1.2.3 秒杀暴露实现:exportSeckillUrl方法

这里有两个问题:(1)什么是秒杀暴露呢?(2)为何要进行秒杀暴露呢?

首先看第一个问题:什么是秒杀暴露呢?很简单,就是根据该商品的ID,获取到这个商品的秒杀MD5字符串。

再来看第二个问题:为何要进行秒杀暴露呢?

目的之一就是保证公平,防止刷单。

秒杀系统中,同一件商品,好比瞬间有十万的用户访问,而还存在各类黄牛,有各类工具去抢购这个商品,那么此时确定不止10万的访问量的,而且开发者要尽可能的保证每一个用户抢购的公平性,也就是不能让一个用户抢购一堆数量的此商品。

如何防止刷单呢?就是生成验证字符串,好比MD5字符串。而且验证字符串能够包含要进行防止刷单验证的各类信息,好比商品ID、好比用户ID,这样同一用户只能有惟一的一个MD5字符串,不一样用户间不一样的,就没有办法经过其余人的连接,进行商品的刷单了。

/**
     * 秒杀暴露
     * @param gooId  商品id
     * @return 暴露的秒杀商品
     */
    @Override
    public Result<SeckillGoodDTO> exposeSeckillGood(long gooId) {
        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(gooId);
        if (!optional.isPresent()) {
            //秒杀不存在
            throw BizException.builder().errMsg("秒杀不存在").build();
        }
        SeckillGoodPO goodPO = optional.get();

        Date startTime = goodPO.getStartTime();
        Date endTime = goodPO.getEndTime();
        //获取系统时间
        Date nowTime = new Date();
        if (nowTime.getTime() < startTime.getTime()) {
            //秒杀不存在
            throw BizException.builder().errMsg("秒杀没有开始").build();
        }

        if (nowTime.getTime() > endTime.getTime()) {
            //秒杀已经结束
            throw BizException.builder().errMsg("秒杀已经结束").build();
        }
        //转换特定字符串的过程,不可逆的算法
        String md5 = Encrypt.getMD5(String.valueOf(gooId));

        SeckillGoodDTO dto = new SeckillGoodDTO();
        BeanUtils.copyProperties(goodPO, dto);
        dto.setMd5(md5);
        dto.setExposed(true);
        return Result.success(dto).setMsg("暴露成功");
    }

exposeSeckillGood ()的主要逻辑:根据传进来的goodId商品ID,查询对应的秒杀商品数据,若是没有查询到,多是用户非法输入的数据;若是查询到了,就获取秒杀开始时间和秒杀结束时间,以及进行判断当前秒杀商品是否正在进行秒杀活动,尚未开始或已经结束都直接抛出业务异常;若是上面两个条件都符合了就证实该商品存在且正在秒杀活动中,那么咱们须要暴露秒杀商品。

暴露秒杀商品的主要内容,就是生成一串md5值做为返回数据的一部分。而Spring提供了一个工具类DigestUtils用于生成MD5值,且又因为要作到更安全因此咱们采用md5+盐的加密方式,将须要加密的信息做为盐,生成一传md5加密数据做为秒杀MD5校验字符串。

1.2.4 分布式秒杀控制:executeSeckill 方法

秒杀的核心业务逻辑,很简单、很清晰,就是两点:1.减库存;2.储存用户秒杀订单明细。针可是其中涉及到不少分布式控制、数据库事务、秒杀安全验证等问题。这里咱们将秒杀分红两个方法:

(1)分布式秒杀控制:executeSeckill 方法;

(2)执行秒杀的操做:doSeckill(order)方法。

分布式秒杀控制executeSeckill 方法的流程如图7所示。
在这里插入图片描述
​ 图7 分布式秒杀控制executeSeckill 方法的流程图

分布式秒杀控制executeSeckill 方法的代码以下:

/**
     * 秒杀的分布式控制
     * Spring默认只对运行期异常进行事务的回滚操做
     * 对于受检异常Spring是不进行回滚的
     * 因此对于须要进行事务控制的方法尽量将可能抛出的异常都转换成运行期异常
     *
     * @param goodId 商品id
     * @param money  钱
     * @param userId 用户id
     * @param md5    校验码
     * @return
     */
    @Override
    public Result<SeckillOrderDTO> executeSeckillV1(
            long goodId, BigDecimal money, long userId, String md5) {
        if (md5 == null || !md5.equals(Encrypt.getMD5(String.valueOf(goodId)))) {
            throw BizException.builder().errMsg("秒杀的连接被重写过了").build();
        }

        /**
         * Zookeeper 限流计数器 增长数量
         */
        DistributedAtomicInteger counter =
                zkRateLimitServiceImpl.getZookeeperCounter(String.valueOf(goodId));
        try {
            counter.increment();
        } catch (Exception e) {
            e.printStackTrace();
            //秒杀异常
            throw BizException.builder().errMsg("秒杀异常").build();

        }

        /**
         * 建立订单对象
         */
        SeckillOrderPO order =
                SeckillOrderPO.builder().goodId(goodId).userId(userId).build();


        //执行秒杀逻辑:1.减库存;2.储存秒杀订单
        Date nowTime = new Date();
        order.setCreateTime(nowTime);
        order.setMoney(money);
        order.setStatus(SeckillConstants.ORDER_VALID);


        /**
         * 建立分布式锁
         */
        InterProcessMutex lock =
                lockService.getZookeeperLock(String.valueOf(goodId));

        try {
            /**
             * 获取分布式锁
             */
            lock.acquire(1, TimeUnit.SECONDS);
            /**
             * 执行秒杀,带事务
             */
            doSeckill(order);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                /**
                 * 释放分布式锁
                 */
                lock.release();
            } catch (Exception e) {
                log.error(e.getMessage());
            }
        }


        SeckillOrderDTO dto = new SeckillOrderDTO();
        BeanUtils.copyProperties(order, dto);

        //Zookeeper 限流计数器  减小流量计算
        try {
            counter.decrement();
        } catch (Exception e) {
            e.printStackTrace();
            //秒杀异常
            throw BizException.builder().errMsg("秒杀异常").build();

        }
        return Result.success(dto).setMsg("秒杀成功");

    }

1.2.5 秒杀执行:doSeckill(order)方法

doSeckill简单一些,主要涉及两个业务操做:1.减库存;2.记录订单明细。可是,在执行前,须要进行数据的验证,以防止超卖等不合理的现象发生。

doSeckill(order)方法的流程如图8所示。
在这里插入图片描述
​ 图8 doSeckill(order)流程图

doSeckill(order)方法的代码以下所示:

@Transactional
    public void doSeckill(SeckillOrderPO order) {
        /**
         * 建立重复性检查的订单对象
         */
        SeckillOrderPO checkOrder =
                SeckillOrderPO.builder().goodId(order.getGoodId()).userId(order.getUserId()).build();

        //记录秒杀订单信息
        long insertCount = seckillOrderDao.count(Example.of(checkOrder));

        //惟一性判断:goodId,userId 保证一个用户只能秒杀一件商品
        if (insertCount >= 1) {
            //重复秒杀
            log.error("重复秒杀");
            throw BizException.builder().errMsg("重复秒杀").build();
        }


        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(order.getGoodId());
        if (!optional.isPresent()) {
            //秒杀不存在
            throw BizException.builder().errMsg("秒杀不存在").build();
        }


        //查询库存
        SeckillGoodPO good = optional.get();
        if (good.getStockCount() <= 0) {
            //重复秒杀
            throw BizException.builder().errMsg("秒杀商品被抢光").build();
        }

        order.setMoney(good.getCostPrice());

        seckillOrderDao.save(order);

        //减库存

        seckillGoodDao.updateStockCountById(order.getGoodId());
    }

1.2.6 BizException 业务异常定义

减库存操做和插入购买明细操做都会产生不少未知异常(RuntimeException),好比秒杀结束、重复秒杀等。除了要返回这些异常信息,还有一个很是重要的操做就是捕获这些RuntimeException,从而避免系统直接报错。

针对秒杀可能出现的各类业务异常,这里定义了一个本身的异常类 BizException类,代码以下:

package com.crazymaker.springcloud.common.exception;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@AllArgsConstructor
@Builder
@Data
public class BizException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    /**
     * 默认的错误编码
     */
    private static final int DEFAULT_BIZ_ERR_CODE = -1;


    private static final String DEFAULT_ERR_MSG = "系统错误";


    /**
     * 业务错误编码
     */
    private int bizErrCode = DEFAULT_BIZ_ERR_CODE;

    /**
     * 错误的提示信息
     */
    private String errMsg = DEFAULT_ERR_MSG;

}

特别注意一下,此类继承了 RuntimeException 运行时异常类,而不是Exception受检异常基类,代表BizException类其实一个非受检的运行时异常类。为何要这样呢? 由于默认状况下,SpringBoot 事务只有检查到RuntimeException类型的异常才会回滚,若是检查到的是受检异常,SpringBoot 事务是不会回滚的,除非通过特殊配置。

1.2.7 Zookeeper 分布式锁应用

分布式秒杀控制executeSeckill 方法中,用到了Zookeeper分布式锁,这里简单说明一下分布式锁特色:

(1)排他性:同一时间,只有一个线程能得到;

(2)阻塞性:其它未抢到的线程阻塞等待,直到锁被释放,再继续抢;

(3)可重入性:线程得到锁后,后续是否可重复获取该锁(避免死锁)。

Zookeeper的分布式锁与Redis的分布式锁、数据库锁相比,简单来讲有如下优点:

(1)Zookeeper 通常是多节点集群部署,性能比较高;而使用数据库锁会有单机性能瓶颈问题。

(2)Zookeeper分布式锁可靠性比Redis好,实现相对简单。固然,因为须要建立节点、删除节点等,效率比Redis确定要低。

分布式秒杀控制executeSeckill 方法中,只有成功抢占了分布式锁,才能进入执行实际秒杀的doSeckill()方法。即时部署了多个秒杀的微服务,也能保证,同一时刻,只有一个微服务进行实际的秒杀,具体如图9所示。
在这里插入图片描述

​ 图9 秒杀的分布式锁示意图

在《Netty、Zookeeper、Redis高并发实战》一书中,详细介绍了关于分布式锁的知识,以及如何经过Curator API实现本身的Zookeeper分布式锁。这里再也不对分布式锁的实现,进行赘述。

这里仅仅介绍一下,若是在SpringBoot程序中,如何获取分布式锁。代码以下:

package com.crazymaker.springcloud.common.distribute.lock.impl;

import com.crazymaker.springcloud.common.distribute.lock.LockService;
import com.crazymaker.springcloud.common.distribute.zookeeper.ZKClient;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ZkLockServiceImpl implements LockService {

    Map<String, InterProcessMutex> lockMap = new ConcurrentHashMap<>();


    /**
     * 取得ZK 的分布式锁
     * @param key  锁的key
     * @return   ZK 的分布式锁
     */
    public InterProcessMutex getZookeeperLock(String key) {
        CuratorFramework client = ZKClient.getSingleton().getClient();
        InterProcessMutex lock = lockMap.get(key);
        if (null == lock) {
            lock = new InterProcessMutex(client, "/mutex/seckill/" + key  );
            lockMap.put(key, lock);
        }
        return lock;
    }

}

1.3 高并发测试

1.3.1 启动微服务和秒杀服务

首先须要启动Eureka服务注册和发现应用,而后启动SpringCloud Config服务,最后启动秒杀服务Seckill-provider。不过,为了提升并发能力,这里直接启动了两个Seckill-provider服务,具体如图10所示。说明下:服务名称不区分大小写,图中的服务名称,统一进行了大写的展现。

在这里插入图片描述

​ 图10 秒杀的服务清单示意图

图10中的message-provider消息服务,在当前的秒杀版本中,并无用到。可是,在秒杀的第三个实现版本中有用到,后续会详细介绍。

1.3.2 使用Jmeter进行高并发测试

启动完微服务后,能够启动Jmeter,配置完参数后,开始进行压力测试。

在这里插入图片描述

1.3.3 高并发过程当中遇到的问题

一些潜在问题,在用户量少的场景,每每都是发现不了,而一旦进行压力测试,就会蹦出来了。好比说,下面这个链接池的链接数不够的问题,具体以下:

Caused by: com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 60000, active 20, maxActive 20, creating 0
        at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1512)
        at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1255)
        at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5007)
        at com.alibaba.druid.filter.stat.StatFilter.dataSource_getConnection(StatFilter.java:680)
        at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5003)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1233)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1225)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:90)
        at org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection(DatasourceConnectionProviderImpl.java:122)
        at org.hibernate.internal.NonContextualJdbcConnectionAccess.obtainConnection(NonContextualJdbcConnectionAccess.java:35)
        at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:106)
        ... 118 common frames omitted

很显然,是Druid数据库链接池中的链接数不够,查看代码,发现以前的数据池配置以下:

spring:
  datasource:
    driverClassName: com.mysql.jdbc.Driver
#    driverClassName: oracle.jdbc.driver.OracleDriver
    druid:
      initial-size: 5
      max-active: 20
      max-wait: 60000
      min-evictable-idle-time-millis: 300000
      min-idle: 5
      test-on-borrow: false
      test-on-return: false
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      validation-query: SELECT 1 FROM DUAL
      ……..

max-active的值为20,表示池中最多20个链接,很显然,这个值过小。适当的增长链接池的最大链接数限制,这里从20修改到200。生产场景中,这个数据要依据实际的最大链接数预估值去肯定。修改完成后的数据库链接池的配置以下:

spring:
  datasource:
    driverClassName: com.mysql.cj.jdbc.Driver
#    driverClassName: com.mysql.jdbc.Driver
#    driverClassName: oracle.jdbc.driver.OracleDriver
    druid:
      initial-size: 20
      max-active: 200
      max-wait: 60000
      min-evictable-idle-time-millis: 300000
      min-idle: 5
      test-on-borrow: false
      test-on-return: false
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      validation-query: SELECT 1 FROM DUAL
    password: root
    username: root
   ……..

再一次启动秒杀微服务,后续的并发测试中,没有出现过链接数不够的异常。说明问题已经接近。

1.3.4 Zuul+Zookeeper秒杀性能分析

以前也提到过,Zookeeper自己的性能不是过高,因此,对测试的结果预期也不高。下面是并发测试的结果,能够看到,在50并发的场景下,单次秒杀的平均响应时间,已经到了17s。

在这里插入图片描述

Zookeeper自己的并发性能不是过高,不是说Zookeeper没有用,仅仅是适用领域不一样。在分布式ID,分布式集群协调等领域,Zookeeper的做用是很是巨大的,这是Redis等缓存工具,无法替代的和比拟的。

好了,至此一个版本的秒杀,已经介绍完毕。后面会介绍第二个版本、第三个版本的秒杀,后面的版本,性能会直接飙升。

最后,介绍一下疯狂创客圈:疯狂创客圈,一个Java 高并发研习社群博客园 总入口

疯狂创客圈,倾力推出:面试必备 + 面试必备 + 面试必备 的基础原理+实战 书籍 《Netty Zookeeper Redis 高并发实战

img


疯狂创客圈 Java 死磕系列

  • Java (Netty) 聊天程序【 亿级流量】实战 开源项目实战

  • Netty 源码、原理、JAVA NIO 原理
  • Java 面试题 一网打尽
  • 疯狂创客圈 【 博客园 总入口 】