软件资源-----黑马秒杀

发布于:2024-10-17 ⋅ 阅读:(155) ⋅ 点赞:(0)

黑马秒杀: 黑马秒杀主要包括商品抢购、订单管理、活动管理以及用户管理等功能。该项目基于微服务架构,结合大数据分析,共同打造亿级流量、百万并发、高可用秒杀解决方案。 (gitee.com)

黑马秒杀主要包括商品抢购、订单管理、活动管理以及用户管理等功能。该项目基于微服务架构,结合大数据分析,共同打造亿级流量、百万并发、高可用秒杀解决方案。

第1章 秒杀商品维护

目标1:秒杀流程设计讲解

目标2:秒杀表结构讲解

目标3:秒杀工程导入

目标4:秒杀索引导入和搜索【elasticsearch】

目标5:商品详情页【freemarker静态化】

目标6:Canal增量数据同步利器讲解

目标7:使用Canal实现增量数据实时同步索引、静态页

1 秒杀设计

1.1 业务流程

电商项目中,秒杀属于技术挑战最大的业务。后台可以发布秒杀商品后或者将现有商品列入秒杀商品,热点分析系统会对商品进行分析,对热点商品做特殊处理。商城会员可以在秒杀活动开始的时间内进行抢购,抢购后可以在线进行支付,支付完成的订单由平台工作人员发货,超时未支付订单会自动取消。

 当前秒杀系统中一共涉及到管理员后台、搜索系统、秒杀系统、抢单流程系统、热点数据发现系统,如下图:

1.2 秒杀架构

B2B2C商城秒杀商品数据一般都是非常庞大,流量特别高,尤其是双十一等节日,所以设计秒杀系统,既要考虑系统抗压能力,也要考虑系统数据存储和处理能力。秒杀系统虽然流量特别高,但往往高流量抢购的商品为数不多,因此我们系统还需要对抢购热门的商品进行有效识别。

商品详情页的内容除了数量变更频率较高,其他数据基本很少发生变更,像这类变更频率低的数据,我们可以考虑采用模板静态化技术处理。

秒杀系统需要考虑抗压能力,编程语言的选择也有不少讲究。项目发布如果采用Tomcat,单台Tomcat抗压能力能调整到大约1000左右,占用资源较大。Nginx抗压能力轻飘的就能到5万,并且Nginx占用资源极小,运行稳定。如果单纯采用Java研发秒杀系统,用Tomcat发布项目,在抗压能力上显然有些不足,如果采用Lua脚本开发量大的功能,采用Nginx+Lua处理用户的请求,那么并发处理能力将大大提升。

下面是当前秒杀系统的架构图:

1.3 数据库设计

1.3.1 秒杀商品数据库

数据库名字:seckill_goods

秒杀活动表:tb_activity

