【校招项目】秒杀系统第一版

项目简介

为了准备校招,需要准备项目,在网上看了很多项目,最后决定做一个秒杀系统,虽然现在秒杀系统有点烂大街,但是项目的覆盖的点依然很广,主要解决在高并发场景下的高可用,以及可拓展问题。写个博客记录一下项目所有的设计思路以及编码细节。

第一版为没有优化最垃圾的版本

项目的技术架构

技术架构秒杀系统需要解决的问题

  • 分布式会话
  • 用户登录、商品列表、商品详情、订单详情模块
  • 缓存优化
  • 系统压测,测试系统的QPS
  • 信息队列
  • 接口安全

学会在高并发场景下的常见解决思路 缓存,异步,优雅的代码

项目框架搭建

springboot环境搭建

需要的依赖有 web mysql驱动 mybatis redis thymleaf

封装CodeMsg和Result

CodeMsg和Result是为了给前端返回的结果对象

  • codemsg包含了错误的消息和状态码,result是泛型对象包含msg和code以及data数据
    codemsg含有一个私有的构造器还有get方法,以及若干个静态常量对象
    例如
public static final CodeMsg SUCCESS = new CodeMsg(0,"success");
public static final CodeMsg SERVER_ERROR = new CodeMsg(500100,"服务端异常");
  • 这些静态常量初始化为对象,我们想要获取codemsg对象时直接调用类的常量即可
    (这些对象都是单例)
  • 而result对象包含了两个私有构造器 一个参数为codemsg对象,一个参数为T的data
    包含了两个静态方法success 方法(参数为data)和error方法(codemsg对象)方法里面调用私有构造器返回result对象

mybatis集成

配置文件配置mybatis然后
在启动类上面配置扫描注解注册
@MapperScan(“com.seckill.dao”)

redis集成

封装工具类RedisConfig

RedisConfig类读取配置文件中的配置,通过注解@ConfigurationProperties将配置导入字段

RedisPoolFactory

注册JedisPool jedis连接池 单独封装的原因是因为 之前封装在Service里面报错了
循环引用出错
在这里插入图片描述

服务类RedisService

作用:通过jedis操作来访问redis
get方法 泛型方法 参数是前缀KeyPrefix key 要获得对象的Class对象 通过注入的jedispool获取jedis,调用get方法,然后调用stringtobean方法把字符串转换为对象
set方法
incr和decr方法 简化get方法 redis的incr和decr操作 对整数加1减1
exists方法 简化get方法
stringtobean 校验一下数据,然后调用fastjson的api转换成java对象

如何避免key被覆盖

开发一个通用缓存key(带前缀),多个模块比如商品或订单id可能重复

如何开发一个通用缓存key(单例模式)

模板模式 接口->抽象类->实现类

  1. KeyPrefix接口 方法有getPrefix获取前缀和getExpireSeconds获取过期时间
  2. BasePrefix抽象类 封装了两个字段prefix 和 expireseconds 实现了两个方法 getprefix 通过类名加prefix字段组合 比如 UserKey:id getexpireSeconds直接获取字段
  3. 有两个构造函数 一个双参数 一个只有prefix参数把expire赋值为0
  4. 实现类(UserKey) 公共字段GetById 和 GetByName 通过私有构造方法来构造对象,私有构造方法直接调用父类的单参数的构造器(分别是super(“id”)和super(“name”))
使用通用缓存Key

直接把实现类的静态方法传入到get(set…)方法里,get方法里面会获取前缀以及失效时间信息

用户登录与分布式session

创建miaosha_user表

关于mysql数据类型以及显示长度
  • 整数类型 int和bigint 分别占据4个字节以及8个字节,存储长度是不变的,int()和bigint(),括号内定义的是显示长度 如果后面加上zerofill,当位数不够显示时候会默认填充0,当位数超过括号内则失效
  • varchar和char括号内代表字符的个数,而不是字节 char(10)字节可能是10也可能是30

两次MD5加密

为什么要进行两次md5加密

第一次是因为避免密码明文在互联网上传输,第二次加密是为了防止数据库被脱库后密码泄露

md5utils

