跳至主要內容

云商城-商品秒杀-程序隔离解决方案

soulballad实践项目SpringCloudAlibaba 云商城SpringCloudAlibaba约 6438 字大约 21 分钟

第11章 秒杀抢购下单控制

课程目标

1、MyBatis查询Apache Druid

​ 1)常规查询

​ 2)复杂查询

2、热门商品处理

​ 1)热门商品分析

​ 2)热门商品分离·

3、秒杀抢单操作

​ 1)热门商品抢单

​ 2)添加订单功能

​ 3)非热门商品抢单

​ 4)超卖控制

1 MyBatis查询Apache Druid

我们java程序中可以通过JDBC查询ApacheDruid,但是需要引入avatica,Apache Calcite 是一个动态数据管理框架,用于 Hadoop 平台,1.10.0 增加了对 JDBC Array data, Docker, 和 JDK 9 的支持(同时支持JDK7、JDK8)。

https://druid.apache.org/docs/0.20.0/querying/sql.html#jdbc

1610676432289

我们项目中使用的是MyBatisPlus,我们需要做的是把JDBC操作Druid改成MyBatis操作ApacheDruid。关于JDBC的操作,我们就不在这里做过多讲解。

我们单独搭建一个工程mall-dw-service实现MyBatis集成ApacheDruid,所有Druid查询都用该工程操作。

1.1 API工程搭建

1)API

创建mall-dw-api,并创建查询Druid对应的JavaBean对象com.gupaoedu.vip.mall.dw.model.HotGoods

@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "mslogs")
public class HotGoods {
    //IP
    private String ip;
    //访问的uri
    private String uri;
    //时间.
    @TableField("__time")
    private Date accesstime;
}

1.2 Service工程搭建

搭建操作ApacheDruid的工程mall-dw-service,并完善该工程。

1)pom.xml

<dependencies>
    <!--API-->
    <dependency>
        <groupId>com.gupaoedu</groupId>
        <artifactId>mall-dw-api</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <!--操作Apache Druid-->
    <dependency>
        <groupId>org.apache.calcite.avatica</groupId>
        <artifactId>avatica</artifactId>
        <version>1.17.0</version>
    </dependency>

    <dependency>
        <groupId>org.apache.calcite.avatica</groupId>
        <artifactId>avatica-core</artifactId>
        <version>1.17.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.4</version>
    </dependency>
</dependencies>

2)bootstrap.yml

server:
  port: 8093
spring:
  application:
    name: mall-dw
  datasource:
    driver-class-name: org.apache.calcite.avatica.remote.Driver
    url: jdbc:avatica:remote:url=http://192.168.100.130:8082/druid/v2/sql/avatica/
    type: com.alibaba.druid.pool.DruidDataSource
  cloud:
    nacos:
      config:
        file-extension: yaml
        server-addr: 192.168.100.130:8848
      discovery:
        #Nacos的注册地址
        server-addr: 192.168.100.130:8848
# ====================MybatisPlus====================
mybatis:
  mapper-locations: mapper/*.xml
  type-aliases-package: com.gupaoedu.vip.mall.*.model
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

#日志配置
logging:
  pattern:
    console: "%msg%n"

此时需要注意,数据库连接地址和驱动和之前是存在差异,使用的地址是Apache druid地址,而驱动用的则是avatica驱动。

3)Dao

创建com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper

public interface HotGoodsMapper extends BaseMapper<HotGoods> {}

4)Service

接口:创建com.gupaoedu.vip.mall.dw.service.HotGoodsService

public interface HotGoodsService extends IService<HotGoods> {}

实现类:创建com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl

@Service
public class HotGoodsServiceImpl extends ServiceImpl<HotGoodsMapper,HotGoods> implements HotGoodsService {}

5)Controller

创建com.gupaoedu.vip.mall.dw.controller.HotGoodsController

@RestController
@RequestMapping(value = "/hot/goods")
public class HotGoodsController {}

6)启动类

创建启动类com.gupaoedu.vip.mall.dw.DwApplication,代码如下:

@SpringBootApplication
@MapperScan(basePackages = "com.gupaoedu.vip.mall.dw.mapper")
public class DwApplication {

    public static void main(String[] args) {
        SpringApplication.run(DwApplication.class,args);
    }
}

1.3 复杂查询实现

DruidSQL主要用于对Apache Druid实现查询操作,所以我们把各种可能用到的查询都实现一遍,比如查询所有、查询总记录数、分页查询、条件查询、排序等。

1.3.1 查询所有

查询所有其实只需要执行select * from mslogs就可以了。

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController添加查询所有方法:

@Autowired
private HotGoodsService hotGoodsService;

/***
 * 集合查询
 * @return
 */
@GetMapping
public RespResult<List<HotGoods>> list(){
    //集合查询
    List<HotGoods> goods = hotGoodsService.list();
    return RespResult.ok(goods);
}

请求http://localhost:8093/hot/goods效果如下:

1610677832423

1.3.2 查询前N条

查询前N条记录使用limit关键词,但limit关键词并不能单独实现分页。

1.Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper

/***
 * 查询前N条记录
 * @param size
 * @return
 */