CREATE TABLE `tb_activity` (
  `id` varchar(60) NOT NULL,
  `name` varchar(100) NOT NULL,
  `status` int(2) NOT NULL DEFAULT '1' COMMENT '状态:1开启,2未开启',
  `startdate` date DEFAULT NULL,
  `begintime` datetime DEFAULT NULL COMMENT '开始时间,单位:时分秒',
  `endtime` datetime DEFAULT NULL COMMENT '结束时间,单位:时分秒',
  `total_time` float DEFAULT NULL,
  `is_del` int(1) DEFAULT '1' COMMENT '删除:1未删除,2已删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

商品品牌表:tb_brand

CREATE TABLE `tb_brand` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
  `name` varchar(100) NOT NULL COMMENT '品牌名称',
  `image` varchar(1000) DEFAULT '' COMMENT '品牌图片地址',
  `letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
  `seq` int(11) DEFAULT NULL COMMENT '排序',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325468 DEFAULT CHARSET=utf8 COMMENT='品牌表';

秒杀商品分类表:tb_category

CREATE TABLE `tb_category` (
  `id` int(20) NOT NULL AUTO_INCREMENT COMMENT '分类ID',
  `name` varchar(50) DEFAULT NULL COMMENT '分类名称',
  `goods_num` int(11) DEFAULT '0' COMMENT '商品数量',
  `is_show` char(1) DEFAULT NULL COMMENT '是否显示',
  `is_menu` char(1) DEFAULT NULL COMMENT '是否导航',
  `seq` int(11) DEFAULT NULL COMMENT '排序',
  `parent_id` int(20) DEFAULT NULL COMMENT '上级ID',
  `template_id` int(11) DEFAULT NULL COMMENT '模板ID',
  PRIMARY KEY (`id`),
  KEY `parent_id` (`parent_id`)
) ENGINE=InnoDB AUTO_INCREMENT=11177 DEFAULT CHARSET=utf8 COMMENT='商品类目';

秒杀时刻表:tb_seckill_time

CREATE TABLE `tb_seckill_time` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL COMMENT '秒杀分类名字,双十一秒杀,每日时段秒杀等',
  `starttime` time NOT NULL COMMENT '开始时间',
  `endtime` time DEFAULT NULL,
  `total_time` float(11,2) DEFAULT NULL COMMENT '秒杀时长,按小时计算',
  `status` int(2) DEFAULT '1' COMMENT '是否启用,1:启用,2:停用',
  `sort` int(2) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8;

秒杀商品表:tb_sku

CREATE TABLE `tb_sku` (
  `id` varchar(60) NOT NULL COMMENT '商品id',
  `name` varchar(200) NOT NULL COMMENT 'SKU名称',
  `price` int(20) NOT NULL DEFAULT '1' COMMENT '价格(分)',
  `seckill_price` int(20) DEFAULT NULL COMMENT '单位,分',
  `num` int(10) DEFAULT '100' COMMENT '库存数量',
  `alert_num` int(11) DEFAULT NULL COMMENT '库存预警数量',
  `image` varchar(200) DEFAULT NULL COMMENT '商品图片',
  `images` varchar(2000) DEFAULT NULL COMMENT '商品图片列表',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `spu_id` varchar(60) DEFAULT NULL COMMENT 'SPUID',
  `category1_id` int(10) DEFAULT NULL COMMENT '类目ID',
  `category2_id` int(10) DEFAULT NULL,
  `category3_id` int(10) DEFAULT NULL,
  `category1_name` varchar(20) DEFAULT NULL,
  `category2_name` varchar(20) DEFAULT NULL,
  `category3_name` varchar(20) DEFAULT NULL COMMENT '类目名称',
  `brand_id` int(11) DEFAULT NULL,
  `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称',
  `spec` varchar(200) DEFAULT NULL COMMENT '规格',
  `sale_num` int(11) DEFAULT '0' COMMENT '销量',
  `comment_num` int(11) DEFAULT '0' COMMENT '评论数',
  `seckill_end` datetime DEFAULT NULL COMMENT '秒杀结束时间',
  `seckill_begin` datetime DEFAULT NULL COMMENT '秒杀开始时间',
  `status` int(1) DEFAULT '1' COMMENT '商品状态 1普通商品,2参与秒杀',
  `islock` int(1) DEFAULT '1' COMMENT '是否锁定,1:未锁定,2:锁定',
  `seckill_num` int(11) DEFAULT NULL COMMENT '秒杀数量',
  PRIMARY KEY (`id`),
  KEY `cid` (`category1_id`),
  KEY `status` (`status`),
  KEY `updated` (`update_time`),
  KEY `spu_id` (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品表';

秒杀商品活动关联表:tb_sku_act

CREATE TABLE `tb_sku_act` (
  `sku_id` varchar(60) NOT NULL,
  `activity_id` varchar(60) NOT NULL,
  PRIMARY KEY (`sku_id`,`activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1.3.2 秒杀订单数据库

秒杀订单表:tb_order

CREATE TABLE `tb_order` (
  `id` varchar(60) COLLATE utf8_bin NOT NULL COMMENT '订单id',
  `total_num` int(11) DEFAULT NULL COMMENT '数量合计',
  `pay_type` varchar(1) COLLATE utf8_bin DEFAULT NULL COMMENT '支付类型,1、在线支付、0 货到付款',
  `create_time` datetime DEFAULT NULL COMMENT '订单创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '订单更新时间',
  `pay_time` datetime DEFAULT NULL COMMENT '付款时间',
  `consign_time` datetime DEFAULT NULL COMMENT '发货时间',
  `end_time` datetime DEFAULT NULL COMMENT '交易完成时间',
  `close_time` datetime DEFAULT NULL COMMENT '交易关闭时间',
  `receiver_contact` varchar(50) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人',
  `receiver_mobile` varchar(12) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人手机',
  `receiver_address` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人地址',
  `transaction_id` varchar(30) COLLATE utf8_bin DEFAULT NULL COMMENT '交易流水号',
  `order_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '订单状态,0:未完成,1:已完成,2:已退货',
  `pay_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '支付状态,0:未支付,1:已支付,2:支付失败',
  `consign_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '发货状态,0:未发货,1:已发货,2:已收货',
  `is_delete` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '是否删除',
  `sku_id` varchar(60) COLLATE utf8_bin DEFAULT NULL,
  `name` varchar(200) COLLATE utf8_bin DEFAULT NULL,
  `price` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `create_time` (`create_time`),
  KEY `status` (`order_status`),
  KEY `payment_type` (`pay_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
1.3.3 管理员数据库

管理员表:tb_admin

CREATE TABLE `tb_admin` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `login_name` varchar(20) DEFAULT NULL COMMENT '用户名',
  `password` varchar(60) DEFAULT NULL COMMENT '密码',
  `status` char(1) DEFAULT NULL COMMENT '状态',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
1.3.4 用户数据库

用户表:tb_user

CREATE TABLE `tb_user` (
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '密码,加密存储',
  `phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
  `email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',
  `created` datetime NOT NULL COMMENT '创建时间',
  `updated` datetime NOT NULL COMMENT '修改时间',
  `nick_name` varchar(50) DEFAULT NULL COMMENT '昵称',
  `name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
  PRIMARY KEY (`username`),
  UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

收件地址表:tb_address

CREATE TABLE `tb_address` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL COMMENT '用户名',
  `provinceid` varchar(20) DEFAULT NULL COMMENT '省',
  `cityid` varchar(20) DEFAULT NULL COMMENT '市',
  `areaid` varchar(20) DEFAULT NULL COMMENT '县/区',
  `phone` varchar(20) DEFAULT NULL COMMENT '电话',
  `address` varchar(200) DEFAULT NULL COMMENT '详细地址',
  `contact` varchar(50) DEFAULT NULL COMMENT '联系人',
  `is_default` varchar(1) DEFAULT NULL COMMENT '是否是默认 1默认 0否',
  `alias` varchar(50) DEFAULT NULL COMMENT '别名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2 项目介绍

2.1 技术栈介绍

2.2 项目结构

 此次项目并不打算从0-1的去研发,我们打算遵循企业开发模式,从1-2的方式进行研发,所以这里将直接导入已经搭建好的工程。

2.3 项目导入

项目导入后,结构如下:

 由于我们注册中心使用的是Nacos,数据库用的是MySQL,数据搜索Elasticsearch,所以当前开发环境配置的虚拟机已经直接安装好了这些软件,采用了docker的安装方式。

虚拟机:

IP:192.168.211.137
账号:root
密码:123456

MySQL:
    端口:3306
    账号:root
    密码:123456

Elasticsearch:
    TCP:9300
    Http:9200

Elastic-head:
    Http:9100
    
//..略

2.4 工程案例

由于秒杀系统回归了企业开发,采用了1-2的模式,我们例举一下增删改查在当前项目中的一个案例。

项目中相关服务地址采用了别名,需要将别名配置到C:\Windows\System32\drivers\etc\hosts文件中:

192.168.211.137 db-server
192.168.211.137 redis-server
192.168.211.137 zk-server
192.168.211.137 kafka-server
192.168.211.137 nacos-server
192.168.211.137 es-server
192.168.211.137 canal-server
192.168.211.137 seata-server

我们找一个项目中的案例,梳理和解读一下项目的访问流程,流程图如下:

在代码中,我们讲解一下用户查询所有品牌的案例:

请求路径:/api/brand

调用:

seckill-gateway

seckill-service/seckill-goods/BrandController.findAll()

代码图解:

3 索引库数据管理{#3-索引库数据管理}

秒杀商品数量庞大,我们要想实现快速检索,不建议直接使用关系型数据库查找。不建议使用Redis缓存所有数据,因为秒杀商品量大,会影响Redis的性能,并且Redis的条件检索能力偏弱。我们可以使用Elasticsearch,它在海量数据存储与检索上,能力卓越,市场使用面广。

3.1 批量导入

我们需要将秒杀商品数据导入到ES索引库中,但秒杀商品数量庞大,所以我们应该分页查询并导入,流程如下:

1)service总数量查询

我们先在seckill-goods中编写相关方法实现数据查询,因为要用到分页,所以先查询总数量,然后再实现分页集合查询。

seckill-goodscom.seckill.goods.service.SkuService中添加count方法,用于查询秒杀商品总数量:

/**
 * 总数量加载
 * @return
 */
Integer count();

seckill-goodscom.seckill.goods.service.impl.SkuServiceImpl中添加count方法,用于实现查询秒杀商品总数量:

/**
 * 总数量加载
 * @return
 */
@Override
public Integer count() {
    Example example = new Example(Sku.class);
    Example.Criteria criteria = example.createCriteria();
    //秒杀剩余商品数量>0
    criteria.andGreaterThan("seckillNum",0);
    //状态为参与秒杀,1:普通商品,2:参与秒杀
    criteria.andEqualTo("status","2");
    //秒杀结束时间>=当前时间
    criteria.andGreaterThanOrEqualTo("seckillEnd",new Date());
    return skuMapper.selectCountByExample(example);
}

seckill-goodscom.seckill.goods.controller.SkuController中添加count方法,用于实现查询秒杀商品总数量:

/***
 * Sku数量加载
 * @return
 */
@PostMapping(value = "/count" )
public Integer count(){
    return skuService.count();
}

2)service分页集合数据查询

seckill-goodscom.seckill.goods.service.SkuService中添加list方法,用于查询秒杀商品:

/***
 * 分页加载
 * @param page
 * @param size
 * @return
 */
List<Sku> list(int page, int size);

seckill-goodscom.seckill.goods.service.impl.SkuServiceImpl中添加list方法,用于实现查询秒杀商品:

/***
 * 分页加载
 * @param page
 * @param size
 * @return
 */
@Override
public List<Sku> list(int page, int size) {
    //分页
    PageHelper.startPage(page,size);

    //条件构建
    Example example = new Example(Sku.class);
    Example.Criteria criteria = example.createCriteria();
    //秒杀剩余商品数量>0
    criteria.andGreaterThan("seckillNum",0);
    //状态为参与秒杀,1:普通商品,2:参与秒杀
    criteria.andEqualTo("status","2");
    //秒杀结束时间>=当前时间
    criteria.andGreaterThanOrEqualTo("seckillEnd",new Date());
    return skuMapper.selectByExample(example);
}

seckill-goodscom.seckill.goods.controller.SkuController中添加list方法,用于实现查询秒杀商品:

/***
 * Sku分页条件加载
 * @param page
 * @param size
 * @return
 */
@GetMapping(value = "/list/{page}/{size}" )
public List<Sku> list(@PathVariable  int page, @PathVariable  int size){
    //调用SkuService实现分页条件查询Sku
    List<Sku> skus = skuService.list(page, size);
    return skus;
}

3)Feign接口编写

seckill-goods-apicom.seckill.goods.feign.SkuFeign中编写feign方法,分别调用刚才的count、list方法,代码如下:

/***
 * Sku数量加载
 * @return
 */
@PostMapping(value = "/sku/count" )
Integer count();

/***
 * Sku分页条件加载
 * @param page
 * @param size
 * @return
 */
@GetMapping(value = "/sku/list/{page}/{size}" )
List list(@PathVariable(value ="page") Integer page, @PathVariable(value = "size")Integer size);

4)批量导入实现

数据添加到数据库:

1.将数据接到,并转成JavaBean->数据库表中对应的一个JavaBean
2.调用API,把JavaBean保存到数据库

将数据添加到索引库,流程和上面类似,也需要先创建一个能体现索引库的JavaBean映射对象,将要保存到索引库的数据赋值给JavaBean,利用API将JavaBean保存到索引库。

我们首先编写一个和索引库中一一对应的实体Bean,代码如下:

@Document(indexName = "goodsindex",type = "skuinfo")
public class SkuInfo implements Serializable {

    //Sku相关的数据
    //商品id,同时也是商品编号
    @Id //唯一标识符,ES中对应的_id
    private String id;

    /***
     * SKU名称
     * type =FieldType.Text:指定当前name属性所对应的域的类型为Text类型,该类型支持分词支持创建索引
     *       FiledType.Keyword:不分词
     * searchAnalyzer="ik_smart":搜索所使用的分词器
     * analyzer = "ik_smart":添加索引所使用的分词器
     */
    @Field(type =FieldType.Text ,searchAnalyzer = "ik_smart",analyzer = "ik_smart",store =false)
    private String name;

    //商品价格,单位为:元
    private Long price;

    //秒杀价
    private Long seckillPrice;

    //商品图片
    private String image;

    //更新时间
    private Date updateTime;

    //类目ID
    private String category1Id;

    //类目ID
    private String category2Id;

    //类目ID
    private String category3Id;

    //类目名称
    @Field(type = FieldType.Keyword)
    private String category1Name;

    //类目名称
    @Field(type = FieldType.Keyword)
    private String category2Name;

    //类目名称
    @Field(type = FieldType.Keyword)
    private String category3Name;

    //品牌名称
    @Field(type = FieldType.Keyword)
    private String brandName;

    //开始时间,用于做搜索
    @Field(type = FieldType.Keyword)
    private String bgtime;

    //品牌ID
    private String brandId;

    private Date seckillBegin;//秒杀开始时间

    private Date seckillEnd;//秒杀结束时间

    private Integer status; //秒杀状态,1普通商品,2秒杀

    //规格
    private String spec;
    
    //..get..set略
    
}

集成SpringData Elasticsearch实现索引导入流程:

1.配置Elasticsearch地址信息
2.编写Dao代码,继承ElasticsearchRepository<T,ID>
3.在Service中分页调用查询秒杀商品集合
4.分页导入秒杀商品集合数据到Elasticsearch中

当前项目已经集成好了SpringDataElasticsearch,我们只需要实现相关的操作过程即可。

bootstrap.yml添加es配置:

spring:
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: es-server:9300

创建dao

public interface SkuInfoMapper extends ElasticsearchRepository<SkuInfo,String> {
}

seckill-searchcom.seckill.search.service.SkuInfoService中编写导入索引,代码如下:

/***
 * 导入所有
 */
void addAll();

seckill-searchcom.seckill.search.service.impl.SkuInfoServiceImpl中编写导入索引,代码如下:

@Autowired
private SkuFeign skuFeign;

/***
 * 使用Feign导入所有
 */
@Override
public void addAll() {
    //从第1页开始,每页处理500条
    int pageNum=1;
    int size=500;
    //查询所有数量
    Integer count = skuFeign.count();
    //总页数
    int totalPage = count%size==0? count/size : (count/size)+1;

    //分页查询,并将所有数据导入到索引库中
    for (int i = 0; i <totalPage ; i++) {
        List list = skuFeign.list(pageNum, size);

        //将数据转换成List<SkuInfo>
        List<SkuInfo> skuInfos =JSON.parseArray( JSON.toJSONString(list) ,SkuInfo.class);

        //规格处理
        for (SkuInfo skuInfo : skuInfos) {
            //获取秒杀时间
            Date seckillBegin = skuInfo.getSeckillBegin();
            if(seckillBegin!=null){
                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyymmddhhmmss");
                String bgtime = simpleDateFormat.format(seckillBegin);
                skuInfo.setBgtime(bgtime);
            }
        }
        skuInfoMapper.saveAll(skuInfos);
        //页数递增
        pageNum++;
    }
}

注意:我们可以发现下面这段代码以后在其他地方有可能也会用得着,我们可以把它单独抽取出一个方法来:

抽取后:

seckill-searchcom.seckill.search.controller.SearchController中编写导入索引,代码如下:

/***
 * 所有记录导入到搜索引擎中
 */
@GetMapping(value = "/all/add")
public Result addAll(){
    //添加数据到索引库中
    skuInfoService.addAll();
    return new Result(true, StatusCode.OK,"导入所有数据到索引库成功!");
}

4)测试

启动seckill-goodsseckill-searchseckill-gateway,访问刚才编写的批量导入的方法,访问地址:http://localhost:8001/api/search/all/add

3.2 增量导入

增量导入,也就是某个商品设置成秒杀商品的时候,或者发生变更的时候,能实现增量备份(只将修改的数据同步修改索引库),所以我们还需要实现单个商品导入索引库,我们可以在变更方法(增删改)中调用这边同步方法,但随着系统的增加,这种方法容易有漏网之鱼,我们可以采用canal实现数据库增量监听,然后调用seckill-search的单个操作方法。

1)索引操作方法编写

