苍穹外卖Day7 | 缓存商品、购物车、SpringCache、缓存雪崩、缓存套餐
目录
缓存菜品
1. 问题说明
2. 实现思路
3. 代码开发
4. 功能测试
补充:缓存雪崩
缓存雪崩的定义
大量 key 失效引发缓存雪崩的原因分析
缓存雪崩的危害
缓存雪崩的解决方案
缓存套餐
1. Spring Cache
学习用例
@EnableCaching
@CachePut
@Cachable
@CacheEvict
2. 实现思路
3. 代码开发
4. 功能测试
添加购物车
1. 需求分析和设计
2. 代码开发
3. 功能测试
查看购物车
1. 需求分析和设计
2. 代码开发
3. 功能测试
清空购物车
1. 需求分析和设计
2. 代码开发
3. 测试
缓存菜品
对于小程序,如果短时间内有大量用户访问,对后端数据库的压力很大,需要大量查询,导致性能下降、用户体验感变差。
解决:将菜品数据存储到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 {@Autowiredprivate DishService dishService;@Autowiredprivate RedisTemplate redisTemplate;/*** 根据分类id查询菜品* @param categoryId* @return*/@GetMapping("/list")@ApiOperation("根据分类id查询菜品")public Result<List<DishVO>> list(Long categoryId){// 构造redis中的key,规则:dish_分类idString 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 {@Autowiredprivate DishService dishService;@Autowiredprivate 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,必须加一个注解 @RequestParampublic 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_开头的keycleanCache("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 {@Autowiredprivate 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 {@Autowiredprivate ShoppingCartMapper shoppingCartMapper;@Autowiredprivate DishMapper dishMapper;@Autowiredprivate SetmealMapper setmealMapper;/*** 添加购物车* @param shoppingCartDTO*/public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {// 判断当前加入到购物车的商品是否已经存在了ShoppingCart shoppingCart = new ShoppingCart();BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);// 通过ThreadLocal获得当前用户idLong 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. 测试
可以成功删除!
外卖刚好到了!今天任务完成!