为了准备校招,需要准备项目,在网上看了很多项目,最后决定做一个秒杀系统,虽然现在秒杀系统有点烂大街,但是项目的覆盖的点依然很广,主要解决在高并发场景下的高可用,以及可拓展问题。写个博客记录一下项目所有的设计思路以及编码细节。
第一版为没有优化最垃圾的版本
学会在高并发场景下的常见解决思路 缓存,异步,优雅的代码
需要的依赖有 web mysql驱动 mybatis redis thymleaf
CodeMsg和Result是为了给前端返回的结果对象
public static final CodeMsg SUCCESS = new CodeMsg(0,"success");
public static final CodeMsg SERVER_ERROR = new CodeMsg(500100,"服务端异常");
配置文件配置mybatis然后
在启动类上面配置扫描注解注册
@MapperScan(“com.seckill.dao”)
RedisConfig类读取配置文件中的配置,通过注解@ConfigurationProperties将配置导入字段
注册JedisPool jedis连接池 单独封装的原因是因为 之前封装在Service里面报错了
循环引用出错
作用:通过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(带前缀),多个模块比如商品或订单id可能重复
模板模式 接口->抽象类->实现类
直接把实现类的静态方法传入到get(set…)方法里,get方法里面会获取前缀以及失效时间信息
第一次是因为避免密码明文在互联网上传输,第二次加密是为了防止数据库被脱库后密码泄露
需要使用apache codec包里的DigestUtils.md5Hex(src)方法来进行md5加密,
使用formpasstodbpass可以把前端表单传过来的密码和数据库里的盐一起计算,得到的结果可以和数据库里进行比对
需要的静态资源和框架
domain包下建立MiaoshaUser实体类和db里字段对应,并且id即为手机号
vo包下建立一个LoginVo对象封装前端的表单数据 电话和密码
-新建MiaoshaUserDao接口,用注解的方式写一个getById方法
@Select("SELECT * FROM miaosha_user where id = #{id}")
MiaoshaUser findById(@Param("id") long id);
然后调用service,看一下返回的状态码是不是0
省去校验参数的代码,在路由请求的方法参数前面加上@Valid注解,可以对参数进行校验,然后在实体类的字段上加上相应的注解
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min=32)
private String password;
自定义一个IsMobile注解
自定义注解可以模仿hibernate的已有注解 复制属性 和 注解上面的注解
主要有三个地方需要改定
@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 {
然后实现两个方法即可 init 和 isValid
本质上init可以获取到自定义注解IsMobile的值(比如required),然后根据这个值来校验手机号 是否正确(比如required为false,并且参数为null 空 就返回true)
使用注解来进行参数校验,出现异常会直接抛出bindexception (spring),这样不友好,使用spring提供的全局异常处理机制来解决
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler
public Result 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
以前实现Service层返回CodeMsg不够语义化 最好能直接返回true 或者 false,中途遇到错误直接抛出异常
那么需要定义一个全局异常,全局异常包含CodeMsg字段
因为秒杀肯定需要多台服务器,如果用户的请求打到了第二个服务器上那么也要保持会话状态。服务器同步麻烦 也乱
利用uuid(通用唯一标识符)来创建token,登录成功后,token作为key,用户信息作为value保存到redis服务器中,并且把token作为cookie保存到客户端里,客户端请求时从校验token是否存在并取出用户信息
java的util包提供获得uuid的方法不过 uuid带’-'这个字符需要替换掉
更新login逻辑 把token作为key user作为value加入到redis里然后在前端设置cookie
首选错误在页面错误thymeleaf跳转失败,因为我跳转到路由上了应该是后端的模板(如login/to_login 正确为login)
然后修改过来总是重定向到login页面,查看setcookie设置上了。
然后单独访问/goods/to_list路由,network不显示信息(这里不小心过滤了只保留xhr(对象))
最后认识到时cookie路径的问题
直接调用redisService.get方法
把login方法的set redis逻辑提取addcookie方法
private void addCookie(HttpServletResponse response , MiaoshaUser user)
然后更新getbytoken方法 (addcookie需要response)
public MiaoshaUser getByToken(HttpServletResponse response ,String token)
每个需要判断登录的都要获取cookie或者param里的token然后取出redis里的user
这样代码冗余,可以利用springmvc的参数解析机制,自定义参数注入让spring帮我们把user对象注入到方法的参数里(request对象 Model对象都是这样的机制)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List resolvers) {
resolvers.add(userArgumentResolver);
}
}
这样我们的业务逻辑都到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的所有字段)
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 goodsVoList();
}
@Autowired
private GoodsDao goodsDao;
public List goodsVoList(){
return goodsDao.goodsVoList();
}
调用GoodsService获取商品列表 加到model里面 然后交给thymeleaf进行渲染
@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 三种 开始 结束 未开始)然后把倒计时和状态 用户 商品信息交给视图
倒计时用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方法
核心逻辑是 减库存 下单 然后存入秒杀订单 这三个动作要保持原子性 要开启事务支持
@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;
}
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
最终qps 在商品列表页 大约1200并发