秒杀项目
介绍
视频地址:https://www.bilibili.com/video/BV1sf4y1L7KE
内容
1.项目框架搭建
-
SpringBoot环境搭建
-
集成Thymeleaf模板引擎
-
Mybatis
2.分布式会话
-
用户登陆
-
设计数据库
-
明文密码二次MD5加密
public class MD5Utils { private static final String salt = "1a2b3c4d";//盐 /** * MD5加密 * * @param str 机密的字符串 * @return */ public static String MD5(String str) { String s = DigestUtils.md5Hex(str); return s; } //第一次加密,固定盐 public static String firstPassP(String passwd) { String s1 = "" +salt.charAt(0) + salt.charAt(2) + passwd + salt.charAt(5) + salt.charAt(4); return MD5(s1); } //第二次加密,数据库盐 public static String secondPassP(String passwd2, String salt) { String str = "" +salt.charAt(0) + salt.charAt(2) + passwd2 + salt.charAt(5) + salt.charAt(4); return MD5(str); } //入库加密 public static String InputPassToDb(String inputPass, String salt) { String passwd1 = firstPassP(inputPass); String passwd2 = secondPassP(passwd1, salt); return passwd2; } //测试 //d3b1294a61a07da9b49b6e22b2cbd7f9 public static void main(String[] args) { String s = InputPassToDb("123456", "test6666"); System.out.println(s); } }
-
参数校验+全局异常处理
参数校验
@Data @AllArgsConstructor @ApiModel("登录请求参数") public class TloginVo { @NotNull @Ismobile//自定义校验手机号 @ApiModelProperty("手机号") private String mobile; @NotNull @ApiModelProperty("密码") private String password; }
@Retention(RetentionPolicy.RUNTIME) @Documented @Constraint( validatedBy = {IsMobileValidato.class} ) public @interface Ismobile { boolean required() default true; String message() default "手机号码格式不对"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
public class IsMobileValidato implements ConstraintValidator<Ismobile, String> { private boolean required = false; @Override public void initialize(Ismobile constraintAnnotation) { // ConstraintValidator.super.initialize(constraintAnnotation); required = constraintAnnotation.required(); } @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { if (required) { return ValidatorUtil.isMobile(s); } else { if (StringUtils.isEmpty(s)) { return true; } else { return ValidatorUtil.isMobile(s); } } } }
public class ValidatorUtil { private static final Pattern mobile_patten = Pattern.compile("[1]([3-9])[0-9]{9}$"); /** * 校验手机号 * @param mobile * @return */ public static Boolean isMobile(String mobile) { if (StringUtils.isEmpty(mobile)) { return false; } Matcher matcher = mobile_patten.matcher(mobile); return matcher.matches(); } }
异常处理
@Data @AllArgsConstructor @NoArgsConstructor public class GlobalException extends RuntimeException { private RespBeanEnum respBeanEnum; }
@RestControllerAdvice public class GlobalExceptionHandler { private RespBean respBean; @ExceptionHandler(Exception.class) public RespBean ExceptionHandler(Exception e) { if (e instanceof GlobalException) { GlobalException exception = (GlobalException) e; return RespBean.error(exception.getRespBeanEnum()); } else if (e instanceof BindException) { BindException bindException = (BindException) e; RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR); respBean.setMessage(bindException.getAllErrors().get(0).getDefaultMessage()); return respBean; } return RespBean.error(RespBeanEnum.ERROR); } }
-
-
共享Session
-
SpringSession
pom
<!--Spring Boot Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 对象池依赖--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
application.yml
#缓存配置 redis: host: 192.168.2.217 port: 6378 password: 123456 database: 0 timeout: 10000ms lettuce: pool: #最大连接数 默认8 max-active: 8 #最大连接阻塞时间 max-wait: 1000ms #最大空闲连接,默认8 max-idle: 200
配置完成之后,默认sessio会存入redis
-
Redis
配置
#缓存配置 redis: host: 192.168.2.217 port: 6378 password: 123456 database: 0 timeout: 10000ms lettuce: pool: #最大连接数 默认8 max-active: 8 #最大连接阻塞时间 max-wait: 1000ms #最大空闲连接,默认8 max-idle: 200
登陆代码
@Override public RespBean login(TloginVo tloginVo, HttpServletRequest request, HttpServletResponse response) { String mobile = tloginVo.getMobile(); String passwd = tloginVo.getPassword(); TUser tUser = tUserMapper.selectById(mobile); log.info(tUser.toString()); if (tUser == null) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); } //校验密码是否正确 if (MD5Utils.secondPassP(passwd, tUser.getSalt()).equals(tUser.getPassword())) { //生成Cookie String userTicket = UUIDUtil.getUuidCode(); //将用户信息存入redis redisTemplate.opsForValue().set("user:" + userTicket, tUser); CookieUtil.setCookie(request, response, "userTicket", userTicket); return RespBean.success(userTicket); } return RespBean.error(RespBeanEnum.LOGIN_ERROR); }
-
3.功能开发
- 商品列表
- 商品详情
- 秒杀
- 订单详情
4.系统压测
- Jmeter的基本操作
- 自定义变量模拟多用户
- Jmeter命令行的使用
- 正式压测
- 商品列表
- 秒杀
5.页面优化
-
页面缓存+URL缓存+对象缓存
页面缓存(将不长变动的页面,通过视图解析器渲染后存入第三方库)
因为要使用redis,所以先引入
//引用redis 缓存页面 @Autowired private RedisTemplate redisTemplate; //手动渲染前端页面,视图解析器 @Autowired private ThymeleafViewResolver thymeleafViewResolver;
修改controller代码
@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8", method = RequestMethod.GET) @ResponseBody
注解RequestMapping中produces属性可以设置返回数据的类型以及编码,可以是json或者xml,也可以是html
前端发请求,进入商品页面时,先用redis缓存查找
//第一次访问首先从redis查找 ValueOperations valueOperations = redisTemplate.opsForValue(); String html = (String) valueOperations.get("goodsdetail:" + goodsId); if (!StringUtils.isEmpty(html)) { return html; }
缓存无法击中的话,通过ThymeleafViewResolver (Thymeleaf视图解析器)渲染页面,然后以String类型存入Redis中,并返回到前端
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext); if (!StringUtils.isEmpty(html)) { valueOperations.set("goodsdetail" + goodsId, html, 60, TimeUnit.SECONDS); } return html;
对象缓存
一开始登陆的时候,将用户登陆信息存入redis
-
页面静态化(不通过模板引擎渲染,后端返回数据,前端调接口)、前后端分离
-
静态资源优化
-
CDN优化
6.接口优化
1.Redis预减库存减少数据库的访问
思路:
秒杀过程中,大量并发请求都需要查库判断商品库存数量,影响Mysql数据库性能,可以在系统初次化时,将库存数存入缓存,减少数据库的访问,库存秒杀完毕后将拦截大量无效请求
public class SeckillController implements InitializingBean
当一个类实现这个接口之后,Spring启动后,初始化Bean时,若该Bean实现InitialzingBean接口,会自动调用afterPropertiesSet()方法,完成一些用户自定义的初始化操作
/**
* 系统初始化,将库存数量加载到redis
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
List<TgoodInfoVo> list = tGoodsService.findGoodVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
list.forEach(tgoodInfoVo -> {
redisTemplate.opsForValue().set("seckillgoods:" + tgoodInfoVo.getId(), tgoodInfoVo.getStockCount());
EmpStack.put(tgoodInfoVo.getId(), false);
});
}
秒杀请求进来时,进行相应库存-1,并判断是否为0
//采用redis缓存预减库存,主要是为了减缓数据库压力
Long stock = value.decrement("seckillgoods:" + goodsId);
if (stock < 0) {
value.increment("seckillgoods:" + goodsId);//不让redis库存变为-1
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
2.内存标记减少redis的访问
描述:库存数10,有50000请求访问,每次通过redis缓存判断库存数量,当10个库存全部卖光时,后续无效请求依旧通过redis进行判断,增大redis负担
解决:通过内存标记,当库存为0时,将不再访问redis
private HashMap<Long, Boolean> EmpStack = new HashMap<>();
初始化时,将每个商品标记为false,当库存数为0时,置为true
流程:
如图所示,内存标记的好处是直接返回库存卖光之后的大量请求
RabbitMQ异步下单
-
SpringBoot整合RabbitMQ
spring: #RabbitMQ rabbitmq: #服务器 host: 192.168.2.217 #用户名 username: guest #密码 password: guest #虚拟主机 virtual-host: / #端口 port: 5673 listener: simple: #消费者最小数量 concurrency: 10 #消费者最大数量 max-concurrency: 10 #限制消费者每次只能处理一条消息,处理完在继续下一条消息 prefetch: 1 #启动是默认启动容器 auto-startup: true #被拒绝时重新进入队列 default-requeue-rejected: true template: retry: #发布重试,默认false enabled: true #重试时间,默认1000ms initial-interval: 1000ms #重试最大次数,默认3次 max-attempts: 3 #最大重试间隔时间 max-interval: 10000ms #重试的间隔乘数,比如配2。0 第一等10s 第二次等20s 第三次等40s multiplier: 1
<!--amqp--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
-
交换机
- 扇形交换机(Fanout exchange)
- 预声明的默认名称:amq.fanout
- 将消息路由给绑定到它身上的所有队列,不理会绑定的路由键。
- 用来交换机处理消息的广播路由。
- 扇形交换机(Fanout exchange)
-
直连交换机(Direct exchange)
- 预声明的默认名称:空串 or amq.direct
- 根据消息携带的routing key 将消息传递给对应的队列。
- 用来处理消息的单播路由。
-
主题交换机(Topic exchange)
- 预声明的默认名称:amq.topic
- 通过对消息的路由键和队列到交换机的绑定模式之间的匹配,将消息路由给一个或多个队列。
- 用来实现各种分发/订阅模式及其变种。
项目思路:
大量秒杀请求进来时,通过队列去做异步处理,后端返回给正在处理的状态,前端做轮询判断订单是否生成
优化代码
//推送消息到队列,异步操作,流量削峰
RabbitMessageVo rabbitMessageVo = new RabbitMessageVo(goodsId, user);//封装参数
seckillRabbitService.seckillMsg(JsonUtil.object2JsonStr(rabbitMessageVo));//异步处理
return RespBean.success(0);
@RabbitListener(queues = "seckill_queue")
public void seckillMsgRevicer(String msg) {
log.info("接收消息:" + msg);
RabbitMessageVo message = JsonUtil.jsonStr2Object(msg, RabbitMessageVo.class);//string-->object
//查询订单数量
TUser tUser = message.getTUser();
Long goodsId = message.getGoodsId();
TgoodInfoVo goodVo = tGoodsService.findGoodVoById(goodsId);
if (goodVo.getStockCount() < 1) {
return;
}
//判断是否重复抢购
TSeckillOrder o = (TSeckillOrder) redisTemplate.opsForValue().get("order:" + goodsId + ":" + tUser.getId());
if (o != null) {
return;
}
//下订单
TOrder seckill = tOrderService.seckill(tUser, goodVo);
}
7.安全优化
-
秒杀接口地址隐藏
描述:防止提前使用脚本访问秒杀接口
处理:前端发起秒杀请求时,首先调接口得到随机数path ,然后再进行url拼接后真正调用秒杀接口
第一次:url: "/seckill/path"
@RequestMapping(value = "/path", method = RequestMethod.GET) @ResponseBody public RespBean path(TUser user, Long goodsId, HttpServletResponse response) { if (user == null) { return RespBean.error(RespBeanEnum.ERROR); } String path = tOrderService.getPath(user, goodsId); return RespBean.success(path); }
@Override public String getPath(TUser user, Long goodsId) { String str = MD5Utils.MD5(UUIDUtil.getUuidCode() + "123456"); redisTemplate.opsForValue().set("seckillpath:" + user.getId() + ":" + goodsId, str, 5, TimeUnit.SECONDS); //存入redis并设置失效时间 return str; }
第二次:url: 'seckill/' + path + '/doSeckill'
@RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST) @ApiOperation("秒杀-rabbitmq优化") @ResponseBody public RespBean seckill3(@PathVariable String path, TUser user, Long goodsId) { //判断用户是否为空 if (user == null) { return RespBean.error(RespBeanEnum.SESSION_ERROR); } ValueOperations value = redisTemplate.opsForValue(); //校验path Boolean flag = tOrderService.checkPath(user, goodsId, path); log.info("隐藏地址校验:" + path + ":" + flag); if (!flag) { return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL); }
-
算术验证码
描述:限流,比如QPS:10000个请求,通过验证码可以将请求分散
工具:https://gitee.com/ele-admin/EasyCaptcha?_from=gitee_search
代码:
@RequestMapping(value = "/captcha", method = RequestMethod.GET)
public void verifyCode(TUser user, Long goodsId, HttpServletResponse response) {
if (user == null || goodsId < 0) {
throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
}
// 设置请求头为输出图片类型
response.setContentType("image/gif");
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
// 算术类型
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3);
captcha.setLen(3); // 几位数运算,默认是两位
captcha.getArithmeticString(); // 获取运算的公式:3+2=?
captcha.text(); // 获取运算的结果:5
//存入redis
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId, captcha.text(), 120, TimeUnit.SECONDS);
try {
captcha.out(response.getOutputStream()); // 输出验证码
} catch (IOException e) {
log.info("验证码生成失败:", e.getMessage());
}
将验证码存入redis,设置失效时间,在秒杀获取path时,进行判断
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean path(TUser user, Long goodsId, String captcha, HttpServletResponse response) {
if (user == null) {
return RespBean.error(RespBeanEnum.ERROR);
}
//验证码校验
Boolean captcha_check = tOrderService.checkCaptcha(user, goodsId, captcha);
if (!captcha_check) {
return RespBean.error(RespBeanEnum.ERROR_CAPTCHA);
}
String path = tOrderService.getPath(user, goodsId);
return RespBean.success(path);
}
接口防刷
redis设置 key = url:userid value=number
微服务,采用sentinel进行限流
软件架构
技术 | 版本 | 说明 |
---|---|---|
Spring Boot | 2.6.4 | |
MySQL | 8 | |
MyBatis Plus | 3.5.1 | |
Swagger2 | 2.9.2 | Swagger-models2.9.2版本报错,使用的是1.5.22 |
Kinfe4j | 2.0.9 | 感觉比Swagger UI漂亮的一个工具,访问地址是ip:端口/doc.html |
Spring Boot Redis |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· AI与.NET技术实操系列(六):基于图像分类模型对图像进行分类