@Select("select uri,__time as accesstime,ip from mslogs limit #{size}")
List<HotGoods> topNum(Integer size);

2.Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加查询前N条记录方法

List<HotGoods> topNum(Integer size);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl实现接口方法

/***
 * 查询前N条记录
 * @param size
 * @return
 */
@Override
public List<HotGoods> topNum(Integer size) {
    return hotGoodsMapper.topNum(size);
}

3.Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController调用查询方法

/***
 * 查询前N条记录
 * @return
 */
@GetMapping("/top/{size}")
public RespResult<List<HotGoods>> topNum(@PathVariable(value = "size")Integer size){
    //集合查询前N条
    List<HotGoods> hotGoods = hotGoodsService.topNum(size);
    return RespResult.ok(hotGoods);
}

访问http://localhost:8093/hot/goods/top/2测试效果如下:

1610680801205

1.3.3 分页查询

Druid分页查询需要用到offset实现,offset表示查询偏移量,说白了就是从指定下标数据查询,结合limit可以实现分页功能。

1)分页对象

创建com.gupaoedu.vip.mall.dw.util.DruidPage,在该类中实现分页offset计算和分页基本功能:

@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class DruidPage<T> {

    //中记录数
    private Integer total;
    //每页显示条数
    private Integer size;
    //当前页
    private Integer page;
    //偏移量
    private Long offset;
    //总页数
    private Integer totalPages;
    //数据集合
    private T data;
    //排序字段
    private String sort;
    //排序类型
    private String sortType;

    /***
     * 初始化并计算偏移量
     * @param sort
     * @param size
     * @param sortType
     * @param page
     */
    public DruidPage(Integer page,Integer size,String sort,String sortType) {
        this.size = size;
        this.page = page;
        this.sort = sort;
        this.sortType = sortType;

        if(page<=0){
            this.page = 1;
        }
        //计算偏移量
        this.offset =Long.valueOf((this.page-1)*size);
    }

    /***
     * 初始化并计算偏移量
     * @param size
     * @param page
     */
    public DruidPage(Integer page,Integer size) {
        this.size = size;
        this.page = page;

        if(page<=0){
            this.page = 1;
        }
        //计算偏移量
       this.offset =Long.valueOf((this.page-1)*size);
    }

    /***
     * 计算分页参数
     */
    public DruidPage<T> pages(T data,Integer total){
        //总记录数
        this.total = total;
        //数据
        this.data = data;
        //总页数
        if(this.total>0){
            this.totalPages = this.total%this.size==0? this.total/this.size : (this.total/this.size)+1;
        }else{
            this.totalPages = 0;
        }
        return this;
    }
}

2)Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper添加分页查询方法,代码如下:

/**
 * 分页查询
 * @return
 */
@Select("select uri,__time as accesstime,ip from mslogs limit #{size} offset #{offset}")
List<HotGoods> pageList(DruidPage druidPage);

3)Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加分页查询方法

DruidPage<List<HotGoods>> pageList(Integer page, Integer size);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl实现接口方法

//分页查询
@Override
public DruidPage<List<HotGoods>> pageList(Integer page, Integer size) {
    //创建分页
    DruidPage<List<HotGoods>> pageInfo = new DruidPage<List<HotGoods>>(page,size);
    //总记录数查询
    Integer total = hotGoodsMapper.selectCount(null);
    //集合查询
    List<HotGoods> hotGoods = hotGoodsMapper.pageList(pageInfo);
    return pageInfo.pages(hotGoods,total);
}

4)Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController调用分页查询

/***
 * 分页查询
 * @return
 */
@GetMapping("/{page}/{size}")
public RespResult<DruidPage<List<HotGoods>>> page(@PathVariable(value = "page")Integer page,
                                                  @PathVariable(value = "size")Integer size){
    //集合查询
    DruidPage<List<HotGoods>> pageInfo = hotGoodsService.pageList(page,size);
    return RespResult.ok(pageInfo);
}

访问http://localhost:8093/hot/goods/1/2测试效果如下:

1610681592209

1.3.4 分页+排序

排序的时候,我们会选择指定列和排序方式,因此需要传递2个参数过来,但是执行的SQL语句需要将他们拼接进去而不是作为一个变量,因此在MyBatis中可以考虑使用${}拼接,使用#{}传递变量。

1)Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper添加排序分页方法

/***
 * 排序+分页
 * @param pageInfo
 * @return
 */
@Select("select uri,__time as accesstime,ip from mslogs order by ${sort} ${sortType} limit #{size} offset #{offset}")
List<HotGoods> pageListSort(DruidPage<List<HotGoods>> pageInfo);

2)Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加分页排序方法:

DruidPage<List<HotGoods>> pageListSort(Integer page, Integer size, String sort, String sortType);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl添加实现方法

/***
 * 分页+排序
 * @param page
 * @param size
 * @param sort
 * @param sortType
 * @return
 */
