缓存菜品

问题说明

  • 性能瓶颈:用户端小程序展示的菜品数据都是通过查询数据库获得,当访问量较大时,数据库压力增大导致查询性能下降。
  • 用户体验影响:数据库压力过大会造成系统响应变慢,用户点击分类后需要等待几秒才能显示菜品,严重影响使用体验。
  • 问题本质:系统查询性能的瓶颈主要在数据库端,频繁的磁盘IO操作导致响应延迟
  • 结果表现:高并发场景下会出现”系统响应慢、用户体验差”的恶性循环。

实现思路

flowchart LR
    A[开始] -->|查询菜品| B[后端服务]
    B --> C{缓存是否存在}
    C -->|Yes| D[读取缓存]
    C -->|No| E[查询数据库]
    E --> F[载入缓存]

image-1

缓存菜品

@GetMapping("/list")  
@ApiOperation("根据分类id查询菜品")  
public Result<List<DishVO>> list(Long categoryId) {  
  
    // 构造key  
    String key = "dish_" + categoryId;  
  
    // 查询redis中是否存在菜品数据  
    List<DishVO> dishVOList = (List<DishVO>) redisTemplate.opsForValue().get(key);  
  
    // 如果存在,直接返回  
    if (dishVOList != null && dishVOList.size() > 0) {  
        return Result.success(dishVOList);  
    }  
  
    Dish dish = new Dish();  
    dish.setCategoryId(categoryId);  
    dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品  
  
    // 如果不存在,根据分类id查询数据库,将数据缓存到redis中  
    List<DishVO> list = dishService.listWithFlavor(dish);  
    redisTemplate.opsForValue().set(key, list);  
  
    return Result.success(list);  
}

Note

为什么可以直接将 List<DishVO> 类型的 list 直接放进 Redis:

从代码中可以看出,这是可以实现的,主要有以下几个原因:

  1. DishVO 实现了 Serializable 接口: 从 DishVO 类的定义可以看到它实现了 Serializable 接口:

    public class DishVO implements Serializable {
        // ...
    }

    这使得 DishVO 对象可以被序列化和反序列化。

  2. RedisTemplate 的默认序列化机制: 在 RedisConfiguration 中,虽然只设置了 key 的序列化器为 StringRedisSerializer:

    redisTemplate.setKeySerializer(new StringRedisSerializer());

    但 value 的序列化器采用的是 Spring Data Redis 的默认序列化器,即 JdkSerializationRedisSerializer。这个序列化器可以自动将实现了 Serializable 接口的对象序列化为字节流存储到 Redis 中。

  3. Java 的集合框架也实现了序列化: List 接口及其实现类(如 ArrayList)也都实现了 Serializable 接口,因此整个 List 对象包括其中的元素都可以被序列化。

虽然这种做法在技术上是可行的,但存在一些潜在问题:

  1. 性能问题:JDK 序列化相对于 JSON 序列化效率较低
  2. 存储空间:JDK 序列化产生的字节流通常比 JSON 格式更大
  3. 可读性:在 Redis 中无法直接查看数据内容
  4. 跨语言兼容性:JDK 序列化格式只能被 Java 程序解析

在实际项目中,通常会自定义 RedisTemplate 的 value 序列化器,比如使用 Jackson2JsonRedisSerializer 或 GenericJackson2JsonRedisSerializer,这样可以以 JSON 格式存储数据,提高可读性和跨平台兼容性。

清理缓存数据

  • 数据不一致现象:当数据库数据变更但未清理Redis缓存时,会导致用户端展示的数据与数据库实际数据不一致。例如将菜品价格从88元改为66元后,用户端仍显示88元。

  • 问题根源:修改操作仅更新了数据库,未同步清理Redis中的旧缓存数据,导致后续查询仍从Redis获取过期数据。

  • 需要清理缓存的操作:

    • 新增菜品:会影响所属分类的菜品列表
    • 修改菜品:特别是修改分类时会影响新旧两个分类的缓存
    • 删除菜品:需移除对应分类的菜品数据
    • 起售/停售:影响菜品可见性状态
  • 缓存键规则:使用”dish_分类ID”作为键名,如分类17的菜品缓存键为”dish_17”

