做苍穹外卖这个项目的时候,新增菜品和删除菜品这两个接口,是我最早写的“看起来很简单”的接口。简单到什么程度?Controller里一个方法,Service里一个insert/delete,好像就完事了。但真正在本地联调、测试、跑通整个流程之后,我才发现菜品这一块的坑比想象中多——它牵扯到口味表、套餐关联、缓存一致性、事务边界、公共字段填充,还牵扯到“管理端改了数据,用户端能不能马上看到”这个非常现实的问题。
这篇东西不讲大道理,就讲我在写苍穹外卖菜品新增和删除时,踩过的坑、趟过的路,以及最终落地的完整分析和代码思路。适合正在做苍穹外卖项目、或者在做类似外卖/餐饮管理系统的人参考。无论你是刚写到这个模块,还是已经写完但总觉得哪里不对劲,都可以对照着看。
1. 菜品模块在苍穹外卖里到底承担什么职责
1.1 管理端和用户端的数据流转
先盘一下菜品在整个项目里的位置。苍穹外卖分为管理端和用户端:管理端是给商家用的,用户端是给顾客点餐用的。菜品是这两个端衔接的核心数据——商家在管理端新增一个菜品,用户端的小程序或APP里就要能正常浏览到;商家把菜品删掉,用户端就再也看不到这个菜,也不能再下单。
这两个操作背后的数据一致性是非常关键的。管理端写入的是MySQL,用户端查询的时候,很多数据会经过Redis缓存(尤其是根据分类查菜品列表这个接口)。如果你只改了数据库而没管缓存,用户端可能半天都看不到新菜品——这就是典型的缓存一致性问题,后面我会专门展开。
还有一个容易被忽略的点:管理端操作菜品的接口路径是/admin/dish,用户端查询菜品的接口路径是/user/dish,两者表面上互不相干,实际上共享同一张dish表。这就意味着,管理端的每一次写操作,都在间接影响用户端的读操作。写代码的时候脑子里要时刻有一根弦:我改的不只是一张表,而是一条完整的数据链路。
1.2 菜品表和口味表的拆分设计
先看表结构。苍穹外卖里菜品相关的核心表有两张:dish和dish_flavor。
dish表的主干字段包括:id、name、category_id(归属分类)、price、image、description、status(0表示停售,1表示起售),还有一套公共字段:create_time、update_time、create_user、update_user。
dish_flavor表是口味表,字段很少:id、dish_id、name、value。一个菜品对应多个口味记录。比如“香辣鸡腿堡”有“辣度:不辣/微辣/中辣/特辣”和“加料:生菜/芝士/培根”两组口味,dish_flavor表里就会存多条记录,每条记录通过dish_id指向同一个菜品。
这里有个设计问题值得琢磨:为什么不把口味直接存到dish表里?比如搞一个flavors字段存JSON?我整理过两种方案的对比,看完你就明白苍穹外卖为什么选择拆表:
| 对比维度 | 拆成两张表 | 单表存JSON字符串 |
|---|---|---|
| 数据规范性 | 符合第一范式,一对多关系清晰 | 多值属性冗余在一行,违背范式 |
| 按口味查询/统计 | 可以SQL精准查(如“找出所有辣度包含中辣的菜品”) | 只能like模糊匹配,逻辑复杂且性能差 |
| 扩展性 | 新增一种口味类型就是加几条记录,不用动表结构 | 得改字符串内容,还可能结构不统一 |
| 修改局部口味 | 精确定位到某条flavor记录增删改 | 必须把整个JSON字符串取出来解析再重写 |
| 复杂度 | 多一张表,插入/删除时多一步操作 | 看似简单,后期改造成本极高 |
结论很明确:拆表虽然让代码多写几步,但数据边界清楚,业务扩展空间大。这就是经典的一对多表设计,苍穹外卖里传递这个设计思路,面试的时候也是可以展开讲的点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新增菜品:核心不是 insert 语句,而是数据怎么组装正确
新增菜品的业务逻辑,表面看是“插一条dish记录”,实际上要处理四层问题:参数接收用什么样的数据结构、主键怎么回填、公共字段谁去填充、口味子表怎么一起写进去。
2.1 为什么前端参数要用 DTO 来接
先看Controller的接收参数类型。苍穹外卖管理端新增菜品接口,接收的是DishDTO,而不是Dish实体。原因是前端传过来的JSON结构是这样的:
json复制{
"name": "香辣鸡腿堡",
"categoryId": 11,
"price": 18.5,
"image": "https://xxx.jpg",
"description": "外酥里嫩",
"status": 1,
"flavors": [
{"name": "辣度", "value": "不辣,微辣,中辣,特辣"},
{"name": "加料", "value": "生菜,芝士,培根"}
]
}
注意,这个结构里多了一个flavors字段,而dish表里根本没有这个列。如果你直接用Dish实体去接,要么Jackson反序列化时把flavors忽略掉——那口味数据全丢了;要么报错。就算不报错,Service里也无从下手处理口味列表。
所以必须先有个DishDTO,把dish自身的字段和flavors列表都装进去,在Service里再手动拆开、组装。苍穹外卖项目里还有配套的DishVO,用于列表展示场景——比如商家后台的菜品列表,除了菜品基础信息,还要带出分类名称、口味列表。DTO管“输入”,VO管“输出”,各司其职。
另外,DTO上还可以挂校验注解,比如@NotBlank(message = "菜品名称不能为空")、@NotNull(message = "分类不能为空"),Controller方法参数前加@Validated就能在入口处拦截非法参数,不用把校验逻辑散落在Service里。教学项目里一般不会写太细的校验,但这个设计思维值得你保留。
2.2 主键回填:口味数据必须要拿到 dish_id
开始写ServiceImpl时很自然的思路是:
java复制Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.insert(dish);
然后问题来了——口味表要以dish_id作为外键,这个dish_id从哪来?如果dish表的主键是自增主键,insert之前你根本不知道这条记录会得到什么id。解决办法就是MyBatis的useGeneratedKeys:
xml复制<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into dish(name, category_id, price, image, description, status, create_time, update_time, create_user, update_user)
values(#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>
useGeneratedKeys="true" + keyProperty="id"的意思是:数据库生成的自增主键,自动回填到传入的dish对象的id属性上。这样执行完insert,dish.getId()就有值了。
这个点看似简单,但在很多真实项目里都有人栽跟头——忘了配useGeneratedKeys,或者keyProperty写错,导致后面取不到id、口味表插入时外键全是null。我自己的习惯是:任何自增主键的insert,一律把useGeneratedKeys写上,别指望默认行为帮你兜底。
拿到dishId之后,遍历DishDTO里的flavors:
java复制Long dishId = dish.getId();
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && !flavors.isEmpty()) {
flavors.forEach(flavor -> flavor.setDishId(dishId));
dishFlavorMapper.insertBatch(flavors);
}
注意循环里把每个flavor的dishId都set上。这个步骤很容易漏,一旦漏了,口味记录就不知道属于哪个菜品,而且数据库层面不一定有物理外键约束——查不出来,在用户端菜品详情页就是“空口味”的状态。
2.3 公共字段自动填充:谁在替你维护 create_time 和 create_user
dish表有四个公共字段:create_time、update_time、create_user、update_user。如果每个Mapper的insert/update都手动set一遍,代码会非常啰嗦,而且容易漏。苍穹外卖里用的是自定义注解+AOP切面的方案。
大致思路分三步。
第一步,定义一个注解@AutoFill,带一个枚举属性Operation,取值为INSERT或UPDATE:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
Operation value();
}
第二步,把注解标在Mapper方法上:
java复制@AutoFill(Operation.INSERT)
void insert(Dish dish);
@AutoFill(Operation.UPDATE)
void update(Dish dish);
第三步,AOP切面拦截所有带有@AutoFill注解的Mapper方法,通过反射拿到实体对象,填充公共字段。而“当前登录用户是谁”这个信息,通过ThreadLocal实现的BaseContext来获取——登录成功后就把当前用户id放进BaseContext,切面里取出来,填到create_user和update_user上。
这样做代码非常干净,Service层不需要手动set这些字段。但有两件事要注意:
- 如果切面没有生效(比如AOP依赖没引入、切点表达式写错了),字段会是null,数据库层如果有非空约束就直接报错;没有非空约束,数据进去了但审计信息缺失。
- 如果你在Service里手动set了
createTime,又同时配置了自动填充,会互相覆盖,容易出怪问题。我的建议是二选一,既然项目里设计了AOP方案,Service就不要再手动set了。
2.4 为什么这个 Service 方法必须加事务
新增菜品是“先插dish主表,再插dish_flavor子表”的两步操作。如果第一步成功、第二步失败,数据库里就留下了一个没有口味数据的“残废菜品”。所以一定要加事务:
java复制@Transactional(rollbackFor = Exception.class)
public void saveWithFlavor(DishDTO dishDTO) {
// 插入dish
// 插入flavor
}
关于rollbackFor = Exception.class这个参数,说一下我的理解。Spring默认只在RuntimeException和Error时回滚事务,如果抛出的是受检异常(比如IOException),事务不会回滚。所以凡是自己写@Transactional,我都习惯带上rollbackFor = Exception.class——这是一个防御性写法,成本为零,收益可能很大。
另一个问题是事务失效的经典场景:同一个类内部方法互相调用,不会走Spring代理,事务不生效。比如你在DishServiceImpl里写了一个私有方法,里面加了@Transactional,从saveWithFlavor里调用——这个事务其实是白加的。这个坑我放到第5节踩坑部分再详细展开。
3. 删除菜品:三个隐藏深坑,每个都能让你线上翻车
如果说新增菜品是“组装数据”,删除菜品就是“守住边界”。删除动作本身SQL很简单,难的是删除之前你要确认这个菜能不能删、删了之后关联数据怎么办。
3.1 起售状态的菜品不能删
想象一个场景:商家后台还有个“香辣鸡腿堡”在正常起售,顾客下单都需要看这个菜品。这个时候管理员直接把它删掉了,正在点餐的用户会看到菜品消失、或者订单详情里出现“菜品不存在”之类的异常。所以删除之前必须先校验状态:
java复制Dish dish = dishMapper.getById(id);
if (dish != null && dish.getStatus() == StatusConstant.ENABLE) {
throw new DeletionNotAllowedException("菜品起售中不能删除");
}
这里用的是常量类方式去判断状态,代码里别裸写0和1。StatusConstant.ENABLE一眼就能看懂是起售,可读性好很多。后面你接手别人的代码,看到dish.getStatus() == 1还得猜这个1到底代表什么——这种隐性成本能省就省。
3.2 被套餐关联的菜品不能删
苍穹外卖里还有一个“套餐”的概念:套餐是由多个菜品组合而成的。套餐和菜品之间通过中间表setmeal_dish关联:setmeal_id指向套餐,dish_id指向菜品。
如果某个菜品已经被包含在某个套餐里,你还把它删了,那么套餐的配置就悬空了——套餐到时候卖的是个“不存在”的菜品组合,这在真实业务中是绝对不允许的。所以删除前需要查中间表:
java复制List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && !setmealIds.isEmpty()) {
throw new DeletionNotAllowedException("菜品被套餐关联,不能删除");
}
这个SQL的要点是in查询:传入一个ids列表,查出所有关联的setmeal_id集合,只要非空就说明有引用关系,直接拒绝删除。为什么要用in而不是for循环逐个查?因为批量删除时ids可能有好几个,一次in查询比多次单查效率高,也省IO。
这里还隐藏着一个对称的业务规则:套餐模块删除套餐的时候,也要反向检查套餐里有没有菜品?不用,套餐删了中间表记录跟着删就行,菜品本身不受影响。但菜品删除要检查套餐,这个单向约束别搞反了。
3.3 删了主表,别忘了口味表
这一条我愿称之为“新手必踩坑”。直接执行delete from dish where id = ?之后,数据库层面确实没有了dish主表的数据,但是dish_flavor表里那些dish_id指向它的口味记录还在。它们成了永远访问不到的孤儿数据。
为什么数据库没有自动清理?因为大多数实际项目根本不建物理外键——物理外键对插入性能、分库分表、数据迁移都不友好,团队普遍用“逻辑关联、代码保证一致性”的方式。所以删除菜品时,代码里必须显式把口味表也删掉:
java复制for (Long id : ids) {
dishMapper.deleteById(id);
dishFlavorMapper.deleteByDishId(id);
}
写到这里我顺便提一个很多人忽略的细节:顺序问题。建议先删口味表、再删菜品主表,还是反过来?从业务一致性来说,先删子表再删主表更稳——万一删主表失败,至少子表已经清理了,不会留下“有口味无主菜”的孤儿。当然在事务保护下,最终都会回滚,但顺序养成好习惯总没坏处。
3.4 批量删除还要注意接口的参数设计
Controller里删除接口是这样的:
java复制@DeleteMapping
public Result delete(@RequestParam List<Long> ids) { ... }
前端传多个id时,通常用?ids=1&ids=2&ids=3这种形式,SpringMVC会自动把多个同名参数绑定成List<Long>。也有项目用/delete/{ids}路径拼接,或JSON数组传参,具体用哪种要看你们前后端约定。
我遇到过一种情况:后端接口定义是@RequestParam List<Long> ids,前端却用Content-Type: application/json把[1,2,3]发过来,结果Spring根本接不到。这种问题排查起来很让人抓狂,因为后端代码看起来完全没问题。最好的办法就是提前定好接口文档,明确参数格式,别让前后端各自脑补。
4. 完整代码链路:从 Controller 到 SQL 每一层该做什么
前两节把这个模块的难点拆完了,这一节把完整的链路串一遍,给你一份可以直接对照的代码骨架。
4.1 Controller 层
DishController里两个核心方法:
java复制@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品管理")
public class DishController {
@Autowired
private DishService dishService;
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
dishService.saveWithFlavor(dishDTO);
return Result.success();
}
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result delete(@RequestParam List<Long> ids) {
dishService.deleteBatch(ids);
return Result.success();
}
}
这个分层其实就是经典的三层架构职责划分:Controller只做参数接收和结果返回,不写任何业务逻辑。业务校验、数据组装、事务控制都应该在Service层做。很多初学者喜欢把校验和业务逻辑写成Controller里的一大坨,当时觉得省事,后面前端多了一个调用方、或者要复用这套逻辑时,后悔都来不及。
4.2 Service 层
完整骨架:
java复制@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
@Autowired
private SetmealDishMapper setmealDishMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
@Transactional(rollbackFor = Exception.class)
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.insert(dish);
Long dishId = dish.getId();
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && !flavors.isEmpty()) {
flavors.forEach(flavor -> flavor.setDishId(dishId));
dishFlavorMapper.insertBatch(flavors);
}
cleanCache("dish_*");
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteBatch(List<Long> ids) {
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
if (dish != null && dish.getStatus() == StatusConstant.ENABLE) {
throw new DeletionNotAllowedException("菜品起售中不能删除");
}
}
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && !setmealIds.isEmpty()) {
throw new DeletionNotAllowedException("菜品被套餐关联,不能删除");
}
for (Long id : ids) {
dishFlavorMapper.deleteByDishId(id);
dishMapper.deleteById(id);
}
cleanCache("dish_*");
}
private void cleanCache(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
这里有个地方我要强调一下:删除接口虽然叫“单个删除/批量删除”,但代码里一定要同时处理口味表和套餐关联校验。我之前就见过有人只写了dishMapper.deleteById(id),把口味表忘得干干净净,最后用户端菜品详情页报错才回头看代码。
4.3 Mapper 和 SQL
DishMapper.xml里主要是insert、deleteById、getById,前面已经给了insert的写法。delete和select比较简单,重点看DishFlavorMapper.xml的批量插入和按菜品删除:
xml复制<insert id="insertBatch">
insert into dish_flavor(dish_id, name, value)
values
<foreach collection="flavors" item="flavor" separator=",">
(#{flavor.dishId}, #{flavor.name}, #{flavor.value})
</foreach>
</insert>
<delete id="deleteByDishId">
delete from dish_flavor where dish_id = #{dishId}
</delete>
SetmealDishMapper.xml的关联查询:
xml复制<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select setmeal_id
from setmeal_dish
where dish_id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</select>
写SQL时有个小细节:#{}是预编译占位符,会自动处理参数类型,能防SQL注入;${}是字符串拼接,除非是表名/列名这种动态场景,否则一律用#{}。这个习惯在写foreach的in查询时尤其重要,别为了省事直接拼字符串。
4.4 为什么新增和删除后都要清 Redis 缓存
用户端有一个根据分类查询菜品的接口,为了提高性能加了Spring Cache缓存。缓存key的格式通常是dish::分类ID。也就是说,管理端新增了一个菜品,如果缓存不清理,用户端查列表时命中的还是旧缓存,新菜品根本看不到;删除同理,用户端可能还会看到已经被删掉的菜。
所以新增和删除操作最后都调用了cleanCache("dish_*"),把这一类的缓存key全部删除,让用户端下次查询时回源数据库、重建缓存。这里的pattern要用通配符匹配所有分类ID的菜品缓存,别只删一个固定key。
还要提醒一点:Redis的keys命令在数据量特别大的生产环境是O(n)操作,如果缓存key非常多,会阻塞Redis一段时间。教学项目/小型项目这样写完全没问题,但如果是大型项目,一般会改为“在更新操作里直接删除对应的具体key”,或者用canal监听binlog异步清理缓存。作为项目练习阶段,keys加通配符是最直观的写法。
5. 我在实操中踩过的坑与验证记录
这一节是我写这篇文章的初衷。开发阶段理直气壮觉得“这功能我五分钟就写完了”,实际联调时被各种奇怪问题教会了做人。
5.1 新增菜品后用户端怎么都看不到
第一次跑通新增菜品接口后,我在管理端创建了一个新菜品,立刻切到用户端看分类下的菜品列表——没有。我又刷新一次,还是没有。第一反应是代码bug,检查了半天发现数据库里菜品记录明明在,但用户端查询接口走的Redis缓存,里面还是旧数据。这就是前面讲的缓存一致性问题。
解决办法就是在新增方法里加上cleanCache("dish_*")。这里我也要提醒一句:清理缓存时一定要确认缓存key的实际生成规则。有的版本缓存key是dish_1这种下划线风格,有的是Spring Cache默认的dish::1风格。如果你的cleanCache写的是"dish_*",而实际key是"dish::1",keys()匹配不到任何key,你的清理代码就是“假装在工作”,问题依旧。最好加一行日志,把清理掉的key数量打出来看一眼。
5.2 口味数据丢了,但数据库里没报错
学习阶段很多人的数据库外键约束没开,或者根本没有物理外键。此时即使口味表插入失败,事务回滚到位,不会有任何数据库报错。但有一种更隐蔽的错误:我在循环里写漏了flavor.setDishId(dishId),结果SQL执行成功,口味记录也进了表,但dish_id全部是null。用户端查询菜品详情时,根据dishId查不到任何口味,页面上“辣度/加料”全是空的。
排查这类问题,一个很实用的习惯是:在测试阶段打开SQL日志,或者临时在每个关键SQL前后打印入参。尤其是批量insert,foreach里的参数一定要靠日志去确认不是空的。肉眼盯着代码看半天,不如一条带参日志来得直接。
5.3 事务失效的陷阱
有个朋友遇到的情况是:删菜品时,第一个id校验发现是起售状态,抛了异常,但他发现数据库里第一个菜品已经被删掉了。问题根源通常不在代码逻辑,而在事务边界。
两个常见原因:一是方法没有被Spring代理——比如DishServiceImpl内部另一个方法调用了deleteBatch,this.xxx()这种自调用方式不走代理,事务不生效。二是事务方法被final修饰或被static修饰,CGLIB代理无法覆盖。排查时可以先看调用栈,确认事务方法是“从外部Controller到ServiceImpl代理对象再到业务方法”的正常链路,而不是类内部自调用。
另外一个非常容易被忽略的坑:如果Service方法catch了异常并自己处理掉,事务是不会因为你catch而回滚的。所以校验抛异常时,别在方法内部又包一层try-catch把异常吞掉。苍穹外卖里抛DeletionNotAllowedException,异常经过全局异常处理器统一处理,返回给前端友好提示——这种做法比较规范,值得模仿。
5.4 状态校验和套餐校验的顺序问题
我一开始写删除逻辑时,先查套餐关联,再查起售状态。后来发现顺序会影响用户体验:一个起售中的菜品,它同时也被某个套餐关联。如果先查套餐关联,返回的错误信息是“菜品被套餐关联,不能删除”;但真正的首要风险是“这个菜品正在售卖中”。用户看到错误后,先去解除套餐关联,又回来删一次,又被提示起售中不能删除——来回折腾。
反过来,先校验起售状态,再校验套餐关联,用户第一次就会看到真正核心的拦截理由。这个顺序不是技术问题,是产品思路上“优先暴露更紧急的问题”的实践。不要小看这种细节,真实项目评审里是会有人问的。
5.5 删除成功但用户端菜品还挂着
这其实是缓存清理匹配不上的另一种表现。我在本地验证过:管理端删除菜品后,dish表数据没了,但Redis里dish::2这样的key还在。用户端再查分类2的列表,缓存直接返回已删除的菜品,页面就出现了脏数据。这时候手动删掉对应key,再刷用户端,数据就正常了。
所以每次改动完菜品主表或口味表数据,都要问自己一句:用户端展示数据时,会不会命中有问题的缓存?会的话,清掉。
再往下走,如果是要给这个模块做扩展,可以从这几个方向入手:新增菜品时同步写入ES索引或者其他商品系统的数据;删除菜品改成逻辑删(加一个deleted字段,查询时过滤),方便将来做数据恢复和审计;套餐和菜品的关联关系用外键约束或者定时任务对账。不过这些都是后话了,先把新增和删除这两个接口做得严丝合缝,比什么都强。