需要使用apache codec包里的DigestUtils.md5Hex(src)方法来进行md5加密,
使用formpasstodbpass可以把前端表单传过来的密码和数据库里的盐一起计算,得到的结果可以和数据库里进行比对

用户登录前端

需要的静态资源和框架

  • layer(弹出层插件) bootstrap jquery-validation common.js(salt变量) md5.js
    先用插件校验,然后获取数据进行ajax请求后端接口

用户登录后端

domain包下建立MiaoshaUser实体类和db里字段对应,并且id即为手机号
vo包下建立一个LoginVo对象封装前端的表单数据 电话和密码

-新建MiaoshaUserDao接口,用注解的方式写一个getById方法

@Select("SELECT * FROM miaosha_user where id = #{id}")
    MiaoshaUser findById(@Param("id") long id);
MiaoshaUserService
  1. getById方法直接调用dao
  2. login方法返回Codemsg对象,参数是loginvo前端的表单对象,先把id转换成long类型然后调用getbyid,返回的user进行判断,如果为null返回手机不存在
  3. 先获得前端的formpass,然后获取数据库里的dbpass,然后md5计算formpass和salt与dbpass进行比较,如果不正确返回password_error
LoginController(不包含会话)
  • login方法
    先进行数据校验
  1. 空校验 包括空和null
  2. 手机号是否符合规则调用validatorutil (正则表达式工具类)

然后调用service,看一下返回的状态码是不是0

jsr参数校验

省去校验参数的代码,在路由请求的方法参数前面加上@Valid注解,可以对参数进行校验,然后在实体类的字段上加上相应的注解

public class LoginVo {
    @NotNull
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min=32)
    private String password;
自定义校验注解

自定义一个IsMobile注解
自定义注解可以模仿hibernate的已有注解 复制属性 和 注解上面的注解
主要有三个地方需要改定

  1. validatedBy = {IsMobileValidator.class} ,这个值是需要进行复杂验证所需要的辅助类
  2. message是默认信息
  3. required 意思是这个值是否能为空
@Documented

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(
        validatedBy = {IsMobileValidator.class}
)
public @interface IsMobile {
    boolean required() default true;

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

自定义注解 需要实现ConstraintValidator这个泛型接口

public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {

然后实现两个方法即可 init 和 isValid
本质上init可以获取到自定义注解IsMobile的值(比如required),然后根据这个值来校验手机号 是否正确(比如required为false,并且参数为null 空 就返回true)

异常处理

全局异常处理

使用注解来进行参数校验,出现异常会直接抛出bindexception (spring),这样不友好,使用spring提供的全局异常处理机制来解决

BindException异常处理
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler
    public Result<String>  exceptionHandler(HttpServletRequest request,Exception e){

两个注解@ControllerAdvice(类上) 和 @ExceptionHandler (方法上),参数需要异常e
然后判断一下这个e instanceof exception 正确吗 正确获得默认消息 然后用封账的CodeMsg对象处理一下返回

String msg = error.getDefaultMessage();
return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));

拓展字符串格式化 String.format

自定义的GlobalException处理

以前实现Service层返回CodeMsg不够语义化 最好能直接返回true 或者 false,中途遇到错误直接抛出异常
那么需要定义一个全局异常,全局异常包含CodeMsg字段

分布式session实现

原理

因为秒杀肯定需要多台服务器,如果用户的请求打到了第二个服务器上那么也要保持会话状态。服务器同步麻烦 也乱

实现

利用uuid(通用唯一标识符)来创建token,登录成功后,token作为key,用户信息作为value保存到redis服务器中,并且把token作为cookie保存到客户端里,客户端请求时从校验token是否存在并取出用户信息

uuid获取

java的util包提供获得uuid的方法不过 uuid带’-'这个字符需要替换掉

通用MiaoShaUserKey开发
商品页面(分布式session)
保存登录状态 UserService

更新login逻辑 把token作为key user作为value加入到redis里然后在前端设置cookie

判断登录状态 GoodsController (几乎所有页面都需要)
  • GoodsController 的list方法
    利用@CookieValue(spring中的)从前端获取cookie中的token,手机端可能保存在请求参数中所以加上@RequestParam,如果两个都不存在则跳回到登录页面,然后用token取出redis服务器中的user信息。
记一次错误 :后端获取不到cookie的token

首选错误在页面错误thymeleaf跳转失败,因为我跳转到路由上了应该是后端的模板(如login/to_login 正确为login)
然后修改过来总是重定向到login页面,查看setcookie设置上了。
然后单独访问/goods/to_list路由,network不显示信息(这里不小心过滤了只保留xhr(对象))
最后认识到时cookie路径的问题

getByToken 提取redis里的user

直接调用redisService.get方法

访问页面延长cookie有效期

把login方法的set redis逻辑提取addcookie方法

private void addCookie(HttpServletResponse response , MiaoshaUser user)

然后更新getbytoken方法 (addcookie需要response)

public MiaoshaUser getByToken(HttpServletResponse response ,String token)
优化Controller层的判断登录

每个需要判断登录的都要获取cookie或者param里的token然后取出redis里的user
这样代码冗余,可以利用springmvc的参数解析机制,自定义参数注入让spring帮我们把user对象注入到方法的参数里(request对象 Model对象都是这样的机制)