seckill-searchcom.seckill.search.service.SkuInfoService中添加modify方法,代码如下:

/****
 * 单条索引操作
 * @param type: 1:增加,2:修改,3:删除
 * @param skuInfo
 */
void modify(Integer type, SkuInfo skuInfo);

seckill-searchcom.seckill.search.service.impl.SkuInfoServiceImpl中添加modify实现方法,代码如下:

/***
 * 单条索引操作
 * @param type
 * @param skuInfo
 */
@Override
public void modify(Integer type, SkuInfo skuInfo) {
    if(type==1 || type==2){
        //时间转换
        skuInfoConverter(skuInfo);
        //增加数据到索引库
        skuInfoMapper.save(skuInfo);
    }else{
        skuInfoMapper.deleteById(skuInfo.getId());
    }
}

seckill-searchcom.seckill.search.controller.SkuInfoController中添加modify方法,代码如下:

/***
 * 将一条记录导入到搜索引擎中
 */
@PostMapping(value = "/modify/{type}")
public Result add(@PathVariable(value = "type")Integer type, @RequestBody SkuInfo skuInfo){
    //添加数据到索引库中
    skuInfoService.modify(type,skuInfo);
    return new Result(true, StatusCode.OK,"操作一条数据到索引库成功!");
}

2)Feign接口编写

