ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

秒杀项目总结

2022-07-12 17:34:03  阅读:145  来源: 互联网

标签:总结 goods return 项目 redis 秒杀 user RespBean id


seckill-demo

秒杀练习项目:主要目的是通过本项目练习SpingBoot学习过程中所学知识,逐渐将学习到的技能融合至项目中.
github地址:seckill-demo
目录

目录

1 项目框架搭建

1.1 SpringBoot环境搭建

正常搭建SpringBoot项目,勾选Web、Lombok、Thymeleaf、Spring Data JDBC等组件,后续随着项目进行会引入其他组件。

1.2 集成Thymeleaf, RespBean

Thymeleaf作为模板引擎,在创建项目时候已经引入,后续直接使用即可。

RespBean的目的是定义公共返回对象枚举,可以用作为用户在登录页面时的消息返回值类型。

1.3 Mybatis-plus

本项目中使用Mybatis-plus逆向生成工具类生成mapper、service、serviceImpl、pojo、等文件。

详情逆向生成代码可见Mybatis-plus代码生成器(旧)

2 分布式会话

实现分布式会话的意义:将用户登录后生成的cookie保存下来,登录跳转后返回给goodsList页面,前面使用了model的方式传递用户cookie,后面引入了redis实现分布式session。

为什么要使用分布式session?

用户登录信息在tomcat1上保存,由于Nginx默认的负载均衡策略时轮询机制反向代码请求转发,在Tomcat上没有用户信息,通过分布式session将用户信息保存在缓存中间件redis中。这样,可以使用户信息在Tomcat上同步。

2.1 用户登录

本部分主要涉及数据库设计、前端密码存储二次加密、登录参数校验、全局异常处理等工作。

a. 设计数据库

参考链接数据库文件seckill-demo.sql

表设计:

用户表

id nickname pwd salt head register_date last_login_date login_count
phone 昵称 MD5 盐(加密) 头像 注册时间 最后登录时间 登录次数
create table `t_user` (
    `id` bigint(20) not null comment '用户ID,mobilephone',
    `nickname` varchar(255) not null,
    `password` varchar(32) not null comment 'MD5(MD5(pwd明文+固定salt)+salt)',
    `salt` varchar(10) default null,
    `head` varchar(128) default null,
    `register_date` datetime dafault null,
    `last_login_date` datetime dafault null,
    `login_count` int(11) default '0',
    primary key(`id`)
)engine=innodb default charset=utf8;

商品表