@Override
public DruidPage<List<HotGoods>> pageListSort(Integer page, Integer size, String sort, String sortType) {
    //创建分页
    DruidPage<List<HotGoods>> pageInfo = new DruidPage<List<HotGoods>>(page,size,sort,sortType);
    //总记录数查询
    Integer total = hotGoodsMapper.selectCount(null);
    //集合查询
    List<HotGoods> hotGoods = hotGoodsMapper.pageListSort(pageInfo);
    return pageInfo.pages(hotGoods,total);
}

3)Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController添加排序查询方法

/***
 * 分页排序查询
 * @return
 */
@GetMapping("/{page}/{size}/{sort}/{type}")
public RespResult<DruidPage<List<HotGoods>>> page(@PathVariable(value = "page")Integer page,
                                                  @PathVariable(value = "size")Integer size,
                                                  @PathVariable(value = "sort")String sort,
                                                  @PathVariable(value = "type")String sortType){
    //集合查询
    DruidPage<List<HotGoods>> pageInfo = hotGoodsService.pageListSort(page,size,sort,sortType);
    return RespResult.ok(pageInfo);
}

请求http://localhost:8093/hot/goods/1/2/accesstime/asc测试,效果如下:

1610682672053

1.3.5 时间查询

我们查询1小时前或者1天前的数据,__time时间格式是TIMESTAMP,SQL中可以使用TIMESTAMP指定时间格式,比如我们要查询1小时前的数据,只需要计算出1小时前的时间,然后使用TIMESTAMP即可,例如:TIMESTAMP 2021-1-14 12:00:00

1)时间工具类

我们首先创建一个时间工具类com.gupaoedu.vip.mall.dw.util.TimeUtil,用时间工具类实现时间格式化:

public class TimeUtil {

    public static final String format1 = "yyyy-MM-dd HH:mm:ss";
    public static final String format2 = "yyyy-MM-dd";
    public static final String format3 = "yyyy年MM月dd日 HH时mm分ss秒";
    public static final String format4 = "yyyy年MM月dd日";
    public static final String unit_hour = "hour";
    public static final String unit_day = "day";

    /***
     * 当前时间增加N Unit
     */
    public static String beforeTime(String unit,Integer num){
        //1小时为单位
        long times = 3600000;
        if(unit.equalsIgnoreCase(unit_hour)){
            times=times*num;
        }else if(unit.equalsIgnoreCase(unit_day)){
            times=times*24*num;
        }
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat(format1);
        return simpleDateFormat.format( new Date(System.currentTimeMillis()-times));
    }
}

2)Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper添加时间查询方法:

/***
 * 搜索数据
 * @param size
 * @param time
 * @return
 */
@Select("select uri,__time as accesstime,ip from mslogs where __time>=TIMESTAMP '${time}' limit #{size}")
List<HotGoods> search(@Param("size") Integer size,@Param("time") String time);

3)Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加时间查询方法

List<HotGoods> search(Integer size, Integer hour);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl实现接口方法

/***
 * 查询历史数据
 * @param size
 * @param hour
 * @return
 */
@Override
public List<HotGoods> search(Integer size, Integer hour) {
    return hotGoodsMapper.search(size, TimeUtil.beforeTime(TimeUtil.unit_hour,hour));
}

4)Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController实现时间查询调用

/****
 * 指定时间的历史数据查询
 */
@GetMapping("/search/{size}/{hour}")
public RespResult<List<HotGoods>> history(@PathVariable(value = "size")Integer size,
                                          @PathVariable(value = "hour")Integer hour){
    //集合查询前N条
    List<HotGoods> hotGoods = hotGoodsService.search(size,hour);
    return RespResult.ok(hotGoods);
}

请求http://localhost:8093/hot/goods/search/2/20测试效果如下:

1610683848192

13.6 NOT IN

我们如果想排除某些数据可以使用not in,比如我们想排除部分uri就可以这么做。

1)Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper添加排除部分数据方法

/***
 * 数据搜索
 * @param size
 * @param time
 * @param urls
 * @return
 */
@Select("select uri,__time as accesstime,ip from mslogs where __time>=TIMESTAMP '${time}' and uri not in('${urls}') limit #{size}")
List<HotGoods> searchExclude(@Param("size") Integer size,@Param("time") String time,@Param("urls")String urls);

2)Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加排除部分数据方法:

List<HotGoods> search(Integer size, Integer hour, String[] ids);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl实现接口方法

/***
 * 排除指定uri数据
 * @param size
 * @param hour
 * @param urls
 * @return
 */
@Override
public List<HotGoods> search(Integer size, Integer hour, String[] urls) {
    String urlsJoin = StringUtils.join(urls, "','");
    return hotGoodsMapper.searchExclude(size, TimeUtil.beforeTime(TimeUtil.unit_hour,hour),urlsJoin);
}

3)Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController添加方法调用

/****
 * 指定时间的历史数据查询,排除指定数据
 */
@PostMapping("/search/{size}/{hour}")
public RespResult<List<HotGoods>> history(@PathVariable(value = "size")Integer size,
                                          @PathVariable(value = "hour")Integer hour,@RequestBody String[] ids){
    //集合查询前N条
    List<HotGoods> hotGoods = hotGoodsService.search(size,hour,ids);
    return RespResult.ok(hotGoods);
}

测试如下:

1610684112204

2 热门数据分离

1610438262742

