来自苍穹外卖
本文以
保存菜品为例,讲解为什么一条业务要包在一个事务里,并结合源码进行讲解。
第一章:整体流程
本文只用菜品模块这一条业务线说明事务:dish + dish_flavor。
1.1 新增流程(saveWithFlavor)
目标:新增完成后,数据库里要同时有菜品和口味。
- 插入
dish主表,得到dishId; - 给每条口味设置
dishId; - 批量插入
dish_flavor; - 任一步失败,整次新增回滚。
flowchart LR
A[开始新增菜品] --> B[写入 dish 主表]
B --> C[获取 dishId]
C --> D[给每条 flavor 设置 dishId]
D --> E[批量写入 dish_flavor]
E --> F{是否异常}
F -- 否 --> G[提交事务]
F -- 是 --> H[回滚事务]
1.2 修改流程(updateWithFlavor)
目标:修改完成后,只保留本次提交的口味数据。
- 更新
dish主表; - 删除当前菜品全部旧口味;
- 批量插入新口味;
- 任一步失败,整次修改回滚。
flowchart LR
A[开始修改菜品] --> B[更新 dish 主表]
B --> C[按 dishId 删除旧口味]
C --> D[组装新口味列表]
D --> E[批量写入新口味]
E --> F{是否异常}
F -- 否 --> G[提交事务]
F -- 是 --> H[回滚事务]
1.3 删除流程(deleteBatch)
目标:满足业务规则才允许删除,并且主子表一起删干净。
- 先校验是否允许删除(起售状态、套餐关联);
- 删除
dish; - 删除
dish_flavor; - 任一步失败,整次删除回滚。
flowchart LR
A[开始删除菜品] --> B[校验是否起售中]
B --> C{可删除?}
C -- 否 --> X[抛异常并结束]
C -- 是 --> D[校验是否被套餐关联]
D --> E{可删除?}
E -- 否 --> Y[抛异常并结束]
E -- 是 --> F[删除 dish]
F --> G[删除 dish_flavor]
G --> H{是否异常}
H -- 否 --> I[提交事务]
H -- 是 --> J[回滚事务]
1.4 结论
这 3 个流程的共同点是:一次业务包含多次写库。
因此事务边界必须按业务划分,而不是按单条SQL划分。
第二章:实现思路
先理解为什么一次业务要放进一个事务?
先看反例(不加事务):
- 先写
dish成功; - 再写
dish_flavor时报错; - 最终库里留下“有菜品、没口味”的半成品数据。
这就是我们要避免的问题。
正确做法是:把“新增菜品”这整件事当成一个整体提交。
- 成功路径:
dish成功 +dish_flavor成功 -> 一起提交。 - 失败路径:中间任一步失败 -> 整体回滚到执行前。
同样逻辑套到修改和删除:
- 修改不是一条 SQL,而是“更新主表 + 删除旧口味 + 插入新口味”;必须同事务。
- 删除不是一条 SQL,而是“校验 + 删除主表 + 删除子表”;必须同事务。
所以事务边界应该这样划:
- 不是按“某条 SQL”划;
- 而是按“完成一次业务动作”划;
- 在本项目里,边界就落在 Service 方法(
saveWithFlavor/updateWithFlavor/deleteBatch)。
第三章:结合代码讲解
3.1 saveWithFlavor(新增操作)
先写主表拿到 dishId,再写口味表。两步都成功,这次新增才算成功。
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//向菜品表插入1条数据
dishMapper.insert(dish);
//获取insert语句生成的主键值
Long dishId = dish.getId();
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishId);
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}
重点:
dishMapper.insert后立刻拿dish.getId():依赖 MyBatisuseGeneratedKeys回填主键(见DishMapper.xml)。- 口味行必须先
setDishId:保证外键语义正确。 - 整段在
@Transactional内:任一步异常 -> 前面已执行的 SQL 回滚。
3.2 updateWithFlavor(修改操作)
不是逐条改口味,而是“改主表 + 删旧子表 + 插新子表”。
@Transactional
public void updateWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//修改菜品表基本信息
dishMapper.update(dish);
//删除原有的口味数据
dishFlavorMapper.deleteByDishId(dishDTO.getId());
//重新插入口味数据
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishDTO.getId()); //为口味绑定当前的dish的id
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}
这段逻辑是完整的多表写入流程。如果中间失败,则整体回滚。
3.3 deleteBatch(校验 + 多表删除)
先判断能不能删,能删再删除主表和子表。
@Transactional
public void deleteBatch(List<Long> ids) {
//判断当前菜品是否能够删除---是否存在起售中的菜品??
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
if (dish.getStatus() == StatusConstant.ENABLE) {
//当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
//判断当前菜品是否能够删除---是否被套餐关联了??
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//当前菜品被套餐关联了,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
//删除菜品表中的菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);
//删除菜品关联的口味数据
dishFlavorMapper.deleteByDishId(id);
}
}
要点:
- 先校验(起售状态、套餐关联),不满足条件直接抛异常;
- 校验通过才执行删除;
- 删除主表和子表放在同一事务,避免删一半。
总结
核心原则:按业务划分事务边界。若一次业务里有多次写库,就必须放在同一个事务中。
实现:注解@Transactional
相关源码路径
sky-server/src/main/java/com/sky/service/impl/DishServiceImpl.java
sky-server/src/main/java/com/sky/mapper/DishMapper.java
sky-server/src/main/java/com/sky/mapper/DishFlavorMapper.java
sky-server/src/main/resources/mapper/DishMapper.xml
sky-server/src/main/resources/mapper/DishFlavorMapper.xml
参考
苍穹外卖www.bilibili.com/video/BV1TP…
deekseek-v4