文章目录
项目简介
为了准备校招,需要准备项目,在网上看了很多项目,最后决定做一个秒杀系统,虽然现在秒杀系统有点烂大街,但是项目的覆盖的点依然很广,主要解决在高并发场景下的高可用,以及可拓展问题。写个博客记录一下项目所有的设计思路以及编码细节。
第一版为没有优化最垃圾的版本
项目的技术架构
秒杀系统需要解决的问题
- 分布式会话
- 用户登录、商品列表、商品详情、订单详情模块
- 缓存优化
- 系统压测,测试系统的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(单例模式)
模板模式 接口->抽象类->实现类
- KeyPrefix接口 方法有getPrefix获取前缀和getExpireSeconds获取过期时间
- BasePrefix抽象类 封装了两个字段prefix 和 expireseconds 实现了两个方法 getprefix 通过类名加prefix字段组合 比如 UserKey:id getexpireSeconds直接获取字段
- 有两个构造函数 一个双参数 一个只有prefix参数把expire赋值为0
- 实现类(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
- getById方法直接调用dao
- login方法返回Codemsg对象,参数是loginvo前端的表单对象,先把id转换成long类型然后调用getbyid,返回的user进行判断,如果为null返回手机不存在
- 先获得前端的formpass,然后获取数据库里的dbpass,然后md5计算formpass和salt与dbpass进行比较,如果不正确返回password_error
LoginController(不包含会话)
- login方法
先进行数据校验
- 空校验 包括空和null
- 手机号是否符合规则调用validatorutil (正则表达式工具类)
然后调用service,看一下返回的状态码是不是0
jsr参数校验
省去校验参数的代码,在路由请求的方法参数前面加上@Valid注解,可以对参数进行校验,然后在实体类的字段上加上相应的注解
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min=32)
private String password;
自定义校验注解
自定义一个IsMobile注解
自定义注解可以模仿hibernate的已有注解 复制属性 和 注解上面的注解
主要有三个地方需要改定
- validatedBy = {IsMobileValidator.class} ,这个值是需要进行复杂验证所需要的辅助类
- message是默认信息
- 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对象都是这样的机制)
- 首先创建WebConfig对象实现WebMvcConfigurer接口,然后标记config注解
- 然后复写addArgumentResolvers方法(这个方法是default的 所以不是实现)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
- 创建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并发