  1. 首先创建WebConfig对象实现WebMvcConfigurer接口,然后标记config注解
  2. 然后复写addArgumentResolvers方法(这个方法是default的 所以不是实现)
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private UserArgumentResolver userArgumentResolver;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }
}
  1. 创建UserArgumentResolver类实现 HandlerMethodArgumentResolver实现supportsParameter方法和resolveArgument方法 前一个方法spring会调用它返回true就会调用第二个方法把第二个方法返回值注入到方法参数中(HandlerMethodParameter)

这样我们的业务逻辑都到argumentResolver里面了 精简前后代码参考GoodsController

秒杀功能开发及管理后台

数据库设计

设计四张表
goods表 商品表 id在企业中一般不是自增的很容易被遍历有一项技术可以解决

CREATE TABLE `goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `goods_name` varchar(16) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '商品名称',
  `goods_title` varchar(64) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '商品标题',
  `goods_img` varchar(64) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '商品的图片',
  `goods_detail` longtext CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '商品的详情介绍',
  `goods_price` decimal(10,2) DEFAULT 0.00 COMMENT '商品单价',
  `goods_stock` int(11) DEFAULT 0 COMMENT '商品库存',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

miaosha_goods表 单独建表主要两个原因,如果在原有goods表里增加字段的话
因为秒杀活动多增加的字段会导致表很臃肿,而且秒杀活动会频繁修改商品表,所以单独建表

CREATE TABLE `miaosha_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀的商品表',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品的ID',
  `miaosha_price` decimal(10,2) DEFAULT 0.00 COMMENT '秒杀价',
  `stock_count` int(11) DEFAULT NULL COMMENT '库存数量',
  `start_date` datetime DEFAULT NULL COMMENT '秒杀开始时间',
  `end_date` datetime DEFAULT NULL COMMENT '秒杀结束时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

order_info表订单信息 里面含有商品的一些字段 是为了避免关联商品表直接显示

CREATE TABLE `order_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品ID',
  `delivery_addr_id` bigint(20) DEFAULT NULL COMMENT '收货地址ID',
  `goods_name` varchar(16) CHARACTER SET utf8mb4 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_date` datetime DEFAULT NULL COMMENT '支付时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;

秒杀订单表 同理 秒杀商品表

