目录
缓存菜品
对于小程序,如果短时间内有大量用户访问,对后端数据库的压力很大,需要大量查询,导致性能下降、用户体验感变差。
解决:将菜品数据存储到redis,使用spring data redis进行操作
1. 问题说明
2. 实现思路
先查缓存:这个思路类似于计算机网络中的缓存代理服务器机制。
会存储用户经常访问的 Web 页面、图片等资源。当有用户请求访问这些资源时,缓存代理服务器先检查本地缓存中是否有对应的内容。若有,直接将缓存的内容返回给用户,而不需要再从原始的 Web 服务器获取数据,减少了对原始服务器的请求压力 。
由于java中的数据类型和redis中的不完全相同,按照分类的粒度来存储,value部分对应java的List
,将这个List序列化成字符串存储到redis
3. 代码开发
第一部分:改造user/dishCotroller
package com.sky.controller.user;
import com.sky.constant.StatusConstant;
import com.sky.entity.Dish;
import com.sky.result.Result;
import com.sky.service.DishService;
import com.sky.vo.DishVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
@Api(tags = "C端-菜品浏览接口")
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 根据分类id查询菜品
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId){
// 构造redis中的key,规则:dish_分类id
String key = "dish_" + categoryId;
// 查询redis中是否存在菜品数据
List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
if (list != null && list.size() > 0){
// 如果存在,直接返回,无需查询数据库
return Result.success(list);
}
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE); //查询起售中的菜品
// 如果不存在,查询数据库,将查询到的数据存入redis中
list = dishService.listWithFlavor(dish);
redisTemplate.opsForValue().set(key, list);
return Result.success(list);
}
}
第二部分:当菜品有变动时应该清理redis中的数据缓存
这一部分是在admin端的DishController
package com.sky.controller.admin;
import com.sky.constant.StatusConstant;
import com.sky.dto.DishDTO;
import com.sky.dto.DishPageQueryDTO;
import com.sky.entity.Dish;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.DishService;
import com.sky.vo.DishVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Set;
/**
* 菜品管理
*/
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 新增菜品
* @param dishDTO
* @return
*/
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO){
log.info("新增菜品:{}",dishDTO);
dishService.saveWithFlavor(dishDTO);
// 清理受影响的缓存数据
String key = "dish_" + dishDTO.getCategoryId();
cleanCache(key);
return Result.success();
}
/**
* 菜品分页查询
* @param dishPageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("菜品分页查询")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO){
log.info("菜品分页查询:{}", dishPageQueryDTO);
PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
return Result.success(pageResult);
}
/**
* 菜品批量删除
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("菜品批量删除")
// 希望SpringMVC框架解析用逗号分隔的多个id,必须加一个注解 @RequestParam
public Result delete(@RequestParam List<Long> ids){
log.info("菜品批量删除:{}",ids);
dishService.deleteBatch(ids);
// 清理相关缓存数据,可能影响多个key,还需要查询数据库
// 简单起见,如果执行批量删除,就把redis中的缓存全部删掉
// 缓存雪崩
cleanCache("dish_*");
return Result.success();
}
/**
* 根据id查询菜品和对应的口味数据,需要回显到前端,所以使用VO
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询菜品")
public Result<DishVO> getById(@PathVariable Long id){
log.info("根据id查询菜品:{}",id);
DishVO dishVO = dishService.getByIdWithFlavor(id);
return Result.success(dishVO);
}
/**
* 根据id修改菜品基本信息和对应的口味信息
* @param dishDTO
* @return
*/
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO){
log.info("修改菜品:{}",dishDTO);
dishService.updateWithFlavor(dishDTO);
// 由于修改操作可能影响1/2份缓存数据,比较复杂
// 也是直接清理所有缓存数据
cleanCache("dish_*");
return Result.success();
}
@PostMapping("/status/{status}")
@ApiOperation("菜品起售停售")
public Result<String> startOrStop(@PathVariable Integer status, Long id){
dishService.startOrStop(status, id);
// 将所有菜品缓存数据清理掉,所有以dish_开头的key
cleanCache("dish_*");
return Result.success();
}
/**
* 根据分类id查询菜品
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品
List<DishVO> list = dishService.listWithFlavor(dish);
return Result.success(list);
}
/**
* 清理缓存数据
* @param pattern
*/
private void cleanCache(String pattern){
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}
}
这里要特别注意,在处理批量删除时,有缓存雪崩的问题,所以直接将redis中所有缓存都删掉
4. 功能测试
第一部分代码可以正常缓存数据并正确读取
补充:缓存雪崩
大量 key 失效导致查询数据库过多,这种情况属于缓存雪崩。缓存雪崩是缓存使用中比较常见的一种问题,以下是关于它的详细介绍:
缓存雪崩的定义
缓存雪崩是指在某一个时间段,缓存中大量的 key 同时失效 ,或者缓存服务整体不可用,导致大量原本应该访问缓存的请求直接落到了数据库上,使得数据库的负载瞬间过高,甚至可能导致数据库被压垮,进而使整个应用系统不可用。
大量 key 失效引发缓存雪崩的原因分析
- 过期时间集中设置:在项目中,如果对大量缓存 key 设置了相同或相近的过期时间,比如为了更新一批商品数据,将对应商品信息的缓存 key 都设置了 1 小时的过期时间。当 1 小时到期后,这些 key 同时失效,此时大量针对这些商品信息的请求就会直接打到数据库上,对数据库造成巨大的冲击。
- 缓存服务故障:除了大量 key 同时失效,如果缓存服务(如 Redis 集群)因为网络故障、服务器硬件问题、软件崩溃等原因突然不可用,所有依赖缓存的请求也都会直接转向数据库,这同样会引发缓存雪崩,导致数据库压力剧增。
缓存雪崩的危害
- 数据库负载过高:大量请求绕过缓存直接访问数据库,会使数据库的 CPU、内存、磁盘 I/O 等资源被迅速耗尽,导致数据库性能急剧下降,甚至出现服务不可用的情况。
- 系统响应变慢:由于数据库处理能力有限,大量请求排队等待处理,使得应用系统的响应时间大幅增加,用户体验变差,严重时可能导致用户流失。
- 服务可用性降低:如果数据库被压垮,整个依赖数据库的应用服务都可能无法正常提供服务,造成系统停机,给企业带来巨大的经济损失。
缓存雪崩的解决方案
- 设置随机过期时间:在设置缓存过期时间时,给每个 key 的过期时间加上一个随机值,避免大量 key 在同一时间失效。例如,原本设置的过期时间是 1 小时,可以改为在 50 分钟到 70 分钟之间随机取值。
- 缓存预热:在系统启动时,提前将一些热点数据加载到缓存中,避免在系统运行过程中大量请求同时查询数据库并写入缓存。可以通过定时任务、数据初始化脚本等方式来实现缓存预热。
- 多级缓存:采用多级缓存架构,比如同时使用本地缓存(如 Ehcache、Caffeine)和分布式缓存(如 Redis)。本地缓存可以快速响应用户请求,减少对分布式缓存的压力,当本地缓存未命中时再去查询分布式缓存,分布式缓存也未命中时才查询数据库。
- 缓存服务高可用:构建缓存服务的高可用集群,如使用 Redis Sentinel 或 Redis Cluster,当部分节点出现故障时,其他节点可以继续提供服务,保证缓存服务的可用性,降低因缓存服务不可用导致缓存雪崩的风险。
缓存套餐
解决:将菜品数据存储到redis,使用spring cache(由spring提供的缓存框架),进一步简化编码,提升开发效率
1. Spring Cache
使用时只需要在service上加一个缓存注解,很简单
SpringCache是如何知道我们使用哪个缓存,只需要在pom文件中导入redis的客户端,如spring data redis
@Cachable的逻辑和上面自定义的缓存逻辑非常类似
学习用例
@EnableCaching
在启动处CacheDemoApplication开启 @EnableCaching
@Slf4j
@SpringBootApplication
@EnableCaching//开启缓存注解功能
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class,args);
log.info("项目启动成功...");
}
}
在UserController中,针对不同的存取场景,为相应的函数添加合适的注解
@CachePut
知识点:SpEL表达式
例子: 这里的.是对象导航
@PostMapping
// 如果使用SpringCache缓存数据,key的生成=userCache::id
// SpEL表达式
//也可以写成cacheNames = "userCache", key = "#result.id"
// 从0开始,0表示取第一个参数
// @CachePut(cacheNames = "userCache", key = "#p0.id")
// @CachePut(cacheNames = "userCache", key = "#a0.id")
// @CachePut(cacheNames = "userCache", key = "#root.args[0].id")
@CachePut(cacheNames = "userCache", key = "#user.id")//将方法返回值存储到缓存中
public User save(@RequestBody User user){
userMapper.insert(user);
return user;
}
redis可以对key保存树形结构
测试这个@CachePut,可以正确写入
@Cachable
SpringCache底层是基于代理技术,一旦加入这个注解,SpringCache就会为其创建一个代理对象,在请求方法之前,先进入代理对象查询redis,如果查到之后就直接返回,不进入方法内部。如果redis中没有,就通过反射进入方法内部执行查询
@GetMapping
//key的生成=userCache::id
// 如果在redis中查找到了,直接返回
// 如果没找到,会通过反射进入函数内部执行--查数据库,返回数据,并将结果存到redis
@Cacheable(cacheNames = "userCache",key = "#id")
public User getById(Long id){
User user = userMapper.getById(id);
return user;
}
@CacheEvict
通过代理对象先将缓存中的数据删除,再执行方法内的代码--删除数据库的数据
下面两个方法分别是:删除一个、删除所有
@DeleteMapping
// key的生成 = userCache::id
// 这样配置只删除缓存中的一条数据
@CacheEvict(cacheNames = "userCache",key = "#id")
public void deleteById(Long id){
userMapper.deleteById(id);
}
@DeleteMapping("/delAll")
// 删除userCache下面所有的缓存数据
@CacheEvict(cacheNames = "userCache", allEntries = true)
public void deleteAll(){
userMapper.deleteAll();
}
测试可以成功删除缓存和数据库中的数据
2. 实现思路
3. 代码开发
user/SetmealController
/**
* 条件查询
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
// key = setmealCache::categoryId
@Cacheable(cacheNames = "setmealCache", key = "#categoryId")
public Result<List<Setmeal>> list(Long categoryId) {
admin/SetmealController
/**
* 新增套餐
* @param setmealDTO
* @return
*/
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmealCache", key = "#setmealDTO.categoryId")
public Result save(@RequestBody SetmealDTO setmealDTO) {
批量删除、修改套餐、套餐起售停售都直接删掉所有
// 清理redis中所有缓存数据
@CacheEvict(cacheNames = "setmealCache", allEntries = true)
4. 功能测试
添加购物车
1. 需求分析和设计
小巧思:通过name、image这样的冗余字段可以减少数据库IO,提高查询速度,值查询一张表即可
2. 代码开发
user/ShoppingCartController
package com.sky.controller.user;
import com.sky.dto.ShoppingCartDTO;
import com.sky.result.Result;
import com.sky.service.ShoppingCartService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user/shoppingCart")
@Slf4j
@Api(tags = "C端购物车相关接口")
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result add(@RequestBody ShoppingCartDTO shoppingCartDTO){
log.info("添加购物车,商品信息为:{}", shoppingCartDTO);
shoppingCartService.addShoppingCart(shoppingCartDTO);
return Result.success();
}
}
ShoppingCartMapper
package com.sky.mapper;
import com.sky.entity.ShoppingCart;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface ShoppingCartMapper {
/**
* 动态条件查询
* @param shoppingCart
* @return
*/
List<ShoppingCart> list(ShoppingCart shoppingCart);
/**
* 根据id修改商品数量
* @param shoppingCart
*/
@Update("update shopping_cart set number = #{number} where id = #{id}")
void updateNumberById(ShoppingCart shoppingCart);
/**
* 插入购物车数据
* @param shoppingCart
*/
@Select("insert into shopping_cart (name, user_id, dish_id, setmeal_id, dish_flavor, number, amount, image, create_time )" +
"values (#{name}, #{userId}, #{dishId}, #{setmealId}, #{dishFlavor}, #{number}, #{amount}, #{image}, #{createTime})")
void insert(ShoppingCart shoppingCart);
}
ShoppingCartServiceImpl
package com.sky.service.impl;
import com.sky.context.BaseContext;
import com.sky.dto.ShoppingCartDTO;
import com.sky.entity.Dish;
import com.sky.entity.Setmeal;
import com.sky.entity.ShoppingCart;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.mapper.ShoppingCartMapper;
import com.sky.service.ShoppingCartService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;
/**
* 添加购物车
* @param shoppingCartDTO
*/
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
// 判断当前加入到购物车的商品是否已经存在了
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
// 通过ThreadLocal获得当前用户id
Long userId = BaseContext.getCurrentId();
shoppingCart.setUserId(userId);
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
// 如果已经存在,只需将数量加一
if (list != null && list.size() > 0){
ShoppingCart cart = list.get(0);
cart.setNumber(cart.getNumber() + 1); //数量加一
shoppingCartMapper.updateNumberById(cart);
} else {
// 如果不存在,或者口味不一样,需要插入一条购物车数据
// 判断本次添加到购物车的是菜品还是套餐
Long dishId = shoppingCartDTO.getDishId();
if (dishId != null){
//本次添加到购物车的是菜品
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
// 本次添加的是套餐
Long setmealId = shoppingCart.getSetmealId();
Setmeal setmeal = setmealMapper.getById(setmealId);
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
//同样的代码,放到if-else外面
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
// 统一插入
shoppingCartMapper.insert(shoppingCart);
}
}
}
SHoppingCartMapper
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
<select id="list" resultType="com.sky.entity.ShoppingCart">
select * from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
</select>
</mapper>
ShoppingCartService
package com.sky.service;
import com.sky.dto.ShoppingCartDTO;
public interface ShoppingCartService {
/**
* 添加购物车
* @param shoppingCartDTO
*/
void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}
3. 功能测试
购物车数据库:口味不同的相同商品是不同的两条数据
查看购物车
1. 需求分析和设计
不需要请求参数,用户 id可以从ThreadLocal获取
2. 代码开发
ShoppingCartController
/**
* 查看购物车
* @return
*/
@GetMapping("/list")
@ApiOperation("查看购物车")
public Result<List<ShoppingCart>> list(){
List<ShoppingCart> list = shoppingCartService.showShoppingCart();
return Result.success(list);
}
ShoppingCartServiceImpl
/**
* 查看购物车
* @return
*/
public List<ShoppingCart> showShoppingCart() {
Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = ShoppingCart.builder()
.userId(userId)
.build();
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
return list;
}
3. 功能测试
清空购物车
1. 需求分析和设计
不需要传参,可从ThreadLocal获取用户id
2. 代码开发
user/ShoppingCartConrtroller
/**
* 清空购物车
* @return
*/
@DeleteMapping("/clean")
@ApiOperation("清空购物车")
public Result clean(){
shoppingCartService.cleanShoppingCart();
return Result.success();
}
ShoppingCartServiceImpl
/**
* 清空购物车
*/
public void cleanShoppingCart() {
Long userId = BaseContext.getCurrentId();
shoppingCartMapper.deleteByUserId(userId);
}
ShoppingCartMapper
/**
* 根据用户id删除购物车数据
* @param userId
*/
@Delete("delete from shopping_cart where user_id = #{userId}")
void deleteByUserId(Long userId);
3. 测试
可以成功删除!
外卖刚好到了!今天任务完成!