一、页面缓存
一般做缓存都是需要频繁被读取,变更较少的情况,会考虑做缓存。
用redis做缓存,缓存页面。比如列表页面、详情页面等。
首先缓存商品列表页。
1.1 缓存商品列表页
在GoodsController
中,引入redis
依赖。在跳转页面的RequestMapping
中,添加produces
参数。
将商品列表页缓存起来需要的操作:
- 从redis里读取缓存
- 如果有页面,直接返回
- 如果没有,手动渲染模板
- 缓存到redis中,并把结果返回给输出端
1.2 修改页面跳转逻辑
添加redis
依赖和thymeleafViewResolver
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ThymeleafViewResolver thymeleafViewResolver;
添加ResponseBody
注解,在RequestMapping
中添加produce
参数
/**
* 跳转到商品列表页面
* @author 47roro
* @date 2022/4/3
* @param user
* @param model
* @return java.lang.String
**/
@RequestMapping(value = "/toList",produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model, User user, HttpServletRequest request, HttpServletResponse response){
//redis中获取页面,如果不为空,直接返回页面
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String)valueOperations.get("goodsList");
if(StringUtils.hasLength(html)){
return html;
}
model.addAttribute("user", user);
model.addAttribute("goodsList", goodsService.findGoodsVo());
//如果为空,手动渲染,存入redis,再返回
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
if(StringUtils.hasLength(html)){
valueOperations.set("goodsList", html, 60, TimeUnit.SECONDS);
}
return html;
}
/**
* 跳转商品详情页
*
* @param goodsId
* @return java.lang.String
* @author 47roro
* @date 2022/4/15
**/
@RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
@ResponseBody
public String toDetail(Model model, User user, @PathVariable Long goodsId, HttpServletRequest request, HttpServletResponse response) {
//redis中获取页面,如果不为空,直接返回页面
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsDetail:" + goodsId);
if (StringUtils.hasLength(html)) {
return html;
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
int secKillStatus = 0;
int remainSeconds = 0;
if (nowDate.before(startDate)) {
remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) {
secKillStatus = 2;
remainSeconds = -1;
} else {
secKillStatus = 1;
}
model.addAttribute("secKillStatus", secKillStatus);
model.addAttribute("remainSeconds", remainSeconds);
model.addAttribute("goods", goodsService.findGoodsVoByGoodsId(goodsId));
//如果为空,手动渲染,存入redis,再返回
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);
if (StringUtils.hasLength(html)) {
valueOperations.set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS);
}
return html;
}
1.3 测试
查看缓存
二、对象缓存
在一开始登录的时候,就已经把用户信息保存到redis中了。
用户大部分时候是不更新的,基本上不设置过期时间,如果用户做了相应的变更,如修改密码,那么缓存要怎么操作呢?
最简单的方法是删除redis。
三、压力测试
优化前QPS:250左右
缓存QPS:460左右
经过缓存优化后,QPS得到了近一倍的提升。
四、商品详情页面静态化
虽然用了页面缓存,速度得到了一定的提升,但是还是存在一些问题。现在渲染出来的是完整的html,即使缓存起来了,但是发送到前端的时候,发送的是完整的html,发送的数据量还是很大的。
我们需要把前端一些不需要变化的地方静态化,即前后端分离。变动的数据单独发送就行,不变的部分静态化,减小了发送的数据量。
目前来说,我们暂时不做前后端分离,只做页面静态化,然后做一些数据的更新。
4.1 跳转接口处理
首先把后端接口进行处理,以前是直接返回一个页面,现在是返回一个公共的返回对象,返回了DetailVo对象。
/**
* 跳转商品详情页,静态化
*
* @param goodsId
* @return java.lang.String
* @author 47roro
* @date 2022/05/04
**/
@RequestMapping("/detail/{goodsId}")
@ResponseBody
public RespBean toDetail(User user, @PathVariable Long goodsId) {
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
int secKillStatus = 0;
int remainSeconds = 0;
if (nowDate.before(startDate)) {
remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) {
secKillStatus = 2;
remainSeconds = -1;
} else {
secKillStatus = 1;
}
DetailVo detailVo = new DetailVo();
detailVo.setUser(user);
detailVo.setGoodsVo(goodsVo);
detailVo.setSecKillStatus(secKillStatus);
detailVo.setRemainSeconds(remainSeconds);
return RespBean.success(detailVo);
}
4.2 静态页面处理
把thymeleaf
的模板页面转化为静态页面,通过静态页面跳转,并且通过ajax,调用接口获取静态数据,手动进行渲染。
<script>
$(function () {
//countDown();
getDetails();
});
function getDetails(){
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:'/goods/detail/' + goodsId,
type:'GET',
success:function(data){
if(data.code == 200){
render(data.obj);
}else{
layer.msg("客户端请求出错");
}
},
error:function(){
layer.msg("客户端请求出错");
}
});
}
function render(detail){
var user = detail.user;
var goods = detail.goodsVo;
var remainSeconds = detail.remainSeconds;
if(user){
$("#userTip").hide();
}
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd HH:mm:ss"));
$("#remainSeconds").val(remainSeconds);
$("#goodsId").val(goods.id);
$("#goodsPrice").text(goods.goodsPrice);
$("#seckillPrice").text(goods.seckillPrice);
$("#stockCount").text(goods.stockCount);
countDown();
}
4.3 结果测试
正确获得数据。
五、秒杀静态化
秒杀按钮之前是个form表单提交的形式,跳转到秒杀控制器,进行订单的创建等。
<form id="secKillForm" method="post" action="/seckill/doSeckill">
<input type="hidden" name="goodsId" id="goodsId">
<button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
</form>
5.1 静态秒杀处理
把原来的表单提交形式,修改为普通的按钮,通过按钮触发function doSeckill()
。
<button class="btn btn-primary btn-block" type="button" id="buyButton" onclick="doSeckill()">立即秒杀
<input type="hidden" name="goodsId" id="goodsId">
</button>
function doSeckill(){
$.ajax({
url:'/seckill/doSeckill',
type:'POST',
data:{
goodsId:$("#goodsId").val()
},
success:function(data){
if(data.code == 200){
window.location.href="/orderDetail.htm?orderId="+data.obj.id;
}else{
layer.msg(data.message);
}
},
error:function(){
layer.msg("客户端请求出错");
}
})
}
5.2 跳转接口处理
还是进行一些超卖限购的判断,然后返回order对象。
/**
* 秒杀
* @author 47roro
* @date 2022/4/16
* @param model
* @param user
* @param goodsId
* @return java.lang.String
**/
@RequestMapping(value = "/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(Model model, User user, Long goodsId){
if(user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if(goods.getStockCount() < 1){
model.addAttribute("errmsg", RespBeanEnum.EMPT_STOCK.getMessage());
return RespBean.error(RespBeanEnum.EMPT_STOCK);
}
//判断是否重复抢购(mybatis plus)
SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId));
if(seckillOrder != null){
model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage());
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
Order order = orderService.seckill(user, goods);
return RespBean.success(order);
}
5.3 结果测试
其中碰到问题:无法跳转页面,返回data数据。后关闭浏览器的某些插件,就可以成功跳转。
跳转成功后,一些信息还没有进行渲染。
六、订单页面静态化
6.1 跳转接口处理
返回订单详情对象detail
,控制层
/**
* 跳转订单详情页面
* @author 47roro
* @date 2022/5/5
* @param user
* @param orderId
* @return com.example.seckill.vo.RespBean
**/
@RequestMapping("/detail")
@ResponseBody
public RespBean detail(User user, Long orderId) {
if (user == null) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
OrderDetailVo detail = orderService.detail(orderId);
return RespBean.success(detail);
}
service层
/**
* 获取订单详情
* @author 47roro
* @date 2022/5/5
* @param orderId
* @return com.example.seckill.vo.OrderDetailVo
**/
@Override
public OrderDetailVo detail(Long orderId) {
if(orderId == null){
throw new GlobalException(RespBeanEnum.ORDER_NOT_EXIST);
}
Order order = orderMapper.selectById(orderId);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(order.getGoodsId());
OrderDetailVo detail = new OrderDetailVo();
detail.setOrder(order);
detail.setGoodsVo(goodsVo);
return detail;
}
6.2 静态页面处理
发送ajax请求,通过接口获取静态数据,进行数据渲染。
<script>
$(function(){
getOrderDetail();
});
function getOrderDetail(){
var orderId = g_getQueryString("orderId");
$.ajax({
url:'/order/detail',
type:'GET',
data:{
orderId:orderId
},
success:function(data){
if(data.code == 200){
render(data.obj);
} else {
layer.msg(data.message)
}
},
error:function(){
layer.msg("客户端请求错误")
}
})
}
function render(detail){
var goods = detail.goodsVo;
var order = detail.order;
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#goodsPrice").text(goods.goodsPrice);
$("#createDate").text(new Date(order.createDate).format("yyyy-MM-dd HH:mm:ss"));
var status = order.status;
var statusText = "";
switch(status){
case 0:
statusText = "待支付";
break;
case 1:
statusText = "待发货";
break;
case 2:
statusText = "已发货";
break;
case 3:
statusText = "已收货";
break;
case 4:
statusText = "已退款";
break;
case 5:
statusText = "已完成";
break;
}
$("#status").text(statusText);
}
</script>
6.3 结果测试
七、解决库存超卖
在秒杀商品更新库存时,先判断库存数,有库存则减库存,无库存则不操作。
为用户id和商品id添加唯一索引,防止同一用户在高并发下秒杀多件商品。
但是索引只是解决了seckillorder的并发,没有解决seckillgoods数量减少的并发。
数量减少的并发是通过update操作,update操作每次会加行级排他锁,在更新的同时判断数量是否大于0。大于0才进行后续操作,等于0 则返回空,不创建新订单。这样就可以解决库存超卖的问题。
7.1 秒杀服务修改
在减库存时先判断库存是否大于0,如果等于0,则直接返回null,如果有库存则返回订单。同时,将用户id+商品id,和对应的订单存入redis。
/**
* 秒杀订单具体实现
* @author 47roro
* @date 2022/4/16
* @param user
* @param goods
* @return com.example.seckill.pojo.Order
**/
@Transactional
@Override
public Order seckill(User user, GoodsVo goods) {
//秒杀商品表减库存
SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id",
goods.getId()));
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
//seckillGoodsService.updateById(seckillGoods);
boolean result = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().setSql("stock_count" +
" = stock_count - 1").eq("goods_id", goods.getId()).gt("stock_count", 0));
if(!result){
return null;
}
//生成订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goods.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goods.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
seckillOrder.setGoodsId(goods.getId());
seckillOrderService.save(seckillOrder);
redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goods.getId(), seckillOrder);
return order;
}
7.2 控制层
在控制层进行跳转的时候,判断用户是否重复购买。从redis中查找数据。
/**
* 秒杀
* @author 47roro
* @date 2022/4/16
* @param model
* @param user
* @param goodsId
* @return java.lang.String
**/
@RequestMapping(value = "/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(Model model, User user, Long goodsId){
if(user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if(goods.getStockCount() < 1){
return RespBean.error(RespBeanEnum.EMPT_STOCK);
}
//判断是否重复抢购(mybatis plus)
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if(seckillOrder != null){
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
Order order = orderService.seckill(user, goods);
return RespBean.success(order);
}
7.3 结果测试
order 十条记录。
seckillOrder 十条记录。
seckillGoods 库存为0。
7.4 问题解决
如果在进行压力测试的时候,发现数据库里信息没有变化,可能原因是测试用的config.txt中的用户登录已失效。重新执行生成用户工具类,使这些用户重新登录,然后重新进行压力测试即可。