Set keys = redisTemplate.keys("dish_*");
redisTemplate.delete(keys);

Note

除了修改操作可以只清理单个缓存,其他操作较为复杂,可直接将缓存全部清除

缓存套餐

Spring Cache

  • 框架定位: Spring Cache是一个实现了基于注解的缓存功能的框架,通过简单添加注解即可实现缓存功能。
  • 抽象层特性: 提供了一层抽象,底层可灵活切换不同的缓存实现(如EHCache、Caffeine、Redis)。
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-cache</artifactId>  
</dependency>
  • 实现切换机制: 自动识别项目引入的缓存客户端(如导入spring-data-redis则使用Redis实现),无需额外配置。

Spring Cache 提供了一套注解来简化缓存操作,主要包括以下几个核心注解:

1. @EnableCaching

  • 作用: 开启缓存注解功能,需添加在启动类上
  • 类比: 类似于事务管理的@Transactional注解启用方式
  • 使用位置:通常加在启动类上,如@SpringBootApplication同级
  • 注意事项:必须添加此注解才能使其他缓存注解生效

首先,需要在Spring Boot应用的启动类上添加@EnableCaching注解来启用缓存功能:

@SpringBootApplication
@EnableCaching  // 启用缓存
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

注意:在你的项目中,目前还没有添加@EnableCaching注解,需要先添加这个注解才能使用Spring Cache。

2. @Cacheable

  • 执行流程: 方法执行前先查询缓存,存在数据则直接返回;不存在则执行方法并将返回值存入缓存
  • 典型场景: 适用于查询方法,实现了”缓存命中则返回,未命中则查库+缓存”的完整逻辑
  • 设计对比: 与手动实现的菜品缓存逻辑完全一致,但通过注解自动完成

@Cacheable注解用于将方法的返回值放入缓存中。如果缓存中已经存在相应的key,则直接从缓存中获取数据,而不执行方法(最开始进入的是代理对象,若存在缓存,对应的方法不会被执行)。

示例:

@Service
public class DishService {
    
    @Cacheable(value = "dishCache", key = "#categoryId")
    public List<Dish> getDishesByCategoryId(Long categoryId) {
        // 查询数据库获取菜品列表
        return dishMapper.getByCategoryId(categoryId);
    }
    
    // 使用SpEL表达式定义复杂的key
    @Cacheable(value = "dishCache", key = "#dishId + '_' + #userId")
    public Dish getDishDetail(Long dishId, Long userId) {
        return dishMapper.getById(dishId);
    }
}

3. @CachePut

  • 核心功能: 将方法返回值放入缓存(仅写入不读取)
  • 与@Cacheable区别: 不执行缓存查询步骤,强制更新缓存数据
  • 键生成规则:
    • 格式:cacheNames::key
    • 动态键值:支持SpEL表达式动态计算
    • 常用写法:
      • #参数名.属性(如#user.id)
      • #result.属性(方法返回值属性)
      • #root.args[0].属性 #p0.属性 #a0.属性 (方法第一个参数)

@CachePut注解用于更新缓存。无论缓存中是否存在数据,都会执行方法,并将返回值存入缓存中。

示例:

@Service
public class DishService {
    
    @CachePut(value = "dishCache", key = "#dish.id")
    public Dish updateDish(Dish dish) {
        dishMapper.update(dish);
        return dish;
    }
}

4. @CacheEvict

  • 清理功能: 删除一条或多条缓存数据
  • 应用场景: 数据变更时保持缓存一致性

@CacheEvict注解用于清除缓存中的数据。

示例:

@Service
public class DishService {
    
    // 清除指定key的缓存
    @CacheEvict(value = "dishCache", key = "#id")
    public void deleteDish(Long id) {
        dishMapper.deleteById(id);
    }
    