如上图,是我们热门商品收集和分析流程,而收集已经实现,现在需要实现的是热门商品分析。

2.1 热门商品分析

1610691494237

热门商品分析我们可以在mall-seckill-service中使用elastic-job定时每10分钟查询mall-dw-service,将每小时内访问次数超过1万的商品定为热点商品,再将热点商品从数据库中查询出来并填充到Redis,但此时需要考虑到用户下单安全问题。

2.1.1 热门商品查询分析

我们需要编写一个方法,满足每小时商品访问频率超过1万的商品,我们下面写了个SQL语句:

SELECT 
	uri,count(*) as viewCount 
FROM mslogs 
WHERE 
	__time>=TIMESTAMP '2021-1-14 14:27:37' 
AND 
	uri NOT IN ('/msitems/100001956475.html','/msitems/1.html') 
GROUP BY uri 
HAVING viewCount>10000 
ORDER BY viewCount desc 
LIMIT 100

我们对上面这个语句进行分析:

SELECT 
	uri,count(*) as viewCount 
FROM mslogs 
WHERE 
	--【最近1小时内商品访问频率,时间=当前时间-1小时】
	__time>=TIMESTAMP '2021-1-14 14:27:37' 
AND 
	--【排除已经是热点商品的数据查询】
	uri NOT IN ('/msitems/100001956475.html','/msitems/1.html') 
--【统计每个商品访问的次数,而每个商品访问的uri其实就是区分不同商品,所以根据uri分组】
GROUP BY uri 
--【访问次数>1万的商品才是热门商品】
HAVING viewCount>10000 
--【根据访问次数倒序】
ORDER BY viewCount desc 
--【查询商品个数不要太多,如果太多可以做分页查询】
LIMIT 100

注意:分页查询作为大家的作业。

2.1.2 热门商品查询

按照上面的分析编写查询接口。

1)Dao

修改com.gupaoedu.vip.mall.dw.mapper.HotGoodsMapper增加查询热点商品方法:

/****
 * 分组、聚合判断、TopN、时间判断、排序
 * @param size
 * @param time
 * @param urls
 * @param max
 * @return
 */
@Select("SELECT uri,count(*) as viewCount FROM mslogs WHERE __time>=TIMESTAMP '${time}' AND uri NOT IN ('${urls}') GROUP BY uri HAVING viewCount>#{max} ORDER BY viewCount desc LIMIT #{size}")
List<Map<String,String>> searchHotGoods(@Param("size") Integer size,
                                        @Param("time") String time,
                                        @Param("urls")String urls,
                                        @Param("max")Integer max);

2)Service

接口:修改com.gupaoedu.vip.mall.dw.service.HotGoodsService添加查询热门商品方法

List<Map<String,String>> searchHotGoods(Integer size, Integer hour, String[] urls, Integer max);

实现类:修改com.gupaoedu.vip.mall.dw.service.impl.HotGoodsServiceImpl实现接口方法

/***
 * 热门商品查询
 * @param size:TopN
 * @param hour:N小时前数据统计
 * @param urls:排除之前判断的热点商品
 * @param max:访问频率超过max作为统计条件
 * @return
 */
@Override
public List<Map<String,String>> searchHotGoods(Integer size, Integer hour, String[] urls, Integer max) {
    String urlsJoin = StringUtils.join(urls, "','");
    return hotGoodsMapper.searchHotGoods(size, TimeUtil.beforeTime(TimeUtil.unit_hour,hour),urlsJoin,max);
}

3)Controller

修改com.gupaoedu.vip.mall.dw.controller.HotGoodsController实现方法调用

/****
 * 热点商品查询
 */
@PostMapping("/search/{size}/{hour}/{max}")
public RespResult<List<Map<String,String>>> searchHot(
                                            @PathVariable(value = "size")Integer size,
                                            @PathVariable(value = "hour")Integer hour,
                                            @PathVariable(value = "max")Integer max,
                                            @RequestBody(required = false) String[] ids){
    //集合查询前N条
    List<Map<String,String>> hotGoods = hotGoodsService.searchHotGoods(size,hour,ids,max);
    return RespResult.ok(hotGoods);
}

测试效果如下:

1610694860297

4)Feign

我们需要在mall-seckill-service中调用该方法,因此需要创建feign接口。

创建com.gupaoedu.vip.mall.dw.feign.HotGoodsFeign代码如下:

@FeignClient(value = "mall-dw")
public interface HotGoodsFeign {

    /****
     * 热点商品查询
     */
    @PostMapping("/hot/goods/search/{size}/{hour}/{max}")
    public RespResult<List<Map<String,String>>> searchHot(
            @PathVariable(value = "size")Integer size,
            @PathVariable(value = "hour")Integer hour,
            @PathVariable(value = "max")Integer max,
            @RequestBody(required = false) String[] ids);
}

2.2 热门商品分离

我们先编写定时查询热门商品,再实现热门商品隔离,此时我们要引入mall-dw-api依赖,并且需要引入elasticjob

mall-seckill-service的pom.xml中引入依赖:

