事务与多表一致性

1 阅读4分钟

来自苍穹外卖

本文以保存菜品为例,讲解为什么一条业务要包在一个事务里,并结合源码进行讲解。

第一章:整体流程

本文只用菜品模块这一条业务线说明事务:dish + dish_flavor

1.1 新增流程(saveWithFlavor

目标:新增完成后,数据库里要同时有菜品和口味。

  1. 插入 dish 主表,得到 dishId
  2. 给每条口味设置 dishId
  3. 批量插入 dish_flavor
  4. 任一步失败,整次新增回滚。
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

目标:修改完成后,只保留本次提交的口味数据。

  1. 更新 dish 主表;
  2. 删除当前菜品全部旧口味;
  3. 批量插入新口味;
  4. 任一步失败,整次修改回滚。
flowchart LR
    A[开始修改菜品] --> B[更新 dish 主表]
    B --> C[按 dishId 删除旧口味]
    C --> D[组装新口味列表]
    D --> E[批量写入新口味]
    E --> F{是否异常}
    F -- 否 --> G[提交事务]
    F -- 是 --> H[回滚事务]

1.3 删除流程(deleteBatch

目标:满足业务规则才允许删除,并且主子表一起删干净。

  1. 先校验是否允许删除(起售状态、套餐关联);
  2. 删除 dish
  3. 删除 dish_flavor
  4. 任一步失败,整次删除回滚。
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划分。


第二章:实现思路

先理解为什么一次业务要放进一个事务?

先看反例(不加事务):

  1. 先写 dish 成功;
  2. 再写 dish_flavor 时报错;
  3. 最终库里留下“有菜品、没口味”的半成品数据。

这就是我们要避免的问题。

正确做法是:把“新增菜品”这整件事当成一个整体提交。

  • 成功路径: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);
        }
    }

重点

  1. dishMapper.insert 后立刻拿 dish.getId():依赖 MyBatis useGeneratedKeys 回填主键(见 DishMapper.xml)。
  2. 口味行必须先 setDishId:保证外键语义正确。
  3. 整段在 @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);
        }
    }

要点

  1. 先校验(起售状态、套餐关联),不满足条件直接抛异常;
  2. 校验通过才执行删除;
  3. 删除主表和子表放在同一事务,避免删一半。

总结

核心原则:按业务划分事务边界。若一次业务里有多次写库,就必须放在同一个事务中。
实现:注解@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