    // 清除整个缓存区域的所有数据
    @CacheEvict(value = "dishCache", allEntries = true)
    public void updateDishCategory(Long categoryId) {
        // 更新菜品分类相关操作
    }
}

5. @Caching

@Caching注解用于组合多个缓存操作,可以在一个方法上同时使用多个@Cacheable@CachePut@CacheEvict注解。

示例:

@Service
public class DishService {
    
    @Caching(evict = {
        @CacheEvict(value = "dishCache", key = "#dish.categoryId"),
        @CacheEvict(value = "categoryCache", allEntries = true)
    })
    public void updateDish(Dish dish) {
        dishMapper.update(dish);
    }
}

6. @CacheConfig

@CacheConfig注解用于类级别的缓存配置,可以为整个类设置默认的缓存名称等属性。

示例:

@Service
@CacheConfig(cacheNames = "dishCache")
public class DishService {
    
    @Cacheable(key = "#id")  // 继承了类级别的缓存名称
    public Dish getDishById(Long id) {
        return dishMapper.getById(id);
    }
    
    @CacheEvict(key = "#id")  // 继承了类级别的缓存名称
    public void deleteDish(Long id) {
        dishMapper.deleteById(id);
    }
}

注意事项

  1. 需要在启动类上添加@EnableCaching注解启用缓存功能
  2. 使用Spring Cache时,缓存的key和value需要实现Serializable接口(如果使用默认的序列化方式)
  3. 注意缓存一致性问题,更新数据时要及时清除或更新缓存
  4. 可以通过配置自定义缓存管理器来设置缓存过期时间等属性

这些就是Spring Cache的主要注解和使用方式,可以大大简化缓存操作的代码。

实现

  • 开启缓存注解功能: 在项目启动类上加入@EnableCaching注解。
  • 加入缓存注解: 在用户端接口SetmealController的list方法上加入@Cacheable注解。
  • 加入清理缓存注解: 在管理端接口SetmealController的save、delete、update、startOrStop等方法上加入@CacheEvict注解。
@GetMapping("/list")  
@ApiOperation("根据分类id查询套餐")  
@Cacheable(cacheNames = "setmealCache",key = "#categoryId")  
public Result<List<Setmeal>> list(Long categoryId) {  
    Setmeal setmeal = new Setmeal();  
    setmeal.setCategoryId(categoryId);  
    setmeal.setStatus(StatusConstant.ENABLE);  
  
    List<Setmeal> list = setmealService.list(setmeal);  
    return Result.success(list);  
}
@PostMapping  
@ApiOperation("新增套餐")  
@CacheEvict(value = "setmealCache", key = "#setmealDTO.categoryId")  
public Result save(@RequestBody SetmealDTO setmealDTO) {  
    setmealService.saveWithDish(setmealDTO);  
    return Result.success();  
}
@DeleteMapping  
@ApiOperation("批量删除套餐")  
@CacheEvict(value = "setmealCache", allEntries = true)  
public Result delete(@RequestParam List<Long> ids){  
    setmealService.deleteBatch(ids);  
    return Result.success();  
}

添加购物车

image-2

接口设计

  • 请求方式: POST方法,符合新增类操作规范
  • 请求路径: /user/shoppingCart/add,包含用户端前缀和业务模块标识
  • 请求参数:
    • dishId: 菜品ID(整数类型,非必须)
    • setmealId: 套餐ID(整数类型,非必须)
    • dishFlavor: 口味描述(字符串类型,非必须)
  • 参数约束: 每次请求必须且只能提交dishId或setmealId中的一个,不能同时提交
  • 数据格式: 采用JSON格式通过请求体传输
  • 返回结果: 统一包含code、data、msg三个字段,与其他接口保持风格一致