<dependencies>
    <dependency>
        <groupId>com.gupaoedu</groupId>
        <artifactId>mall-seckill-api</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <!-- ElasticJobAutoConfiguration自动配置类作用-->
    <dependency>
        <groupId>com.github.kuhn-he</groupId>
        <artifactId>elastic-job-lite-spring-boot-starter</artifactId>
        <version>2.1.5</version>
    </dependency>

    <dependency>
        <groupId>com.gupaoedu</groupId>
        <artifactId>mall-dw-api</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>
</dependencies>

2.2.1 定时查询

mall-seckill-service中实现定时查询druid中的数据,我们可以创建定时任务类com.gupaoedu.vip.mall.seckill.task.DiscoverHotGoods,在类中定时查询druid数据:

@ElasticSimpleJob(
        jobName = "${elaticjob.zookeeper.namespace}",
        shardingTotalCount = 1,
        cron = "0/10 * * * * ? *"
)
@Component
public class DiscoverHotGoods implements SimpleJob {

    @Autowired
    private HotGoodsFeign hotGoodsFeign;

    //热门数据条件
    @Value("${hot.size}")
    private Integer size;
    @Value("${hot.hour}")
    private Integer hour;
    @Value("${hot.max}")
    private Integer max;

    @Override
    public void execute(ShardingContext shardingContext) {
        //远程调用
        String[] ids={};
        RespResult<List<Map<String, String>>> listRespResult = hotGoodsFeign.searchHot(size, hour, max, ids);
        //集合数据获取
        List<Map<String, String>> listData = listRespResult.getData();
        //结果信息
        for (Map<String, String> dataMap : listData) {
            //处理请求路径
            String uri =uriReplace( dataMap.get("uri") , 1);
            System.out.println("查询到的商品ID:"+uri);
        }
    }

    /***
     * uri处理
     * @param uri
     * @param type:1 uri表示商品请求路径, 2 uri表示商品ID
     * @return
     */
    public String uriReplace(String uri,Integer type){
        switch (type){
            case 1:
                uri=uri.replace("/msitems/","").replace(".html","");
                break;
            case 2:
                uri="/msitems/"+uri+".html";
                break;
            default:
                    uri="/msitems/"+uri+".html";
        }
        return uri;
    }
}

上面的时间和查询数量等信息我们可以配置在bootstrap.yml中:

spring:
  redis:
    host: 192.168.100.130
    port: 6379
elaticjob:
  zookeeper:
    server-lists: 192.168.100.130:2181
    namespace: hotsync
hot:
  #查询条数
  size: 100
  #N小时的数据
  hour: 1
  #每小时查询超过max次
  max: 1

2.2.2 数据分离

数据隔离也就是将数据存储到处理数据非常快的存储器中,我们选择了Redis,数据存储到Redis后,下次查询的时候,应该将他们从查询条件中排除掉。

1)Service

接口:修改mall-seckill-servicecom.gupaoedu.vip.mall.seckill.service.SeckillGoodsService接口,添加秒杀商品隔离方法:

/***
 * 隔离
 * @param uri
 */
void isolation(String uri);

实现类:修改com.gupaoedu.vip.mall.seckill.service.impl.SeckillGoodsServiceImpl添加接口实现方法:

@Autowired
private RedisTemplate redisTemplate;

/***
 * 隔离
 * @param uri(id)
 */
@Override
public void isolation(String uri) {
    //锁定
    QueryWrapper<SeckillGoods> seckillGoodsQueryWrapper = new QueryWrapper<SeckillGoods>();
    seckillGoodsQueryWrapper.eq("islock",0);
    seckillGoodsQueryWrapper.eq("id",uri);
    seckillGoodsQueryWrapper.gt("store_count",0);
    SeckillGoods seckillGoods = new SeckillGoods();
    seckillGoods.setIslock(1);
    int update = seckillGoodsMapper.update(seckillGoods, seckillGoodsQueryWrapper);
    if(update>0){
        //数据存入缓存隔离(需要控制集群环境问题,所以定时任务分片只设置成1个分片)
        seckillGoods = seckillGoodsMapper.selectById(uri);
        redisTemplate.boundHashOps("HotSeckillGoods").increment(uri,seckillGoods.getStoreCount());
    }
}

2)定时任务

定时任务中先查询已经存在Redis中的热门商品,再将ID处理好,传到searchHot方法中用于排除,代码如下:

1610715014078

上图源码如下:

@ElasticSimpleJob(
        jobName = "${elaticjob.zookeeper.namespace}",
        shardingTotalCount = 1,
        cron = "0/10 * * * * ? *"
)
@Component
public class DiscoverHotGoods implements SimpleJob {

    @Autowired
    private HotGoodsFeign hotGoodsFeign;

    @Autowired
    private SeckillGoodsService seckillGoodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    //热门数据条件
    @Value("${hot.size}")
    private Integer size;
    @Value("${hot.hour}")
    private Integer hour;
    @Value("${hot.max}")
    private Integer max;

