分布式秒杀 – 实战

  • Post author:
  • Post category:其他

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

前言

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

一、版本1 :springcloud + zookeeper 秒杀

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

三、版本3 :springcloud + Nginx + Lua 高性能版本秒杀

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

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

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

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

四、SpringCloud gateway 详解 , 史上最全

本文:是**第一个版本 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)增加秒杀

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

(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)秒杀页面静态化展示:

在桌面浏览器、移动端APP展示秒杀的商品。不论在哪个屏幕展示,秒杀的活动元素,需要尽可能全部静态化,并尽量减少动态元素。这样,就可以通过CDN来抗峰值。

(2)禁止重复秒杀

用户在客户端操作过程中,客户端需要具备用户行为的控制能力。比如,在用户提交秒杀之后,可以将用户秒杀的按钮置灰,禁止重复提交。

二、微服务接入层功能

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

秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时。

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

(2)消息队列削峰

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

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

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

在这里插入图片描述

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

三、微服务业务层功能

单体的秒杀服务,完成到达后台的秒杀下单的前台请求。然后,基于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) 聊天程序【 亿级流量】实战 开源项目实战

版权声明:本文为crazymakercircle原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。