数据库设计

  • 核心字段:
    • user_id: 用户标识,区分不同用户的购物车数据
    • dish_id/setmeal_id: 商品标识,分别记录菜品或套餐ID
    • dish_flavor: 存储菜品的口味选择
    • number: 商品购买数量
  • 冗余字段设计:
    • name: 商品名称(如”鱼2斤”)
    • image: 商品图片路径
    • amount: 商品单价(如¥72)
  • 设计优势:
    • 将原本需要多表联查的操作简化为单表查询,显著提升查询效率
    • 选择相对稳定的字段作为冗余字段(名称、图片、价格),避免频繁更新 小技巧
  • 创建时间: create_time记录商品加入购物车的时间戳

代码开发

  • 查询条件:
    • 套餐查询: SELECT * FROM shopping_cart WHERE user_id = ? AND setmeal_id = ?
    • 菜品查询: SELECT * FROM shopping_cart WHERE user_id = ? AND dish_id = ? AND dish_flavor = ?
  • 动态SQL实现:
    • 使用MyBatis动态SQL标签<if>根据传入条件动态拼接查询条件
    • 查询条件封装在ShoppingCart对象中传递
    • 相同商品不同口味视为不同购物车记录
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {  
    // 判断购物车中是否存在当前商品  
    ShoppingCart cart = new ShoppingCart();  
    BeanUtils.copyProperties(shoppingCartDTO, cart);  
    Long userId = BaseContext.getCurrentId();  
    cart.setUserId(userId);  
    List<ShoppingCart> list = shoppingCartMapper.list(cart);  
  
    // 已存在,只需数量加一  
    if (list != null && list.size() > 0) {  
        ShoppingCart cartInDB = list.get(0);  
        cartInDB.setNumber(cartInDB.getNumber() + 1);  
        shoppingCartMapper.updateNumberById(cartInDB);  
    } else {  
        // 不存在,插入一条购物车数据  
        // 判断当前添加的是菜品还是套餐  
        Long dishId = shoppingCartDTO.getDishId();  
        if (dishId != null) {  
            // 添加的是菜品  
            Dish dish = dishMapper.getById(dishId);  
            cart.setName(dish.getName());  
            cart.setImage(dish.getImage());  
            cart.setAmount(dish.getPrice());  
        } else {  
            // 添加的是套餐  
            Long setmealId = shoppingCartDTO.getSetmealId();  
            Setmeal setmeal = setmealMapper.getById(setmealId);  
            cart.setName(setmeal.getName());  
            cart.setImage(setmeal.getImage());  
            cart.setAmount(setmeal.getPrice());  
        }  
        cart.setNumber(1);  
        cart.setCreateTime(LocalDateTime.now());  
        shoppingCartMapper.insert(cart);  
    }  
}

DTO转Entity

在应用程序中,通常需要将前端传来的 DTO(Data Transfer Object)转换为 Entity(实体)对象,主要原因如下:

1. 职责分离和分层架构

DTO 和 Entity 虽然可能有相似的字段,但它们服务于不同的目的:

  • DTO(ShoppingCartDTO):专门用于在不同层之间传输数据,特别是前后端之间。它只包含传输所需的数据。
  • Entity(ShoppingCart):与数据库表结构对应,包含完整的业务数据和数据库操作所需的所有字段。

在你的例子中:

  • ShoppingCartDTO 只包含前端传来的基本数据:dishId、setmealId 和 dishFlavor
  • ShoppingCart Entity 包含完整的购物车信息,如 userId、name、image、amount、number、createTime 等

2. 数据完整性

在业务处理过程中,需要添加额外的信息才能构成完整的购物车记录:

// 从DTO获取基本信息
BeanUtils.copyProperties(shoppingCartDTO, cart);
// 添加当前用户ID(来自BaseContext,不是前端传递)
Long userId = BaseContext.getCurrentId();
cart.setUserId(userId);
// 设置创建时间
cart.setCreateTime(LocalDateTime.now());
// 设置商品数量
cart.setNumber(1);

这些信息在数据库操作中是必需的,但不应该由前端传递,因为:

  • userId 应该从当前会话获取,防止用户篡改
  • createTime 应该在服务端设置,保证数据准确性
  • number 是新增购物车项时设置的初始值