    @Override
    public void execute(ShardingContext shardingContext) {
        //远程调用
        String[] ids=isolationList();
        RespResult<List<Map<String, String>>> listRespResult = hotGoodsFeign.searchHot(size, hour, max, ids);
        //集合数据获取
        List<Map<String, String>> listData = listRespResult.getData();
        //结果信息
        for (Map<String, String> dataMap : listData) {
            //处理请求路径
            String uri =uriReplace( dataMap.get("uri") , 1);
            //隔离
            seckillGoodsService.isolation(uri);
        }
    }

    /***
     * 查询已经被隔离的热点商品
     */
    public String[] isolationList(){
        //获取所有已经被隔离的热门商品ID
        Set<String> ids = redisTemplate.boundHashOps("HotSeckillGoods").keys();
        String[] allids =new String[ids.size()];
        ids.toArray(allids);
        //uri地址处理
        for (int i = 0; i < allids.length; i++) {
            allids[i] = uriReplace(allids[i],2);
        }
        return allids;
    }


    /***
     * uri处理
     * @param uri
     * @param type:1 uri表示商品请求路径, 2 uri表示商品ID
     * @return
     */
    public String uriReplace(String uri,Integer type){
        switch (type){
            case 1:
                uri=uri.replace("/msitems/","").replace(".html","");
                break;
            case 2:
                uri="/msitems/"+uri+".html";
                break;
            default:
                    uri="/msitems/"+uri+".html";
        }
        return uri;
    }
}

3 抢单程序分离

抢单程序隔离分为热门商品抢单和非热门商品抢单,我们先对抢单流程进行分析,再对抢单流程做出实现。

3.1 抢单流程分析

1610721829448

如上图,我们对抢单流程做一个分析:

1:抢单经过Gateway,在Gateway中判断商品是否是热门商品
2:如果是热门商品,就排队,同时记录用户排队标识,防止重复排队
3:如果是冷门商品,则直接执行抢单操作
4:热门商品需要在抢单程序中订阅消费排队信息,并执行下单

排队的作用是防止服务器同时间处理所有并发请求,我们将并发请求放到队列中,这种操作就是知名的队列削峰模式。

3.2 热门商品抢单

热门商品抢单,我们需要执行4个操作:

1:搭建微服务网关
2:在微服务网关中判断商品是否是热门商品
3:如果是热门商品,则执行排队
4:在秒杀工程中执行监听

3.2.1 Gateway搭建

创建mall-api-gateway微服务网关工程,在该工程中实现路由功能和热门商品抢购排队功能。

引入pom.xml依赖:(mall-api-gateway或者mall-gateway中引入依赖)

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
        <version>2.2.1.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
        <version>2.2.1.RELEASE</version>
    </dependency>
    <!--alibaba-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
</dependencies>

bootstrap.yml

server:
  port: 9001
spring:
  application:
    name: mall-gateway
  cloud:
    nacos:
      config:
        file-extension: yaml
        server-addr: 192.168.100.130:8848
      discovery:
        #Nacos的注册地址
        server-addr: 192.168.100.130:8848
    gateway:
      routes:
        #秒杀
        - id: seckill_route
          uri: lb://mall-seckill
          predicates:
            - Path=/mall/**
          filters:
            - StripPrefix=1
logging:
  pattern:
    console: "%msg%n"

创建第1个过滤器:com.gupaoedu.vip.mall.api.filter.RequestFilter,代码如下:

@Configuration
public class RequestFilter implements GlobalFilter, Ordered {

    /***
     * 拦截所有请求
     * @param exchange
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

创建启动类com.gupaoedu.vip.mall.api.ApiGatewayApplication

@SpringBootApplication
public class ApiGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class,args);
    }
}

3.2.2 热门商品抢单

热门商品抢单分为2个步骤:

1:需要识别商品是否为热门商品,如果是热门商品则需要排队,排队需要向RocketMQ发送MQ消息。
2:下单需要从排队信息中获取抢单信息,并执行下单操作。

接下来我们按这2个步骤实现。

3.2.2.1 抢单排队

排队的时间节点很重要,不要在后端微服务排队,后端服务排队会降低整个服务的性能,我们可以选择在代理层排队(Nginx)或者Api网关层排队(Gateway),由于大家的专长是Java,我们选择在API网关排队。

1611539718656

1)引入依赖

mall-api-gateway的pom.xml中引入如下依赖:

<dependencies>
    <!--Redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!--rocketmq-->
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <version>2.0.2</version>
    </dependency>
</dependencies>

2)MQ、Redis配置

修改mall-api-gateway的bootstrap.yml文件,添加RocketMQ和Redis配置:

spring:
  redis:
    host: 192.168.100.130
    port: 6379
#producer
rocketmq:
  name-server: 192.168.100.130:9876
  producer:
    group: hotorder-group
    send-message-timeout: 300000
    compress-message-body-threshold: 4096
    max-message-size: 4194304
    retry-times-when-send-async-failed: 0
    retry-next-server: true
    retry-times-when-send-failed: 2

3)热门商品排队

mall-api-gateway中创建com.gupaoedu.vip.mall.api.hot.HotQueue在该类中进行排队操作:

@Component
public class HotQueue {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    //商品非热门
    public static final Integer NOT_HOT=0;
    //已经在排队中
    public static final Integer HAS_QUEUE=204;
    //排队成功
    public static final Integer QUEUE_ING=200;


    /***
     * 抢单排队
     * username:用户名
     * id:商品ID
     * num:件数
     */
    public int hotToQueue(String username,String id,Integer num){
        //获取该商品在Redis中的信息,如果Redis中存在对应信息,热门商品
        Boolean bo = redisTemplate.boundHashOps("HotSeckillGoods").hasKey(id);
        if(!bo){
            //商品非热门
            return NOT_HOT;
        }
        //避免重复排队
        Long increment = redisTemplate.boundValueOps("OrderQueue" + username).increment(1);
        if(increment>1){
            //请勿重新排队
            return HAS_QUEUE;
        }

        //执行排队操作
        Map<String,Object> dataMap = new HashMap<String,Object>();
        dataMap.put("username",username);
        dataMap.put("id",id);
        dataMap.put("num",num);
        Message<String> message = MessageBuilder.withPayload(JSON.toJSONString(dataMap)).build();
        rocketMQTemplate.convertAndSend("order-queue",message);
        return QUEUE_ING;
    }
}