id goods_name goods_title goods_img goods_detail goods_price goods_stock
CREATE TABLE `t_goods` (
      `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品id',
      `goods_name` VARCHAR(16) DEFAULT NULL COMMENT '商品名称',
      `goods_title` VARCHAR(64) DEFAULT NULL COMMENT '商品标题',
      `goods_img` VARCHAR(64) DEFAULT NULL COMMENT '商品图片',
      `goods_detail` LONGTEXT COMMENT '商品详情',
      `goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品价格',
      `goods_stock` INT(11) DEFAULT '0' COMMENT '商品库存,-1表示无限制',
      PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

订单表

id user_id goods_id deliver_addr goods_name goods_count goods_price order_channel status create_date pay_time
CREATE TABLE `t_order` (
      `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '订单id',
      `user_id` BIGINT(20) DEFAULT NULL COMMENT '用户id',
      `goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品id',
      `delivery_addr_id` BIGINT(20) DEFAULT NULL COMMENT '收货地址id',
      `goods_name` VARCHAR(16) DEFAULT NULL COMMENT '冗余过来的商品名称',
      `goods_count` INT(11) DEFAULT '0' COMMENT '商品数量',
      `goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品单价',
      `order_channel` TINYINT(4) DEFAULT '0' COMMENT '1pc,2android,3ios',
      `status` TINYINT(4) DEFAULT '0' COMMENT '订单状态,0 1 2 3 4 5',
      `create_date` DATETIME DEFAULT NULL COMMENT '订单创建时间',
      `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
      PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4;

秒杀商品表

id goods_id seckill_price stock_count start_date stop_date
主键id 商品id 商品价格 库存 开始时间 结束时间
create table `t_seckill_goods`(
    `id` bigint(20) not null auto_increment,
    `goods_id` bigint(20) default null,
    `seckill_price` decimal(12,0) default '0',
    `stock_count` int(10) default null,
    `start_date` datetime default null,
    `stop_date` datetime default null,
    primary key(`id`)
)engine=innodb auto_increment=3 default charset=utf8mb4;

秒杀订单表

id user_id order_id goods_id
主键id 用户id 订单id 商品id
create table `t_seckill_order`(
    `id` bigint(20) not null auto_increment,
    `user_id` bigint(20) default null,
    `order_id` bigint(20) default null,
    `goods_id` bigint(20) default null,
    primary key (`id`)
)engine=innodb auto_increment=3 default charset=utf8mb4;

b. 明文密码二次MD5加密

参考MD5Utils.java具体实现.

因为http是明文传输的,也就是中间的路由可以看到我们用户提交的帐号密码信息,因此我们选择对用户的密码进行MD5加密。

使用两次MD5加密:

客户端:PASS=MD5(密码+固定Salt)在前端实现。防止用户密码直接在网络中传输。

服务端:PASS=MD5(用户输入+随机salt)在后端实现。防止数据库泄露用户密码。

c. 参数校验

对用户登录时的手机号做验证

注:本部分需要引入validator依赖

1.新建@IsMobile注解

/**
 * @description 自定义注解
 * @author Chenhf
 * @date 2022/7/1 16:40
 */
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile {
    //加required
    boolean required() default true;

    String message() default "手机号码格式错误";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

2.定义注解校验逻辑实现

/**
 * @description: 手机号码校验规则
 * @className: IsMobileValidator
 * @author: Chenhf
 * @date: 2022/7/1 16:43
 * @version: 1.0
 */
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

    private boolean required = false;
    @Override
    public void initialize(IsMobile constraintAnnotation) {
        required = constraintAnnotation.required();
    }

    //自定义注解IsMobile的校验规则
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (required){
            return ValidatorUtil.isMobile(value);
        }else {
            if (StringUtils.isEmpty(value)) {
                return true;
            }else {
                return ValidatorUtil.isMobile(value);
            }
        }
    }
}

3.ValidatorUtil里面使用正则表达式判断

/**
 * @description: 校验工具类
 * @className: ValidatorUtil
 * @author: Chenhf
 * @date: 2022/6/30 19:36
 * @version: 1.0
 */
public class ValidatorUtil {
    //手机号位数校验
    private static final Pattern mobile_pattern = Pattern.compile("[1]([3-9])[0-9]{9}$");
    public static boolean isMobile(String mobile){
        //为空直接返回
        if (StringUtils.isEmpty(mobile)){
            return false;
        }
        //根据Pattern.compile的规则校验mobile,返回boolean类型
        Matcher matcher = mobile_pattern.matcher(mobile);
        return matcher.matches();
    }
}

4.在需要对参数校验的Controller中添加@Valid注解,登录的实体类LoginVo手机号属性添加我们自定义的@IsMobile注解

@RequestMapping("/doLogin")
@ResponseBody
//加入validation校验功能
public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
    //用来测试用户输入
    //log.info("{}",loginVo);
    //Controller调用service
    return userService.doLogin(loginVo, request, response);
}
@Data
@SuppressWarnings("all")
public class LoginVo {
    //使用validation组件, 减少代码冗余
    @NotNull()
    //创建的自定义组件
    @IsMobile
    private String mobile;
    @NotNull
    @Length(min=32)
    private String password;
}

d.全局异常处理

在前面RespBean定义了公共返回对象枚举,可以用作为用户在登录页面时的消息返回值类型。

这里定义全局异常处理,返回的就是一个responseBody

@Data
@NoArgsConstructor
@AllArgsConstructor
public class GlobalException extends RuntimeException {
    private RespBeanEnum respBeanEnum;
}
//返回的就是一个responseBody
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public RespBean ExceptionHandler(Exception e) {
        if (e instanceof GlobalException) {
            GlobalException ex = (GlobalException) e;
            return RespBean.error(ex.getRespBeanEnum());
        } else if (e instanceof BindException) {
            BindException ex = (BindException) e;
            RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
            respBean.setMessage("参数校验异常:" + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
            return respBean;
        }
        return RespBean.error(RespBeanEnum.ERROR);
    }
}

2.2 共享Session(主要为用户session)

共享session需要同时满足a.b.两部分

a. SpringSession

引入SpringSession较简单,引入spring-session依赖即可

<!--Spring Session依赖-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

b. Redis

<!--springboot-data-redis依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3 功能开发

主要描述goodsList和goodsDetails后端实现