3. 安全性考虑

通过使用 Entity 对象,可以确保只有经过验证和处理的数据才会被存储到数据库中,避免了直接使用前端传递的数据可能带来的安全风险。

4. Mapper 层设计原则

Mapper 层专门负责与数据库交互,它应该操作与数据库表结构对应的 Entity 对象,而不是传输层的 DTO。这种设计遵循了分层架构的原则,使各层职责清晰,便于维护和测试。

总结来说,DTO 到 Entity 的转换是实现分层架构、保证数据完整性、提高安全性的重要步骤。这种设计模式确保了应用程序的各层只处理自己职责范围内的数据,避免了数据混乱和潜在的安全问题。

@Update("update shopping_cart set number = #{number} where id = #{id}")  
void updateNumberById(ShoppingCart shoppingCart);

MyBatis对象属性映射

在MyBatis框架中,#{number}#{id} 可以直接使用的原因如下:

  1. 对象属性映射: 在ShoppingCart实体类中,有idnumber属性,MyBatis会自动将传入的ShoppingCart对象中的同名属性映射到SQL语句中的占位符。

    public class ShoppingCart implements Serializable {
        private Long id;
        // ...
        private Integer number;
        // ...
    }
  2. MyBatis的OGNL表达式: MyBatis使用OGNL(Object-Graph Navigation Language)表达式来访问对象属性。当传入一个ShoppingCart对象时,#{number}#{id}会自动从该对象中获取对应的属性值。

  3. 参数名称匹配#{}语法中的名称需要与传入对象的属性名一致。在这个例子中,ShoppingCart对象正好有numberid属性,所以可以直接使用这些名称。

这种写法是MyBatis的标准用法,它通过反射机制自动获取对象属性值,并安全地设置到SQL语句中,同时防止SQL注入攻击。当调用updateNumberById方法并传入一个ShoppingCart对象时,MyBatis会自动提取该对象的numberid属性值,并替换SQL中的占位符。

查看购物车

  • 请求方式:采用GET方式,符合查询类操作规范
  • 路径设计:遵循项目风格使用user作为前缀,完整路径为/user/shoppingCart/list
  • 参数处理:无需显式传递userID,通过请求头中的token解析获取
  • 返回结构:返回Object数组,字段与购物车实体属性完全对应
public List<ShoppingCart> showShoppingCart() {  
    Long userId = BaseContext.getCurrentId();  
    ShoppingCart shoppingCart = ShoppingCart.builder()  
            .userId(userId)  
            .build();  
    List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);  
    return list;  
}

清空购物车

  • 请求方式:DELETE
  • 请求路径:/user/shoppingCart/clean
  • 参数处理:无需提交参数,后端通过用户ID识别当前用户
  • 返回结果:主要返回操作状态码(code)表示成功或失败
public void clean() {  
    Long userId = BaseContext.getCurrentId();  
    shoppingCartMapper.deleteByUserId(userId);  
}
@Delete("delete from shopping_cart where user_id = #{userId}")  
void deleteByUserId(Long userId);

删除购物车中一个商品

public void subShoppingCart(@RequestBody ShoppingCartDTO shoppingCartDTO) {  
    ShoppingCart cart = new ShoppingCart();  
    BeanUtils.copyProperties(shoppingCartDTO, cart);  
    Long userId = BaseContext.getCurrentId();  
    cart.setUserId(userId);  
    List<ShoppingCart> list = shoppingCartMapper.list(cart);  
    if (list != null && list.size() > 0) {  
        ShoppingCart cartInDB = list.get(0);  
        if (cartInDB.getNumber() == 1) {  
            // 当前商品在购物车中的份数为1,直接删除当前记录  
            shoppingCartMapper.deleteById(cartInDB.getId());  
        } else {  
            // 当前商品在购物车中的份数不为1,修改份数即可  
            cartInDB.setNumber(cartInDB.getNumber() - 1);  
            shoppingCartMapper.updateNumberById(cartInDB);  
        }  
    }  
}