缓存菜品
问题说明
- 性能瓶颈:用户端小程序展示的菜品数据都是通过查询数据库获得,当访问量较大时,数据库压力增大导致查询性能下降。
- 用户体验影响:数据库压力过大会造成系统响应变慢,用户点击分类后需要等待几秒才能显示菜品,严重影响使用体验。
- 问题本质:系统查询性能的瓶颈主要在数据库端,频繁的磁盘IO操作导致响应延迟。
- 结果表现:高并发场景下会出现”系统响应慢、用户体验差”的恶性循环。
实现思路
flowchart LR A[开始] -->|查询菜品| B[后端服务] B --> C{缓存是否存在} C -->|Yes| D[读取缓存] C -->|No| E[查询数据库] E --> F[载入缓存]

缓存菜品
@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:从代码中可以看出,这是可以实现的,主要有以下几个原因:
DishVO 实现了 Serializable 接口: 从 DishVO 类的定义可以看到它实现了
Serializable接口:public class DishVO implements Serializable { // ... }这使得 DishVO 对象可以被序列化和反序列化。
RedisTemplate 的默认序列化机制: 在 RedisConfiguration 中,虽然只设置了 key 的序列化器为 StringRedisSerializer:
redisTemplate.setKeySerializer(new StringRedisSerializer());但 value 的序列化器采用的是 Spring Data Redis 的默认序列化器,即 JdkSerializationRedisSerializer。这个序列化器可以自动将实现了 Serializable 接口的对象序列化为字节流存储到 Redis 中。
Java 的集合框架也实现了序列化: List 接口及其实现类(如 ArrayList)也都实现了 Serializable 接口,因此整个 List 对象包括其中的元素都可以被序列化。
虽然这种做法在技术上是可行的,但存在一些潜在问题:
- 性能问题:JDK 序列化相对于 JSON 序列化效率较低
- 存储空间:JDK 序列化产生的字节流通常比 JSON 格式更大
- 可读性:在 Redis 中无法直接查看数据内容
- 跨语言兼容性: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);
}
}注意事项
- 需要在启动类上添加
@EnableCaching注解启用缓存功能 - 使用Spring Cache时,缓存的key和value需要实现Serializable接口(如果使用默认的序列化方式)
- 注意缓存一致性问题,更新数据时要及时清除或更新缓存
- 可以通过配置自定义缓存管理器来设置缓存过期时间等属性
这些就是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();
}添加购物车

接口设计
- 请求方式: 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对象中传递
- 相同商品不同口味视为不同购物车记录
- 使用MyBatis动态SQL标签
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}可以直接使用的原因如下:
对象属性映射: 在ShoppingCart实体类中,有id和number属性,MyBatis会自动将传入的ShoppingCart对象中的同名属性映射到SQL语句中的占位符。
public class ShoppingCart implements Serializable { private Long id; // ... private Integer number; // ... }MyBatis的OGNL表达式: MyBatis使用OGNL(Object-Graph Navigation Language)表达式来访问对象属性。当传入一个ShoppingCart对象时,
#{number}和#{id}会自动从该对象中获取对应的属性值。参数名称匹配:
#{}语法中的名称需要与传入对象的属性名一致。在这个例子中,ShoppingCart对象正好有number和id属性,所以可以直接使用这些名称。这种写法是MyBatis的标准用法,它通过反射机制自动获取对象属性值,并安全地设置到SQL语句中,同时防止SQL注入攻击。当调用updateNumberById方法并传入一个ShoppingCart对象时,MyBatis会自动提取该对象的number和id属性值,并替换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);
}
}
}