秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -02
- Github:China-Rainbow-sea/seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
- Gitee:seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
自定义参数解析器获取 User 对象
实现 WebMvcConfigurer ,优化登录-扩展知识点
获取浏览器传递的 cookie 值,进行参数解析,直接转成 User 对象,继续传递.
- 配置相关转换为对象的,配置类。UserArgumentResolver
package com.rainbowsea.seckill.config;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.utill.CookieUtil;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author Rainbowsea
* @version 1.0
* UserArgumentResolver: 自定义的一个解析器
*/
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
//装配UserService
@Resource
private UserService userService;
//判断你当前要解析的参数类型是不是你需要的?
@Override
public boolean supportsParameter(MethodParameter parameter) {
//获取参数是不是user类型
Class<?> aClass = parameter.getParameterType();
//如果为t, 就执行resolveArgument
return aClass == User.class;
}
//如果上面supportsParameter,返回T,就执行下面的resolveArgument方法
//到底怎么解析,是由程序员根据业务来编写
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String ticket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(ticket)) {
return null;
}
//不急,一会老师会给小伙伴debug
从Redis来获取用户
User user = userService.getUserByCookieByRedis(ticket, request, response);
return user;
//return UserContext.getUser();
}
}
自定义解析器,如果不是 User 对象的,是其他对象类的处理扩展。
- 同样还需要编写一个
WebConfig
的配置类。
package com.rainbowsea.seckill.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import java.util.List;
/**
* @author RainbowSea
* @version 1.0
*/
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
//装配
@Resource
private UserArgumentResolver userArgumentResolver;
//静态资源加载
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
//这里加入我们自定义的解析器到 HandlerMethodArgumentResolver列表中
//这样自定义的解析器工作
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
配置好,自定义的参数解析器获取 User 对象,就可以修改
package com.rainbowsea.seckill.controller;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 商品列表处理
*/
@Controller
@RequestMapping("/goods")
public class GoodsController {
@Resource
private UserService userService;
// 跳转到商品列表页
@RequestMapping(value = "/toList")
public String toList(Model model,User user) {
// //验证部分的代码,可以注销了,WebMvcConfigurer使用 mvc进行优化,避免每次都要
if (null == user) { // 用户没有成功登录
return "login";
}
// 将 user 放入到 model,携带该下一个模板使用
model.addAttribute("user", user);
return "goodsList";
}
}
// 跳转到商品列表页
//@RequestMapping(value = "/toList")
//public String toList(HttpSession session,
// Model model,
// @CookieValue("userTicket") String ticket,
// ) {
/*@RequestMapping(value = "/toList")
public String toList(Model model,
@CookieValue("userTicket") String ticket,
HttpServletRequest request,
HttpServletResponse response
) {
// @CookieValue("userTicket") String ticket 注解可以直接获取到,对应 "userTicket" 名称
// 的cookievalue 信息
if (!StringUtils.hasText(ticket)) {
return "login";
}
// 通过 cookieVale 当中的 ticket 获取 session 中存放的 user
//User user = (User) session.getAttribute(ticket);
// 改为从 Redis 当中获取
User user = userService.getUserByCookieByRedis(ticket, request, response);
if (null == user) { // 用户没有成功登录
return "login";
}
// 将 user 放入到 model,携带该下一个模板使用
model.addAttribute("user", user);
return "goodsList";
}*/
完成测试,启动项目,用户登录
浏览器输入 http://localhost:8080/login/toLogin,如果登录成功过,可进入商品列表
浏览器输入: http://localhost:8080/login/toLogin, 如果登录成功过,可进入商品列表
查看 Redis, 会看到登录用户信息
秒杀商品列表
秒杀商品的主要代码:没有进行秒杀优化的 V 1.0 版本
package com.rainbowsea.seckill.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rainbowsea.seckill.mapper.OrderMapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillGoods;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillGoodsService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service实现
* @createDate 2025-04-26 20:48:19
*/
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
implements OrderService {
@Resource
private SeckillGoodsService seckillGoodsService;
@Resource
private OrderMapper orderMapper;
@Resource
private SeckillOrderService seckillOrderService;
/**
* 秒杀商品,减少库存
*
* @param user
* @param goodsVo
* @return Order
*/
@Override
public Order seckill(User user, GoodsVo goodsVo) {
// 查询后端的库存量进行减一
SeckillGoods seckillGoods = seckillGoodsService
.getOne(new QueryWrapper<SeckillGoods>()
.eq("goods_id", goodsVo.getId()));
// 完成一个基本的秒杀操作【这快不具原子性】,后面在高并发的情况下,还会优化
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
seckillGoodsService.updateById(seckillGoods);
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 这里随便设置了一个初始值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
// 保存 order 账单信息
orderMapper.insert(order);
// 生成秒杀商品订单~
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
// 这里秒杀商品订单对应的 order_id 是从上面添加 order 后获取到的
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
// 保存 seckillOrder
seckillOrderService.save(seckillOrder);
return order;
}
}
秒杀 Controller 页面处理:
package com.rainbowsea.seckill.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
@Controller
@RequestMapping("/seckill")
public class SeckillController {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V1.0版本,后面在高并发的情况下,还会继续优化
*
* @param model 返回给模块的 model 信息
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("秒杀 V 1.0 ");
if (null == user) { //用户没有登录
return "login";
}
// 登录了,则返回用户信息给下一个模板内容
model.addAttribute("user", user);
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() < 1) { // 没有库存,不可以购买
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail"; // 返回一个错误页面
}
// 判断用户是否复购-判断当前购买用户的ID和购买商品id是否已经在商品秒杀表当中存在了。
SeckillOrder seckillOrder = seckillOrderService.getOne(
new QueryWrapper<SeckillOrder>()
.eq("user_id", user.getId())
.eq("goods_id", goodsId)
); // 这里的 column 不可以随便写,要对应上数据表当中的对应的“字段名”
if (seckillOrder != null) { // 不为 null ,说明用户购买过了,进行了复购
model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage());
return "secKillFail"; // 返回一个错误页面
}
// 抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) { // 说明抢购失败了,由于什么原因
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail"; // 返回一个错误页面
}
// 走到这里,说明抢购成功了,将信息,通过 model 返回给页面
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
System.out.println("秒杀 V 1.0 ");
return "orderDetail"; // 进入到订单详情页
}
}
补充:
fulshdb # 清空 Redis 当中的 keys 的所有内容
Jmeter 工具
由于涉及的篇幅过多,关于 Jmeter 的具体使用,大家可以移步至:✏️✏️✏️ https://blog.csdn.net/qq_58981141/article/details/146150407。进行学习。
使用 Redis 缓存一些查询变化少的页面
首先,将一些查询变化少的页面,添加加载到 Redis 当中。这样我们查询加载某一个页面的时候,只需要查询后端 DB 数据库一次,然后将其缓存大 Redis 当中。 我们就可以直接从 Redis 缓存数据当中获取到,如果从而就不需要对后端数据库 DB 进行查询了,减少了对 DB 数据库查询,而是从 Redis 当中查找。这里我们设置将 Redis 缓存时间设置为 60 秒,当过期时,我们就重新从后端 DB 数据库当中查询获得,然后,再次缓存到 Redis 当中。需要注意的是:我们缓存的页面,一般必须是为一种查询变化少的页面,数据变化少的页面。
- 多用户在查看商品列表和商品详情的时候,每一个用户都需要到 DB 查询。
- 对 DB 查询的压力很大,比如 10000 人,在 1 分钟都查看商品列表,就会有 10000 次对 DB 操作。
- 但是我们商品信息并不会频繁的变化,所以你查询回来的结果都是一样的。
- 我们可以通过 Redis 缓存页面来进行优化,这样可以将 1 分钟内多次查询 DB,优化成 1 次查询,减少 DB 压力。
商品页面-使用 Redis 缓存
package com.rainbowsea.seckill.controller;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* 商品列表处理
*/
@Controller
@RequestMapping("/goods")
public class GoodsController {
@Resource
private UserService userService;
@Resource
private GoodsService goodsService;
// Redis 渲染
@Resource
private RedisTemplate redisTemplate;
// Thymeleaf 手动渲染
@Resource
private ThymeleafViewResolver thymeleafViewResolver;
/**
* 跳转到商品列表页 ,使用上 Redis 缓存的页面
*
* @param model
* @param user 用户
* @param request
* @param response
* @return html 返回的是一个页面对象
*/
@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
@ResponseBody // 使用了 Redis 缓存页面需要添加
public String toList(Model model, User user,
HttpServletRequest request,
HttpServletResponse response) {
// //验证部分的代码,可以注销了,WebMvcConfigurer使用 mvc进行优化,避免每次都要
// 先从 Redis 中获取页面,如果不为空,直接返回页面
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsList");
// html 不为空,说明从 Redis 当中获取到了内容,不需要从DB当中获取
if (StringUtils.hasText(html)) {
return html;
}
if (null == user) { // 用户没有成功登录
return "login";
}
// 将 user 放入到 model,携带该下一个模板使用
model.addAttribute("user", user);
//展示商品
model.addAttribute("goodsList", goodsService.findGoodsVo());
// 如果为从 Redis 中取出页面为 null,则手动渲染,存入到 Redis 中
WebContext webContext = new WebContext(request, response,
request.getServletContext(),
request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
if (StringUtils.hasText(html)) { // html 不为空,进入 if ,说明从页面当中获取的页面,存入到 Redis 当中
// 表示: 每 60s 更新一次 Redis 页面缓存,即60s后,该页面缓存失效,Redis会清除页面缓存。
valueOperations.set("goodsList", html, 60, TimeUnit.SECONDS);
// 注意:我们这里的这个 goodsList 不可以随便写,而是同我们本身项目当中的 /templates 目录下存在的页面当中映射导入的
}
//return "goodsList";
return html;
}
}
完成测试
启动相关服务和项目
登录->进入商品列表页
查看 Redis
商品详情-页面 使用 Redis 缓存
package com.rainbowsea.seckill.controller;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* 商品列表处理
*/
@Controller
@RequestMapping("/goods")
public class GoodsController {
@Resource
private UserService userService;
@Resource
private GoodsService goodsService;
// Redis 渲染
@Resource
private RedisTemplate redisTemplate;
// Thymeleaf 手动渲染
@Resource
private ThymeleafViewResolver thymeleafViewResolver;
/**
* 跳转商品详情页面,使用 上 Redis 缓存
*
* @param model
* @param user
* @param goodsId
* @return String 跳转到对应 templates 下对应的html页面
*/
@RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
@ResponseBody // 注意:这里需要返回一个我们需要的 html 进行一个解析的渲染。所以这个注解必须要有
public String toDetail(Model model, User user,
@PathVariable("goodsId") Long goodsId,
HttpServletResponse response,
HttpServletRequest request) {
//使用页面缓存
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsDetail:" + goodsId);
if (StringUtils.hasText(html)) {
return html;
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 说明: 返回秒杀商品详情时,同时返回该商品的秒杀状态和秒杀的剩余时间
// 为了配合前端展示秒杀前端的状态
// 1. 变量 secKillStatus 秒杀状态 0表示:秒杀未开始,1:秒杀进行中,2:秒杀已经结束
// 2. 变量 remainSeconds 剩余秒数: >0表示:还有多久开始秒杀, 0:表示秒杀进行中,-1:表示秒杀结束
// 秒杀开始时间
Date startDate = goodsVo.getStartDate();
// 秒杀结束时间
Date endDate = goodsVo.getEndDate();
// 当前时间
Date nowDate = new Date();
int secKillStatus = 0;
int remainSeconds = 0;
// 如果nowDate 在 startDate 前,说明还没有开始秒杀
if (nowDate.before(startDate)) {
// startDate.getTime() 返回的是毫秒 , 1000 表示 每秒
secKillStatus = 0; // 秒杀未开始
remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) {
secKillStatus = 2; // 表示秒杀已经结束
remainSeconds = -1; // 表示秒杀已经结束
} else {
// 秒杀进行中
secKillStatus = 1;
remainSeconds = 0;
}
// 将 secKillStatus 和 remainSeconds 放入到 model,携带给下模板页使用
model.addAttribute("secKillStatus", secKillStatus);
model.addAttribute("remainSeconds", remainSeconds);
model.addAttribute("goods", goodsVo);
// 如果为从 Redis 中取出页面为 null,则手动渲染,存入到 Redis 中
WebContext webContext = new WebContext(request, response,
request.getServletContext(),
request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);
if (StringUtils.hasText(html)) { // html 不为空,进入 if ,说明从页面当中获取的页面,存入到 Redis 当中
// 表示: 每 60s 更新一次 Redis 页面缓存,即60s后,该页面缓存失效,Redis会清除页面缓存。
valueOperations.set("goodsDetail", html, 60, TimeUnit.SECONDS);
// 注意:我们这里的这个 goodsList 不可以随便写,而是同我们本身项目当中的 /templates 目录下存在的页面当中映射导入的
}
return html;
//return "goodsDetail";
}
}
完成测试
启动相关服务和项目
登录->进入商品列表页->点击商品详细
查看 Redis
注意观察, 商品列表页和商品详情页, 失效是否生效了
压测-商品列表接口
Jmeter 设置
准备好多用户测试脚本, 保证多用户可以登录, 如果环境变化了, 重新生成即可
配置 Jmeter
测试结果:
对象缓存问题
主要:登录时,我们首先读取的是 Redis 是否存在,但是当我们的用户更新了,就会导致 Redis 当中存储的信息是不对的,和 DB 当中的数据不一致。
当然有小伙伴会有这样的疑虑, 如果我们商品信息修改了, 会存在短时间内(比如 1 分
钟), Redis 页面缓存和商品信息不一致的问题, 怎么办?
2、思路 1 : 可以将 Redis 页面缓存更新时间减小, 比如设置成 10s, 甚至更短.
3、思路 2 : 可以在更新商品信息时, 同时更新 Redis 页面缓存, 通常完整的系统会有后
台管理程序, 更新 Redis 页面缓存的原理和机制是一样的
当用户登录成功后, 就会将用户对象缓存到 Redis
好处是解决了分布式架构下的 Session 共享问题
但是也带来新的问题, 如果用户信息改变, 存在 用户信息和 Redis 缓存用户对象数据
不一致问题-也就是对象缓存问题
解决思路:
- 编写方法, 当用户信息变化时, 就更新用户在 DB 的信息, 同时删除该用户在 Redis 的
缓存对象
- 这样用户就需要使用新密码重新登录, 从而更新用户在 Redis 对应的缓存对象.
- 我们以修改用户密码为例来演示, 用户其它信息修改类似
- 用户信息修改接口,通常是在后台系统调用的,老师写出即可-然后使用
UserService.java 增加 API 接口
package com.rainbowsea.seckill.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.vo.LoginVo;
import com.rainbowsea.seckill.vo.RespBean;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author huo
* @description 针对表【seckill_user】的数据库操作Service
* @createDate 2025-04-24 15:38:01
*/
public interface UserService extends IService<User> {
/**
* 更改密码 ,更新 Redis 当中的用户缓存信息。
* @param userTicket
* @param password
* @param request
* @param response
* @return
*/
RespBean updatePassword
(String userTicket, String password,
HttpServletRequest request, HttpServletResponse response);
}
UserServiceImpl.java , 实现 API 接口
package com.rainbowsea.seckill.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rainbowsea.seckill.exception.GlobalException;
import com.rainbowsea.seckill.mapper.UserMapper;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.utill.CookieUtil;
import com.rainbowsea.seckill.utill.MD5Util;
import com.rainbowsea.seckill.utill.UUIDUtil;
import com.rainbowsea.seckill.utill.ValidatorUtil;
import com.rainbowsea.seckill.vo.LoginVo;
import com.rainbowsea.seckill.vo.RespBean;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author huo
* @description 针对表【seckill_user】的数据库操作Service实现
* @createDate 2025-04-24 15:38:01
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate redisTemplate;
/**
* 更改用户密码,同时立马更新缓存到 Redis 当中的信息
* @param userTicket
* @param password
* @param request
* @param response
* @return
*/
@Override
public RespBean updatePassword(String userTicket,
String password,
HttpServletRequest request,
HttpServletResponse response) {
// 更新用户密码,同时删除用户在 Redis的缓存对象
User user = this.getUserByCookieByRedis(userTicket,request,response);
if(user == null) {
// 抛出异常
throw new GlobalException(RespBeanEnum.MOBILE_NOT_EXIST);
}
// 设置新密码
user.setPassword(MD5Util.inputPassToDBPass(password,user.getSlat()));
int i = userMapper.updateById(user);
if(i == 1) { // 更新成功
redisTemplate.delete("user:"+userTicket);
return RespBean.success();
}
return RespBean.error(RespBeanEnum.PASSWROD_UPDATE_FAIL);
}
}
UserController.java, 增加 API
package com.rainbowsea.seckill.controller;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.vo.RespBean;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 用户登录,返回用户信息
* <p>
* 返回用户信息,同时我们也演示如何携带参数
*/
@Controller
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
/**
* 返回用户信息,同时我们也演示如何携带参数
*
* @param user 用户信息,这里的 User对象,我们是通过使用我们上面配置的一个 UserArgumentResolver 自定义自定义参数解析器获取 User 对象
* @param address 地址
* @return RespBean
*/
@RequestMapping("/info")
@ResponseBody
public RespBean info(User user, String address) {
return RespBean.success(user);
}
@RequestMapping("/updpwd")
@ResponseBody
public RespBean updatePassword(String userTicket,
String password,
HttpServletRequest request,
HttpServletResponse response) {
return userService.updatePassword(userTicket, password, request, response);
}
}
在 Redis 找一个测试用户
在重新使用 13300000257 登录, 会重新生成一个 Redis 用户缓存对象
特别提醒:测试完毕后, 将我们的数据库用户和 config.txt 多用户脚本恢复原来的状态
提示:再进行操作前,记得把 Redis 执行 flushdb , 给一个干净的环境.
注意点:这里我们采用的是虚拟机,而我们安装的 Redis 是安装在虚拟机当中的,可能存在 IP 地址的变化,对应我们项目当中的 Redis 的 IP 地址,需要作出相应的修改。
通过 MySQL 默认锁,解决复购,超购问题
当多用户在高并发秒杀商品时, 不能出现超卖和生成多条订单
修改:OrderServiceImpl.java,执行更新 MySQL 数据库的操作,该方法上添加上,@Transactional 注解
,表示启用 MySQL 默认的锁,进行事务控制处理。
注意:这里我们存储在 Redis 当中的 key-value 的设计是:key表示:order:userId:goodsId Value表示订单 seckillOrder
package com.rainbowsea.seckill.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rainbowsea.seckill.mapper.OrderMapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillGoods;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillGoodsService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service实现
* @createDate 2025-04-26 20:48:19
*/
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
implements OrderService {
@Resource
private SeckillGoodsService seckillGoodsService;
@Resource
private OrderMapper orderMapper;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private RedisTemplate redisTemplate;
/**
* 秒杀商品,减少库存,V2.0 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】 ,
* 添加上 @Transactional,注解进行一个MySQL 默认的事务隔离级别
*
* @param user
* @param goodsVo
* @return Order
*/
@Transactional
@Override
public Order seckill(User user, GoodsVo goodsVo) {
// 查询后端的库存量进行减一
SeckillGoods seckillGoods = seckillGoodsService
.getOne(new QueryWrapper<SeckillGoods>()
.eq("goods_id", goodsVo.getId()));
//分析
// 1. MySQL 在默认的事务隔离级别 【REPEATABLE-READ】 下
// 2. 执行 update 语句时,会在事务中锁定要更新的行
// 3. 这样可以防止其它会话在同一行执行 update,delete
// 说明: 只要在更新成功时,返回 true,否则返回 false
// column 必须是,数据库表当中的字段,不可以随便写
boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0)); // gt 表示大于
if(!update) { // 如果更新失败,说明已经没有库存了
return null;
}
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 这里随便设置了一个初始值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
// 保存 order 账单信息
orderMapper.insert(order);
// 生成秒杀商品订单~
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
// 这里秒杀商品订单对应的 order_id 是从上面添加 order 后获取到的
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
// 保存 seckillOrder
seckillOrderService.save(seckillOrder);
// 将生成的秒杀订单,存入到 Redis,这样在查询某个用户是否已经秒杀了这个商品时
// 直接到 Redis 中查询,起到优化效果
// key表示:order:userId:goodsId Value表示订单 seckillOrder
redisTemplate.opsForValue().set("order:"+user.getId()+":" +
goodsVo.getId(),
seckillOrder);
return order;
}
}
修改:SeckillController.java 使用上,我们添加了,事务控制的接口,进行调用。
package com.rainbowsea.seckill.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
@Controller
@RequestMapping("/seckill")
public class SeckillController {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
@Resource
private RedisTemplate redisTemplate;
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V2.0版本,利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
*
* @param model 返回给模块的 model 信息
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("秒杀 V 1.0 ");
if (null == user) { //用户没有登录
return "login";
}
// 登录了,则返回用户信息给下一个模板内容
model.addAttribute("user", user);
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() < 1) { // 没有库存,不可以购买
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail"; // 返回一个错误页面
}
// 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
// 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsId Value表示订单 seckillOrder),
// 获取对应的秒杀订单,如果有,则说明该
// 用户已经桥抢购了,每人限购一个
SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
if(null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
model.addAttribute("errmsg",RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
return "secKillFail"; // 返回一个错误页面
}
// 抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) { // 说明抢购失败了,由于什么原因
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail"; // 返回一个错误页面
}
// 走到这里,说明抢购成功了,将信息,通过 model 返回给页面
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
System.out.println("秒杀 V 2.0 ");
return "orderDetail"; // 进入到订单详情页
}
}
测试:
- 确保多用户的 userticket 已经正确的保存到 Redis 中
- 启动线程组,进行测试
- 测试结果, 不在出现超卖问题
最后:
“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”