黑马秒杀: 黑马秒杀主要包括商品抢购、订单管理、活动管理以及用户管理等功能。该项目基于微服务架构,结合大数据分析,共同打造亿级流量、百万并发、高可用秒杀解决方案。 (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-goods
的com.seckill.goods.service.SkuService
中添加count方法,用于查询秒杀商品总数量:
/** * 总数量加载 * @return */ Integer count();
在seckill-goods
的com.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-goods
的com.seckill.goods.controller.SkuController
中添加count方法,用于实现查询秒杀商品总数量:
/*** * Sku数量加载 * @return */ @PostMapping(value = "/count" ) public Integer count(){ return skuService.count(); }
2)service分页集合数据查询
在seckill-goods
的com.seckill.goods.service.SkuService
中添加list方法,用于查询秒杀商品:
/*** * 分页加载 * @param page * @param size * @return */ List<Sku> list(int page, int size);
在seckill-goods
的com.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-goods
的com.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-api
的com.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-search
的com.seckill.search.service.SkuInfoService
中编写导入索引,代码如下:
/*** * 导入所有 */ void addAll();
在seckill-search
的com.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-search
的com.seckill.search.controller.SearchController
中编写导入索引,代码如下:
/*** * 所有记录导入到搜索引擎中 */ @GetMapping(value = "/all/add") public Result addAll(){ //添加数据到索引库中 skuInfoService.addAll(); return new Result(true, StatusCode.OK,"导入所有数据到索引库成功!"); }
4)测试
启动seckill-goods
、seckill-search
、seckill-gateway
,访问刚才编写的批量导入的方法,访问地址:http://localhost:8001/api/search/all/add
3.2 增量导入
增量导入,也就是某个商品设置成秒杀商品的时候,或者发生变更的时候,能实现增量备份(只将修改的数据同步修改索引库),所以我们还需要实现单个商品导入索引库,我们可以在变更方法(增删改)中调用这边同步方法,但随着系统的增加,这种方法容易有漏网之鱼,我们可以采用canal实现数据库增量监听,然后调用seckill-search
的单个操作方法。
1)索引操作方法编写
在seckill-search
的com.seckill.search.service.SkuInfoService
中添加modify方法,代码如下:
/**** * 单条索引操作 * @param type: 1:增加,2:修改,3:删除 * @param skuInfo */ void modify(Integer type, SkuInfo skuInfo);
在seckill-search
的com.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-search
的com.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个方法,分别为insert
、update
、delete
,这三个方法用于监听数据增删改变化。
参考地址: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"})
测试的时候,在数据对数据进行增删改,静态页也会同步操作。 :::