1. 项目背景与核心需求
在餐饮管理系统开发中,菜品分类查询是最基础却高频使用的功能模块。我最近在重构一个外卖平台的后台服务时,发现原有的菜品查询接口存在两个明显痛点:一是当分类层级超过两级时查询效率直线下降,二是返回的菜品数据冗余严重导致前端渲染卡顿。于是决定基于SpringBoot重新设计一套分类ID查询菜品的解决方案。
这个功能看似简单,但实际涉及几个关键技术点:
- 如何设计高效的多级分类表结构
- 使用Spring Data JPA还是MyBatis作为持久层
- 查询结果DTO的字段动态筛选
- 高并发场景下的缓存策略
经过两周的迭代开发,最终实现的接口在300QPS压力测试下平均响应时间控制在80ms以内,相比旧系统提升近7倍。下面分享具体实现方案和踩坑经验。
2. 数据结构设计与持久层选型
2.1 分类表结构优化
传统方案常用邻接表存储分类关系:
sql复制CREATE TABLE category (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
parent_id BIGINT -- 父分类ID
);
这种结构虽然简单,但查询子分类需要递归操作。我改用闭包表方案:
sql复制CREATE TABLE category_closure (
ancestor BIGINT, -- 祖先节点ID
descendant BIGINT, -- 后代节点ID
depth INT, -- 层级深度
PRIMARY KEY (ancestor, descendant)
);
插入分类关系时同步维护闭包表,查询三级分类下的所有菜品只需单次JOIN:
java复制@Query("SELECT d FROM Dish d JOIN category_closure cc ON d.categoryId = cc.descendant WHERE cc.ancestor = :categoryId")
List<Dish> findByCategory(@Param("categoryId") Long categoryId);
2.2 持久层技术对比
项目初期尝试了三种方案:
- Spring Data JPA:开发速度快,但复杂查询需要写@Query注解
- MyBatis:SQL灵活但需要维护XML文件
- QueryDSL:类型安全但学习成本高
最终选择JPA+QueryDSL组合:
java复制public List<DishDTO> findDishesByCategory(Long categoryId) {
return queryFactory.select(Projections.bean(DishDTO.class,
dish.id,
dish.name,
dish.price
))
.from(dish)
.join(categoryClosure).on(dish.categoryId.eq(categoryClosure.descendant))
.where(categoryClosure.ancestor.eq(categoryId))
.fetch();
}
这种写法既保持了类型安全,又能灵活构造查询条件。
3. 接口性能优化实战
3.1 DTO字段动态返回
前端不同场景需要不同字段组合,传统做法是定义多个DTO类。我们改用Jackson的@JsonView注解:
java复制public class Dish {
public interface BaseView {}
public interface DetailView extends BaseView {}
@JsonView(BaseView.class)
private Long id;
@JsonView(BaseView.class)
private String name;
@JsonView(DetailView.class)
private String description;
}
@GetMapping(params = "view=detail")
@JsonView(Dish.DetailView.class)
public List<Dish> getDishesWithDetail(Long categoryId) {
return dishService.findByCategory(categoryId);
}
通过URL参数控制返回字段,减少不必要的数据传输。
3.2 多级缓存方案
采用Caffeine+Redis两级缓存:
java复制@Cacheable(cacheNames = "dishes", key = "#categoryId")
public List<DishDTO> getCachedDishes(Long categoryId) {
// 数据库查询
}
@CacheEvict(cacheNames = "dishes", key = "#dish.categoryId")
public void updateDish(Dish dish) {
// 更新操作
}
配置要点:
- 本地缓存过期时间5分钟,最大1000条
- Redis缓存过期时间30分钟,启用缓存穿透保护
- 更新操作同时清除两级缓存
4. 生产环境问题排查
4.1 N+1查询问题
即使使用了JOIN查询,测试时发现实际执行了N+1条SQL。原因是JPA默认对关联对象使用懒加载,需要在repository方法上添加@EntityGraph:
java复制@EntityGraph(attributePaths = {"category"})
List<Dish> findByCategoryId(Long categoryId);
4.2 缓存雪崩防护
压测时模拟缓存集中过期,数据库瞬时QPS飙升。解决方案:
- 对Redis缓存设置随机过期时间(基础30分钟±5分钟随机)
- 使用Hystrix做熔断降级
- 本地缓存作为最后防线
5. 扩展思考与优化方向
当前方案仍可改进的点:
- 分类信息变更时的缓存一致性(考虑使用CDC监听binlog)
- 海量菜品下的分页查询优化(游标分页代替传统分页)
- 多条件筛选时的索引设计(函数索引+复合索引)
实际开发中发现,简单的分类查询背后涉及数据库设计、持久层选型、缓存策略、接口规范等多个技术维度。建议在项目初期就建立完整的监控体系,特别是对缓存命中率和慢查询的监控,这对后期性能调优至关重要。