seckill-search-api中编写Feign接口,实现调用modify方法,代码如下:

@FeignClient(value = "seckill-search")
public interface SkuInfoFeign {

    /***
     * 将一条记录导入到搜索引擎中
     */
    @PostMapping(value = "/search/modify/{type}")
    Result modify(@PathVariable(value = "type")Integer type, @RequestBody SkuInfo skuInfo);
}

3.3 商品搜索

根据秒杀页面的需求,多数是查询指定秒杀时段下的秒杀商品,同时还会有分页,当然,如果有复杂的查询,我们Elasticsearch也都满足。我们可以根据多数秒杀需求,实现按照秒杀时段分页查询数据。

编写Dao

public interface SkuInfoMapper extends ElasticsearchRepository<SkuInfo,String> {

    /***
     * 根据bgtime分页查询
     * @param time
     * @param pageable
     * @return
     */
    Page<SkuInfo> findByBgtime(String time, Pageable pageable);
}

编写Servicecom.seckill.search.service.SkuInfoService创建搜索方法,代码如下:

/***
 * 搜索
 */
Page<SkuInfo> searchPage(Map<String,String> searchMap);

编写Servicecom.seckill.search.service.impl.SkuInfoServiceImpl创建搜索实现方法,代码如下:

/***
 * 搜索实现
 * @param searchMap
 * @return
 */