3.1 商品列表功能

@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model, User user,HttpServletRequest request,HttpServletResponse response) {
    //通过redis valueOperations获取页面
    //redis中获取页面,如果不为空直接返回页面
    ValueOperations valueOperations = redisTemplate.opsForValue();
    String html = (String) valueOperations.get("goodsList");
    if (!StringUtils.isEmpty(html)){
        return html;
    }
    model.addAttribute("user",user);
    model.addAttribute("goodsList", goodsService.findGoodsVo());
    //return "goodsList";
    //如果页面为null,手动渲染存入redis返回
    WebContext context = new WebContext(request, response,request.getServletContext(), request.getLocale(),
            model.asMap());
    html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context);
    if (!StringUtils.isEmpty(html)){
        valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
    }
    return html;
}

3.2 商品详情

@RequestMapping("/detail/{goodsId}")
@ResponseBody
//相当于返回一个对象
public RespBean toDetail(Model model, User user, @PathVariable Long goodsId){

    GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
    //定义秒杀状态
    Date startDate = goodsVo.getStartDate();
    Date endDate = goodsVo.getEndDate();
    Date nowDate = new Date();
    int seckillStatus = 0;
    //秒杀倒计时
    int remainSeconds = 0;
    if (nowDate.before(startDate)){
        remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000));
    } else if (nowDate.after(endDate)){
        seckillStatus = 2;
        remainSeconds = -1;
    } else {
        seckillStatus = 1;
        remainSeconds = 0;
    }
    //做了一个公共的返回对象,之前通过model传参,后面通过DetailVo传参
    DetailVo detailVo = new DetailVo();
    detailVo.setUser(user);
    detailVo.setGoodsVo(goodsVo);
    detailVo.setSeckillStatus(seckillStatus);
    detailVo.setRemainSeconds(remainSeconds);
    return RespBean.success(detailVo);
}

3.3 秒杀

//跳转秒杀页面
@RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(@PathVariable String path, User user, Long goodsId){
    if (user==null){
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    //1.实现after引入库存,通过redis操作
    ValueOperations valueOperations = redisTemplate.opsForValue();
    Boolean check = orderService.checkPath(user, path, goodsId);
    if(!check) {
        return RespBean.error(RespBeanEnum.PATH_ERROR);
    }

    //判断是否重复抢购, t_seckill_order添加唯一索引user_id和goods_id
    //2.存入redis在这里获取秒杀订单,在系统初始化时就把商品库存加载到redis中,通过redis预减库存,减少数据库操作
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
    if (seckillOrder!=null){
        return RespBean.error(RespBeanEnum.REPEATE_ERROR);
    }
    //通过内存标记减少redis访问
    if (EmptyStockMap.get(goodsId)){
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
    }
    //3.decrement是原子性的,用它来预减库存操作
    //Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
    //分布式锁预减库存
    Long stock = (Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);
    if(stock == 0) {
        EmptyStockMap.put(goodsId, true);
        valueOperations.increment("seckillGoods:"+goodsId);//由于递减之后是一个负数,这里递加为零
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
    }
    //4.下单流程,将用户信息及下单商品ID放入RabbitMQ中
    //用户信息及下单商品ID对象
    SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
    //对象转为字符串
    mqSender.sendSeckillMessage(JSON.toJSONString(seckillMessage));
    //秒杀订单进入排队状态
    return RespBean.success(0);
}

3.4 订单详情

@RequestMapping("/detail")
@ResponseBody
public RespBean detail(User user, Long orderId){
    if (user == null){
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    OrderDetailVo detail = orderService.detail(orderId);
    return RespBean.success(detail);
}

4 系统压测

使用Jmeter进行压力测试,测试接口的QPS.

4.1 Jmeter

Jmeter是Apache的一个开源测试工具.可以对系统接口进行测试及模拟压力测试

配置好之后就可以在本地进行测试

4.2 自定义变量模拟多用户

参考UserUtil工具类实现模拟多用户测试,使用JDBC生成多用户插入数据库进行测试.

4.3 Jmeter命令行使用

如4.1部分中对Jmeter进行配置,配置完成之后将文件导出,保存为jmx文件,在Linux系统中运行jar包,使用脚本进行测试.

# 生成报告  first.jmx生成的Jmter配置文件 result聚合报告结果
./jmeter.sh -n -t first.jmx -l result.jtl

完成之后将result.jtl导出察看测试结果

4.4 正式压测

a. 商量列表

b. 秒杀

5 页面优化

对页面进行优化,优化访问速度

5.1 页面缓存+URL缓存+对象缓存

GoodsController中对商品列表页使用redis优化了商品处理页.

通过redis valueOperations获取页面,如果页面不为空直接返回

ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsList");
if (!StringUtils.isEmpty(html)){
    return html;
}

如果页面为空,手动渲染存入redis返回

//获取上下文
WebContext context = new WebContext(request, response,request.getServletContext(), request.getLocale(),
        model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context);
if (!StringUtils.isEmpty(html)){
    valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
}
return html;

5.2 页面静态化, 前后端分离

goodsDetail和orderDetail进行了页面静态化,前后端分离的操作.

5.3 静态资源优化

采用对象缓存的方式,将页面在服务器缓存好,只传输数据对象.

@RequestMapping("/detail/{goodsId}")
@ResponseBody
//相当于返回一个对象
public RespBean toDetail(Model model, User user, @PathVariable Long goodsId){

    GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
    //定义秒杀状态
    Date startDate = goodsVo.getStartDate();
    Date endDate = goodsVo.getEndDate();
    Date nowDate = new Date();
    int seckillStatus = 0;
    //秒杀倒计时
    int remainSeconds = 0;
    if (nowDate.before(startDate)){
        remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000));
    } else if (nowDate.after(endDate)){
        seckillStatus = 2;
        remainSeconds = -1;
    } else {
        seckillStatus = 1;
        remainSeconds = 0;
    }
    //做了一个公共的返回对象,之前通过model传参,后面通过DetailVo传参
    DetailVo detailVo = new DetailVo();
    detailVo.setUser(user);
    detailVo.setGoodsVo(goodsVo);
    detailVo.setSeckillStatus(seckillStatus);
    detailVo.setRemainSeconds(remainSeconds);
    return RespBean.success(detailVo);
}