ApiFilter中调用上面的排队方法:

@Autowired
private HotQueue hotQueue;

/***
 * 执行拦截处理      http://localhost:9001/mall/seckill/order?id&num
 *                 JWT
 * @param exchange
 * @param chain
 * @return
 */
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    //用户名
    String username = "gp";
    //商品ID
    String id = request.getQueryParams().getFirst("id");
    //数量
    Integer num =Integer.valueOf( request.getQueryParams().getFirst("num") );

    //排队结果
    int result = hotQueue.hotToQueue(username, id, num);

    //QUEUE_ING、HAS_QUEUE
    if(result==HotQueue.QUEUE_ING || result==HotQueue.HAS_QUEUE){
        //响应状态码200
        Map<String,Object> resultMap = new HashMap<String,Object>();
        resultMap.put("type","hot");
        resultMap.put("code",result);
        exchange.getResponse().setStatusCode(HttpStatus.OK);
        exchange.getResponse().setComplete();
        exchange.getResponse().getHeaders().add("message",JSON.toJSONString(resultMap));
    }

    //NOT_HOT 直接由后端服务处理
    return chain.filter(exchange);
}
3.2.2.2 排队监听

用户排队后,我们需要对用户排队的信息进行下单操作,此时需要监听排队信息,我们在mall-seckill-service中编写监听操作,用于监听消息。

1)引入依赖

修改mall-seckill-service中的pom.xml添加rocketmq依赖:

<!--rocketmq-->
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.0.2</version>
</dependency>

2)配置MQ信息

修改bootstrap.yml添加rocketmq配置:

#mq
rocketmq:
  name-server: 192.168.100.130:9876

3)监听创建

创建com.gupaoedu.vip.mall.seckill.mq.OrderQueueListener实现对排队抢单信息监听:

@RocketMQMessageListener(
        topic = "order-queue", 					//topic:和消费者发送的topic相同
        consumerGroup = "orderqueue-consumer",     //group:不用和生产者group相同
        selectorExpression = "*") 			    //tag
@Component
public class OrderQueueListener implements RocketMQListener {

    /***
     * 排队信息
     * @param message
     */
    @Override
    public void onMessage(Object message) {
        System.out.println("排队信息:"+message);
    }
}
3.2.2.3 热门商品抢单实现

抢单实现我们需要监听队列信息,并实现下单操作,同时如果抢单后商品库存为0,则需要同步到数据库中。

1)Service

接口:修改com.gupaoedu.vip.mall.seckill.service.SeckillOrderService添加热门商品下单方法:

/***
 * 热门商品抢单操作
 */
int add(Map<String,Object> dataMap);

实现类:修改com.gupaoedu.vip.mall.seckill.service.impl.SeckillOrderServiceImpl实现热门商品下单:

@Service
public class SeckillOrderServiceImpl extends ServiceImpl<SeckillOrderMapper,SeckillOrder> implements SeckillOrderService {

    //库存不足
    public static final int STORE_NOT_FULL=0;
    //库存足够下单成功
    public static final int STORE_FULL_ORDER_SUCCESS=1;


    @Autowired
    private SeckillOrderMapper seckillOrderMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;