@Override
public Page<SkuInfo> searchPage(Map<String, String> searchMap) {
    //根据开始时间查询   findByBgtime(Dao)
    return skuInfoMapper.findByBgtime(searchMap.get("starttime"), PageRequest.of(pageNumber(searchMap)-1,20));
}

/***
 * 获取当期页
 * @param searchMap
 * @return
 */
public int pageNumber(Map<String, String> searchMap){
    try {
        return Integer.parseInt( searchMap.get("pageNum") );
    } catch (NumberFormatException e) {
        return 1;
    }
}

编写Servicecom.seckill.search.controller.SearchController创建搜索方法调用,代码如下:

/***
 * 秒杀分页查询
 * pageNum:当前页
 * starttime:秒杀活动开始时间
 */
@GetMapping
public Page search(@RequestParam(required = false) Map<String,String> searchMap){
    if(searchMap==null){
        return null;
    }
    return skuInfoService.searchPage(searchMap);
}

4 商品详情页{#4-商品详情页}

4.1 分析

秒杀活动中,热卖商品的详情页访问频率非常高,详情页的数据加载,我们可以采用直接从数据库查询加载,但这种方式会给数据库带来极大的压力,甚至崩溃,这种方式我们并不推荐。

商品详情页主要有商品介绍、商品标题、商品图片、商品价格、商品数量等,大部分数据几乎不变,可能只有数量会变,因此我们可以考虑把商品详情页做成静态页,每次访问只需要加载库存数量,这样就可以大大降低数据库的压力。

我们这里将采用freemarker来实现商品详情页的静态化,关于freemarker的语法我们就不在这里讲解了,大家可以自行去网上查阅相关API。

4.2 工程搭建

我们创建一个静态页生成工程,用于生成商品详情页。

pom.xml

<dependencies>
    <!--依赖web-->
    <dependency>
        <groupId>com.seckill</groupId>
        <artifactId>seckill-web</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <!--api-->
    <dependency>
        <groupId>com.seckill</groupId>
        <artifactId>seckill-goods-api</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <!--freemarker-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
</dependencies>

bootstrap.yml

server:
  port: 18087
spring:
  application:
    name: seckill-page
  cloud:
    nacos:
      config:
        file-extension: yaml
        server-addr: nacos-server:8848
      discovery:
        #Nacos的注册地址
        server-addr: nacos-server:8848
  profiles:
    active: dev #dev 开发环境 #test测试环境 #pro生产环境
  main:
    allow-bean-definition-overriding: true
  #freemarker配置
  freemarker:
    cache: false  #关闭模板缓存,方便测试
    settings:
      template_update_delay: 0  #检查模板更新延迟时间,设置为0表示立即检查,如果时间大于0会有缓存不方便进行模板测试
    template-loader-path: classpath:/templates
    charset: UTF-8
    check-template-location: true
    suffix: .ftl
    content-type: text/html
    expose-request-attributes: true
    expose-session-attributes: true
    request-context-attribute: request
#超时配置
ribbon:
  ReadTimeout: 300000
  ConnectTimeout: 30000

#静态页位置
htmlPath: D:/page/html

创建文件生成对象:

public class BaseProcess {

    @Autowired
    private Configuration configuration;

    /***
     * 生成静态页
     * @param dataMap
     *          dataMap.templateName: 模板名字
     *          dataMap.path: 生成文件存储路径
     *          dataMap.name: 生成的文件名字
     * @throws Exception
     */
    public void writerPage(Map<String,Object> dataMap) throws Exception {
        //获取模板名字
        String templateName = dataMap.get("templateName").toString();

        //文件生存的路径
        String path = dataMap.get("path").toString();

        //文件路径如果不存在,则创建
        File file = new File(path);
        if(!file.exists()){
            file.mkdirs();
        }

        //获取文件名字
        String fileName = dataMap.get("name").toString();

        //获取模板对象
        Template template = configuration.getTemplate(templateName);

        //模板处理,获取生成的文件字符串
        String content = FreeMarkerTemplateUtils.processTemplateIntoString(template, dataMap);

        //生成文件
        FileUtils.writeStringToFile(new File(path,fileName),content);
    }
}

BaseProcess介绍:

该类用于生成静态文件,调用writerPage方法即可实现,其中有3个变量,变量注释如下:

templateName:模板名字,例如item.ftl,模板放到templates目录下
path:生成文件的路径,例如D:/page/html
name:生成静态页文件的名字,例如:1.html

4.3 商品详情静态页生成

生成商品详情页,我们需要提供商品信息(sku),并且将商品数据存储到数据模型Map中,然后在页面渲染。

1)静态页生成