5.4 CDN优化

6 接口优化

添加redis作为缓存优化.

QPS最大的瓶颈在于对数据库的操作,频繁读取数据库压力很大,需要对数据进行缓存,我们通过对数据库的操作提取放到缓存中使用减少数据库的访问,提高用户访问效率,减小数据库压力.虽然加入了缓存,读取页面仍然需要与数据库进行交互,所以采用redis预减库存.

6.1 Redis预减库存减少数据库的访问

初始化时将商品库通过goodsVo.getStockCount存加载到redis中,通过redis扣减库存, 这样就可以较少数据库的访问

@Override
public void afterPropertiesSet() throws Exception {
    List<GoodsVo> list = goodsService.findGoodsVo();
    if (CollectionUtils.isEmpty(list)){
        return ;
    }
    ValueOperations valueOperations = redisTemplate.opsForValue();
    for (GoodsVo goodsVo : list){
        valueOperations.set("seckillGoods:"+goodsVo.getId(), goodsVo.getStockCount());
        EmptyStockMap.put(goodsVo.getId(), false);
    }
}

6.2 内存标记减少Redis的访问

为了减少对redis缓存的访问,采用内存标记的方式减少redis的访问.

private Map<Long,Boolean> EmptyStockMap = new HashMap<>();

//通过内存标记减少redis访问
if (EmptyStockMap.get(goodsId)){
    return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}

6.3 RabbitMQ异步下单

因为当系统并发量达到时候, 服务器资源有限, 坑不住大量的并发, 使下单请求进入队列缓存, 采用RabbitMQ进行下单优化, 异步下单, 客户端轮询秒杀优化并发.

//下单流程,将用户信息及下单商品ID放入RabbitMQ中
//用户信息及下单商品ID对象
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
//对象转为字符串
mqSender.sendSeckillMessage(JSON.toJSONString(seckillMessage));
//秒杀订单进入排队状态
return RespBean.success(0);

a. SpringBoot整合RabbitMQ

Linux中Rabbit配置可以参考博客链接

b. 交换机EXCHANG

本项目中采用了topic方式配置rabbitMQ, 既可以实现一对一,也可以实现一对多.

7 安全优化

7.1 秒杀接口地址隐藏

7.2 算术验证码

防止恶意下单操作, 使用算数验证码的方式来控制下单.

7.3 接口防刷

8 项目体验Demo.

标签:总结,goods,return,项目,redis,秒杀,user,RespBean,id
来源: https://www.cnblogs.com/csrecord/p/16470887.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有