    /***
     * 热门商品抢单实现
     * @return
     */
    @Override
    public int add(Map<String,Object> dataMap) {
        //username
        String username = dataMap.get("username").toString();
        //id
        String id = dataMap.get("id").toString();
        //num
        Integer num =Integer.valueOf(dataMap.get("num").toString() );

        /**
         * 库存足够
         */
        Object storecount = redisTemplate.boundHashOps("HotSeckillGoods").get(id);
        if(storecount==null || Integer.valueOf(storecount.toString())<num){
            //移除排队标识
            redisTemplate.delete("OrderQueue"+username);
            return STORE_NOT_FULL;
        }

        //查询商品信息
        SeckillGoods seckillGoods = seckillGoodsMapper.selectById(id);

        /***
         * 添加订单
         */
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUsername(username);
        seckillOrder.setSeckillGoodsId(id);
        seckillOrder.setCreateTime(new Date());
        seckillOrder.setMoney(seckillGoods.getSeckillPrice()*num);
        seckillOrder.setNum(num);
        seckillOrder.setStatus(0);  //下单了
        seckillOrderMapper.insert(seckillOrder);

        /*****
         * 库存递减
         */
        Long lastStoreCount = redisTemplate.boundHashOps("HotSeckillGoods").increment(id, -num);

        if(lastStoreCount==0){
            //将数据同步到数据库
            seckillGoods = new SeckillGoods();
            seckillGoods.setId(id);
            seckillGoods.setStoreCount(0);
            //将当前商品添加到Redis布隆过滤器->作业->用户下次抢购该商品,去布隆过滤器中判断该商品是否在布隆过滤器中,如果在,则表明售罄
            seckillGoodsMapper.updateById(seckillGoods);
            //删除Redis缓存
            redisTemplate.boundHashOps("HotSeckillGoods").delete(id);
        }
        //移除排队标识
        redisTemplate.delete("OrderQueue"+username);

        return STORE_FULL_ORDER_SUCCESS;
    }
}
3.2.2.4 抢单超卖控制

在热门商品抢单过程中,存在超卖现象,即便是MQ监听下单,但MQ消费组有可能会有集群,各个组集群消费时,判断商品库存有可能同时出现判断商品库存为1的时候,这时候就容易存在超卖现象。

我们可以采用基于Redis的分布式锁来实现。

1)引入依赖包

<!--Redisson分布式锁-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.8.2</version>
</dependency>

2)创建RedissonClient

创建com.gupaoedu.vip.mall.seckill.lock.RedissonConfig用于配置当前使用的Redis服务

@Configuration
public class RedissonConfig {

    /***
     * 创建RedissonClient客户端
     * @return
     */
    public RedissonClient redisson(){
        Config config = new Config();
        //单机模式
        config.useSingleServer().setAddress("redis://192.168.100.130:6379");
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}

3)超卖控制

修改com.gupaoedu.vip.mall.seckill.service.impl.SeckillOrderServiceImpl的热门商品抢单方法,增加分布式锁控制:

@Autowired
private RedissonClient redissonClient;


/***
 * 热门商品抢单实现
 * @return
 */
@Override
public int add(Map<String,Object> dataMap) {
    //username
    String username = dataMap.get("username").toString();
    //id
    String id = dataMap.get("id").toString();
    //num
    Integer num =Integer.valueOf(dataMap.get("num").toString() );

    //获取锁
    RLock lock = redissonClient.getLock("No00001");
    lock.lock();

    try {
        /**
         * 库存足够  略...
         */

        //略...

        if(lastStoreCount==0){
            //略...
        }
        //移除排队标识
        redisTemplate.delete("OrderQueue"+username);
        //释放锁
        lock.unlock();
    } catch (NumberFormatException e) {
        //释放锁
        lock.unlock();
    }
    return STORE_FULL_ORDER_SUCCESS;
}

完整代码如下:

@Autowired
private RedissonClient redissonClient;


/***
 * 热门商品抢单实现
 * @return
 */
@Override
public int add(Map<String,Object> dataMap) {
    //username
    String username = dataMap.get("username").toString();
    //id
    String id = dataMap.get("id").toString();
    //num
    Integer num =Integer.valueOf(dataMap.get("num").toString() );

    //获取锁
    RLock lock = redissonClient.getLock("No00001");
    lock.lock();

    try {
        /**
         * 库存足够
         */
        Object storecount = redisTemplate.boundHashOps("HotSeckillGoods").get(id);
        if(storecount==null || Integer.valueOf(storecount.toString())<num){
            //移除排队标识
            redisTemplate.delete("OrderQueue"+username);
            return STORE_NOT_FULL;
        }

        //查询商品信息
        SeckillGoods seckillGoods = seckillGoodsMapper.selectById(id);

        /***
         * 添加订单
         */
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUsername(username);
        seckillOrder.setSeckillGoodsId(id);
        seckillOrder.setCreateTime(new Date());
        seckillOrder.setMoney(seckillGoods.getSeckillPrice()*num);
        seckillOrder.setNum(num);
        seckillOrder.setStatus(0);  //下单了
        seckillOrderMapper.insert(seckillOrder);

        /*****
         * 库存递减
         */
        Long lastStoreCount = redisTemplate.boundHashOps("HotSeckillGoods").increment(id, -num);

        if(lastStoreCount==0){
            //将数据同步到数据库
            seckillGoods = new SeckillGoods();
            seckillGoods.setId(id);
            seckillGoods.setStoreCount(0);
            //将当前商品添加到Redis布隆过滤器->作业->用户下次抢购该商品,去布隆过滤器中判断该商品是否在布隆过滤器中,如果在,则表明售罄
            seckillGoodsMapper.updateById(seckillGoods);
            //删除Redis缓存
            redisTemplate.boundHashOps("HotSeckillGoods").delete(id);
        }
        //移除排队标识
        redisTemplate.delete("OrderQueue"+username);
        lock.unlock();
    } catch (NumberFormatException e) {
        lock.unlock();
    }
    return STORE_FULL_ORDER_SUCCESS;
}
上次编辑于:
贡献者: soulballad