seckill-page中创建com.seckill.page.service.SkuPageService,添加生成静态页方法,代码如下:

/***
 * 生成静态页
 */
void itemPage(Map<String,Object> dataMap) throws Exception;

seckill-page中创建com.seckill.page.service.impl.SkuPageServiceImpl,添加生成静态页方法,代码如下:

@Service
public class SkuPageServiceImpl extends BaseProcess implements SkuPageService  {

    /***
     * 生成静态页
     */
    @Override
    public void itemPage(Map<String,Object> dataMap) throws Exception {
        dataMap.put("username","王五");

        //生成静态页
        super.writerPage(dataMap);
    }
}

seckill-page中创建com.seckill.page.controller.SkuPageController,添加生成静态页方法,代码如下:

@RestController
@RequestMapping(value = "/page")
public class SkuPageController {

    @Autowired
    private SkuPageService skuPageService;

    @Value("${htmlPath}")
    private String htmlPath;

    /****
     * 生成商品详情静态页
     */
    @PostMapping(value = "/html")
    public Result html(@RequestBody Sku sku) throws Exception {
        //数据模型
        Map<String,Object> dataMap = new HashMap<String,Object>();
        dataMap.put("name",sku.getId()+".html"); //生成静态页的文件名字
        dataMap.put("path",htmlPath);            //文件路径
        dataMap.put("templateName","item.ftl"); //模板名字
        dataMap.put("sku",sku);                  //商品数据

        //生成静态页
        skuPageService.itemPage(dataMap);
        return new Result(true, StatusCode.OK,"生成成功!");
    }
}

注意:其中bootstrap.yml中添加htmlPath: D:/page/html配置。

2)Feign调用

seckill-page-api中创建com.seckill.page.feign.SkuPageFeign实现调用,代码如下:

@FeignClient(value = "seckill-page")
public interface SkuPageFeign {

    /****
     * 生成商品详情静态页
     */
    @PostMapping(value = "/page/html")
    Result html(@RequestBody Sku sku) throws Exception;
}

4.4 静态页删除

当商品变成普通商品或者商品售罄的时候,需要删除详情页,因此还需要实现一个根据id删除详情页的方法。

seckill-page中创建com.seckill.page.service.SkuPageService,添加删除静态页方法,代码如下:

/***
 * 删除静态页
 * @param id
 * @param htmlPath
 */
void delItemPage(String id,String htmlPath);

