苍穹外卖Day7 | 缓存商品、购物车、SpringCache、缓存雪崩、缓存套餐

发布于:2025-09-01 ⋅ 阅读:(21) ⋅ 点赞:(0)

目录

缓存菜品

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 {
    @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. 测试

可以成功删除!

外卖刚好到了!今天任务完成!


网站公告

今日签到

点亮在社区的每一天
去签到