CREATE TABLE `miaosha_order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `order_id` bigint(20) DEFAULT NULL COMMENT '订单ID',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品ID',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;

商品列表页

为了取出商品的信息和秒杀信息 需要定义一个GoodsVo对象继承Goods对象 (这样就包含了goods和miaosha_goods的所有字段)

  • GoodsDao goodsVoList从两个表中查出所有商品信息
public interface GoodsDao {
    @Select("SELECT g.*,mg.miaosha_price,mg.stock_count,mg.start_date,mg.end_date " +
            "FROM goods g left join miaosha_goods mg ON g.id = mg.goods_id")
    List<GoodsVo> goodsVoList();

}
  • GoodsService 直接用dao的方法取出商品列表
@Autowired
    private GoodsDao goodsDao;

    public List<GoodsVo> goodsVoList(){
        return goodsDao.goodsVoList();
    }
  • GoodsController list方法 新增一段功能

调用GoodsService获取商品列表 加到model里面 然后交给thymeleaf进行渲染

商品详情页

  • GoodsController detail方法
@RequestMapping("/to_detail/{goodsId}")
    public String detail(@PathVariable("goodsId") long goodsId , Model model
    ,MiaoshaUser user)

这里用的是restapi 需要用PathVariable这个注解来获取请求参数goodsId
调用goodsService.getGoodsVoByGoodsId(goodsId)来获取商品信息
然后获取商品秒杀的开始时间和结束时间以及服务器当前时间(时间戳)

long endTime = goods.getEndDate().getTime();
        long now = System.currentTimeMillis();

然后根据时间戳来判断一下 秒杀状态(if else 三种 开始 结束 未开始)然后把倒计时和状态 用户 商品信息交给视图

倒计时 hide域

倒计时用jquery的定时器来实现 提交按钮包裹一个表单 表单有个hide域(goodsId)
把goodsId提交后端进行秒杀

秒杀功能

秒杀的请求提交到MiaoshaController的miaosha方法(路径do_miaosha)

public String miaosha(@RequestParam(value = "goodsId" , required = true) long goodsId
    , MiaoshaUser user , Model model)

首先判断一下user是否为null,为null没登录返回login页面,然后判断秒杀是否未开始或者已经结束 获取秒杀时间的时间戳与当前进行比较,如果不对的话 把错误信息传递到miaosha_fail页面 (errMsg)
然后检测库存,goods.getStockCount()如果不够返回CodeMsg.MIAO_SHA_OVER。
然后检测是否重复秒杀 调用orderService的getMiaoshaOrderByGoodsIdUserId方法获取秒杀订单如果获取到了说明已经秒杀过了返回CodeMsg.REPEATE_MIAO_SHA
最后下单获取订单信息返回订单信息

OrderInfo orderInfo = miaoshaService.miaosha(user,goods);
        model.addAttribute("orderInfo",orderInfo);
        model.addAttribute("goods",goods);

调用了miaoshaService的miaosha方法

miaoshaService miaosha秒杀核心逻辑

核心逻辑是 减库存 下单 然后存入秒杀订单 这三个动作要保持原子性 要开启事务支持

@Transactional
    public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
        //减库存 下单 存入秒杀订单
        goodsService.reduceStock(goods);
        OrderInfo order = orderService.createOrder(user,goods);
        MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
        miaoshaOrder.setUserId(user.getId());
        miaoshaOrder.setGoodsId(goods.getId());
        miaoshaOrder.setOrderId(order.getId());
        orderService.createMiaoshaOrder(miaoshaOrder);
        return order;
    }
  • goodsService reduceStock
    new一个MiaoshaGoods对象然后设置id,传给dao层把秒杀商品表的stock_count字段减1即可
    orderService createOrder
    new一个order对象然后把信息填充好
order.setCreateDate(new Date());
        //TODO 地址
        order.setDeliveryAddrId(0L);
        order.setGoodsCount(1);
        order.setGoodsName(goods.getGoodsName());
        order.setGoodsId(goods.getId());
        order.setGoodsPrice(goods.getMiaoshaPrice());
        order.setOrderChannel(1);
        order.setStatus(0);
        order.setUserId(user.getId());

然后交给dao层新建一个订单,并且返回新创建的订单id,这里要用到mybatis的一个注解selectKey
statement代表sql语句 before代表insert前执行还是后执行 resulttype代表返回类型

@SelectKey(statement = "select last_insert_id()" , keyProperty = "id", keyColumn = "id", before = false, resultType = long.class)

返回的id自动注入到对象中,方法返回值代表是否成功插入而不是id

订单详情页

  • 秒杀后进入订单页面
    MiaoshaService的miaosha方法返回的order直接渲染到订单页面上

秒杀压测 Jmeter压力测试

最终qps 在商品列表页 大约1200并发

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值