seckill-page中创建com.seckill.page.service.impl.SkuPageServiceImpl,添加删除静态页方法,代码如下:

/***
 * 删除静态页
 * @param id
 * @param htmlPath
 */
@Override
public void delItemPage(String id, String htmlPath) {
    File file = new File(htmlPath,id+".html");
    if(file.exists()){
        file.delete();
    }
}

seckill-page中创建com.seckill.page.controller.SkuPageController,添加删除静态页方法,代码如下:

/****
 * 删除商品详情静态页
 */
@DeleteMapping(value = "/html/{id}")
public Result delHtml(@PathVariable(value = "id")String id) throws Exception {
    //删除静态页
    skuPageService.delItemPage(id,htmlPath);
    return new Result(true, StatusCode.OK,"删除成功!");
}

2)Feign调用

seckill-page-api中创建com.seckill.page.feign.SkuPageFeign实现调用,代码如下:

/****
 * 删除商品详情静态页
 */
@DeleteMapping(value = "/page/html/{id}")
Result delHtml(@PathVariable(value = "id")String id) throws Exception;

5 Canal增量数据同步利器{#5-canal增量数据同步利器}

5.1 Canal介绍

canal主要用途是基于 MySQL 数据库增量日志解析,并能提供增量数据订阅和消费,应用场景十分丰富。

github地址:https://github.com/alibaba/canal

版本下载地址:https://github.com/alibaba/canal/releases

文档地址:https://github.com/alibaba/canal/wiki/Docker-QuickStart

Canal应用场景

1.电商场景下商品、用户实时更新同步到至Elasticsearch、solr等搜索引擎;
2.价格、库存发生变更实时同步到redis;
3.数据库异地备份、数据同步;
4.代替使用轮询数据库方式来监控数据库变更,有效改善轮询耗费数据库资源。

MySQL主从复制原理

1.MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看)
2.MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
3.MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

Canal工作原理

1.canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
2.MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
3.canal 解析 binary log 对象(原始为 byte 流)

5.2 Canal安装

参考文档:https://github.com/alibaba/canal/wiki/QuickStart

5.2.1 MySQL Bin-log开启

1)MySQL开启bin-log

a.进入mysql容器

docker exec -it -u root mysql /bin/bash

b.开启mysql的binlog

cd /etc/mysql/mysql.conf.d

在mysqld.cnf最下面添加如下配置
# 开启 binlog
log-bin=/var/lib/mysql/mysql-bin
# 选择 ROW 模式
binlog-format=ROW
# 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
server-id=12345

c.创建账号并授权

授权 canal 链接 MySQL 账号具有作为 MySQL slave 的权限, 如果已有账户可直接 grant:

create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

d.重启mysql

docker restart mysql

开启bin-log后,我们可以用sql语句查看下:

show variables like '%log_bin%'

效果如下:

5.2.2 Canal安装

1)拉取镜像

docker pull canal/canal-server:v1.1.1

2)安装容器

a.安装canal-server容器

docker run -p 11111:11111 --name canal -d docker.io/canal/canal-server

b.配置canal-server

修改/home/admin/canal-server/conf/canal.properties,将它的id属性修改成和mysql数据库中server-id不同的值,如下图:

c.修改/home/admin/canal-server/conf/example/instance.properties,配置要监听的数据库服务地址和监听数据变化的数据库以及表,修改如下:

指定监听数据库表的配置如下canal.instance.filter.regex

mysql 数据解析关注的表,Perl正则表达式.
多个正则之间以逗号(,)分隔,转义符需要双斜杠(\\) 
常见例子:
1.  所有表:.*   or  .*\\..*
2.  canal schema下所有表: canal\\..*
3.  canal下的以canal打头的表:canal\\.canal.*
4.  canal schema下的一张表:canal.test1
5.  多个规则组合使用:canal\\..*,mysql.test1,mysql.test2 (逗号分隔)
注意:此过滤条件只针对row模式的数据有效(ps. mixed/statement因为不解析sql,所以无法准确提取tableName进行过滤)

重启canal:

docker restart canal

5.3 Canal微服务

我们搭建一个微服务,用于读取canal监听到的变更日志,微服务名字叫seckill-canal。该项目我们需要引入canal-spring-boot-autoconfigure包,并且需要实现EntryHandler<T>接口,该接口中有3个方法,分别为insertupdatedelete,这三个方法用于监听数据增删改变化。

参考地址:https://github.com/NormanGyllenhaal/canal-client

1)pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>seckill-service</artifactId>
        <groupId>com.seckill</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>seckill-canal</artifactId>

    <dependencies>
        <!--web-->
        <dependency>
            <groupId>com.seckill</groupId>
            <artifactId>seckill-web</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <!--esAPI-->
        <dependency>
            <groupId>com.seckill</groupId>
            <artifactId>seckill-search-api</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <!--goodsAPI-->
        <dependency>
            <groupId>com.seckill</groupId>
            <artifactId>seckill-goods-api</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <!--canal-->
        <dependency>
            <groupId>top.javatool</groupId>
            <artifactId>canal-spring-boot-autoconfigure</artifactId>
            <version>1.2.1-RELEASE</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <!-- 指定该Main Class为全局的唯一入口 -->
                    <mainClass>com.seckill.CanalApplication</mainClass>
                    <layout>ZIP</layout>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal><!--可以把依赖的包都打包到生成的Jar包中-->
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

bootstrap.yml配置

server:
  port: 18088
spring:
  application:
    name: seckill-canal
  cloud:
    nacos:
      config:
        file-extension: yaml
        server-addr: nacos-server:8848
      discovery:
        #Nacos的注册地址
        server-addr: nacos-server:8848
#超时配置
ribbon:
  ReadTimeout: 3000000
#Canal配置
canal:
  server: canal-server:11111
  destination: example
#日志
logging:
  level:
    root: error

2)创建com.seckill.handler.SkuHandler实现EntryHandler接口,代码如下:

@Component
@CanalTable(value = "tb_sku")
public class SkuHandler implements EntryHandler<Sku> {

    /***
     * 增加数据
     * @param sku
     */
    @Override
    public void insert(Sku sku) {
        System.out.println("===========insert:"+sku);
    }

    /***
     * 修改数据
     * @param before
     * @param after
     */
    @Override
    public void update(Sku before, Sku after) {
        System.out.println("===========update-before:"+before);
        System.out.println("===========update-after:"+after);
    }

    /***
     * 删除数据
     * @param sku
     */
    @Override
    public void delete(Sku sku) {
        System.out.println("===========delete:"+sku);
    }
}

3)创建启动类

@SpringBootApplication
public class CanalApplication {

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

程序启动后,修改tb_sku数据,可以看到控制会打印修改前后的数据:

6 同步数据{#6-同步数据}

6.1 索引库同步

tb_sku秒杀商品发生变化时,我们应该同时变更索引库中的索引数据,比如秒杀商品增加,则需要同步增加秒杀商品的索引,如果有秒杀商品删除,则需要同步移除秒杀商品。

修改seckill-canal中的com.seckill.handler.SkuHandler的增删改方法,代码如下:

@Component
@CanalTable(value = "tb_sku")
public class SkuHandler implements EntryHandler<Sku> {

    @Autowired
    private SkuInfoFeign skuInfoFeign;

    /***
     * 增加数据
     * @param sku
     */
    @Override
    public void insert(Sku sku) {
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(sku) ,SkuInfo.class);
        //同步索引
        skuInfoFeign.modify(1,skuInfo);
    }

    /***
     * 修改数据
     * @param before
     * @param after
     */
    @Override
    public void update(Sku before, Sku after) {
        int type=2;
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(after) ,SkuInfo.class);
        if(skuInfo.getStatus()==1 || after.getSeckillNum()<=0){
            //商品变成了普通商品,或者商品库存为0,则需要删除索引数据
            type=3;
        }
        //同步索引
        skuInfoFeign.modify(type,skuInfo);
    }

    /***
     * 删除数据
     * @param sku
     */
    @Override
    public void delete(Sku sku) {
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(sku) ,SkuInfo.class);
        //同步索引
        skuInfoFeign.modify(3,skuInfo);
    }
}

开启Feign功能:@EnableFeignClients(basePackages = {"com.seckill.search.feign"})

此时对数据库中tb_sku表进行增删改的时候,会同步到索引库中。

6.2 静态页同步

只需要添加Feign包,注入SkuPageFeign,根据增删改不同的需求实现生成静态页或删除静态页。修改SkuHandler,代码如下:

@Component
@CanalTable(value = "tb_sku")
public class SkuHandler implements EntryHandler<Sku> {

    @Autowired
    private SkuInfoFeign skuInfoFeign;

    @Autowired
    private SkuPageFeign skuPageFeign;

    /***
     * 增加数据
     * @param sku
     */
    @SneakyThrows
    @Override
    public void insert(Sku sku) {
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(sku) ,SkuInfo.class);
        //同步索引
        skuInfoFeign.modify(1,skuInfo);

        //生成静态页
        skuPageFeign.html(sku);
    }

    /***
     * 修改数据
     * @param before
     * @param after
     */
    @SneakyThrows
    @Override
    public void update(Sku before, Sku after) {
        int type=2;
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(after) ,SkuInfo.class);
        if(skuInfo.getStatus()==1 || after.getSeckillNum()<=0){
            //商品变成了普通商品,或者商品库存为0,则需要删除索引数据
            type=3;
        }
        //同步索引
        skuInfoFeign.modify(type,skuInfo);

        if(type==3){
            //删除静态页
            skuPageFeign.delHtml(after.getId());
        }else{
            //生成静态页
            skuPageFeign.html(after);
        }
    }

    /***
     * 删除数据
     * @param sku
     */
    @SneakyThrows
    @Override
    public void delete(Sku sku) {
        //将Sku转换成SkuInfo
        SkuInfo skuInfo = JSON.parseObject( JSON.toJSONString(sku) ,SkuInfo.class);
        //同步索引
        skuInfoFeign.modify(3,skuInfo);

        //删除静态页
        skuPageFeign.delHtml(sku.getId());
    }
}

同时不要忘了添加feign包:@EnableFeignClients(basePackages = {"com.seckill.search.feign","com.seckill.page.feign"})

测试的时候,在数据对数据进行增删改,静态页也会同步操作。 :::