SpringBoot外卖项目实战:从E-R图到业务逻辑,详解套餐与菜品的多表关联设计

张开发
2026/4/7 21:17:49 15 分钟阅读

分享文章

SpringBoot外卖项目实战:从E-R图到业务逻辑,详解套餐与菜品的多表关联设计
SpringBoot外卖项目实战从E-R图到业务逻辑详解套餐与菜品的多表关联设计在开发一个外卖系统时数据库设计往往是项目成败的关键。特别是当业务涉及套餐、菜品、分类等多表关联时如何设计合理的E-R图并实现高效的数据操作成为开发者必须面对的挑战。本文将以苍穹外卖为案例深入剖析这类典型业务场景下的数据库设计与实现细节。1. 外卖业务的核心数据模型外卖系统的核心在于菜品与套餐的管理。一个完整的业务模型通常包含以下几个关键实体分类(Category)用于对菜品和套餐进行分类管理菜品(Dish)基础菜品项包含名称、价格、描述等属性菜品口味(Dish_flavor)记录菜品的可选口味如辣度、甜度等套餐(Setmeal)由多个菜品组成的组合套餐菜品关联(Setmeal_dish)维护套餐与菜品之间的多对多关系这种设计看似简单但在实际业务中会面临诸多挑战如何保证套餐修改时的数据一致性如何处理套餐起售/停售时的级联检查如何高效查询带分类信息的套餐列表2. E-R图设计与表结构解析让我们先来看一个典型的外卖系统E-R图设计分类(Category) 1 ← n 菜品(Dish) 分类(Category) 1 ← n 套餐(Setmeal) 套餐(Setmeal) n → m 菜品(Dish) [通过Setmeal_dish关联] 菜品(Dish) 1 ← n 菜品口味(Dish_flavor)对应的数据库表结构设计如下分类表(category)CREATE TABLE category ( id bigint NOT NULL COMMENT 主键, type int DEFAULT NULL COMMENT 类型 1菜品分类 2套餐分类, name varchar(32) COLLATE utf8_bin NOT NULL COMMENT 分类名称, sort int NOT NULL DEFAULT 0 COMMENT 顺序, status int DEFAULT NULL COMMENT 分类状态 0:禁用1:启用, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, create_user bigint DEFAULT NULL COMMENT 创建人, update_user bigint DEFAULT NULL COMMENT 修改人, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb3 COLLATEutf8_bin COMMENT分类表;套餐表(setmeal)CREATE TABLE setmeal ( id bigint NOT NULL COMMENT 主键, category_id bigint NOT NULL COMMENT 分类id, name varchar(32) COLLATE utf8_bin NOT NULL COMMENT 套餐名称, price decimal(10,2) NOT NULL COMMENT 套餐价格, status int DEFAULT NULL COMMENT 状态 0:停用 1:启用, description varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT 描述信息, image varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT 图片, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, create_user bigint DEFAULT NULL COMMENT 创建人, update_user bigint DEFAULT NULL COMMENT 修改人, PRIMARY KEY (id), KEY idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb3 COLLATEutf8_bin COMMENT套餐表;套餐菜品关联表(setmeal_dish)CREATE TABLE setmeal_dish ( id bigint NOT NULL COMMENT 主键, setmeal_id bigint DEFAULT NULL COMMENT 套餐id, dish_id bigint DEFAULT NULL COMMENT 菜品id, name varchar(32) COLLATE utf8_bin DEFAULT NULL COMMENT 菜品名称冗余字段, price decimal(10,2) DEFAULT NULL COMMENT 菜品单价冗余字段, copies int DEFAULT NULL COMMENT 菜品份数, sort int DEFAULT NULL COMMENT 排序, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, create_user bigint DEFAULT NULL COMMENT 创建人, update_user bigint DEFAULT NULL COMMENT 修改人, PRIMARY KEY (id), KEY idx_setmeal_id (setmeal_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb3 COLLATEutf8_bin COMMENT套餐菜品关联表;这种设计有几个关键点值得注意冗余字段设计setmeal_dish表中存储了菜品名称和价格虽然存在冗余但可以避免频繁联表查询索引优化为category_id和setmeal_id建立了索引提高关联查询效率状态管理通过status字段统一管理分类、套餐、菜品的启用/禁用状态3. 核心业务逻辑实现3.1 新增套餐的实现新增套餐是典型的多表操作场景需要同时操作setmeal表和setmeal_dish表。以下是SpringBoot中的实现要点Transactional public void saveWithDishes(SetmealDTO setmealDTO) { // 1. 保存套餐基本信息 Setmeal setmeal new Setmeal(); BeanUtils.copyProperties(setmealDTO, setmeal); setmealMapper.insert(setmeal); // 2. 保存套餐和菜品的关联关系 ListSetmealDish setmealDishes setmealDTO.getSetmealDishes(); setmealDishes.forEach(dish - { dish.setSetmealId(setmeal.getId()); }); setmealDishMapper.insertBatch(setmealDishes); }关键注意事项事务控制必须添加Transactional注解确保两个操作要么全部成功要么全部回滚批量插入使用批量插入优化setmeal_dish表的写入性能ID传递需要先获取setmeal表的主键ID再设置到setmeal_dish记录中3.2 套餐起售/停售的业务逻辑套餐的起售/停售不是简单的状态更新还需要检查关联菜品的状态Transactional public void startOrStop(Long id, Integer status) { // 起售套餐前检查套餐内是否有停售的菜品 if (status 1) { ListDish dishList dishMapper.getBySetmealId(id); if (dishList ! null dishList.size() 0) { dishList.forEach(dish - { if (dish.getStatus() 0) { throw new CustomException(套餐内包含未启售菜品无法启售); } }); } } // 更新套餐状态 Setmeal setmeal Setmeal.builder() .id(id) .status(status) .updateTime(LocalDateTime.now()) .build(); setmealMapper.update(setmeal); }业务规则启售套餐时如果套餐内包含停售的菜品则不允许启售停售套餐时无需检查菜品状态可直接停售状态变更需要记录操作人和操作时间3.3 套餐分页查询优化套餐列表通常需要展示分类名称这涉及到setmeal和category表的关联查询public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) { PageHelper.startPage(setmealPageQueryDTO.getPage(), setmealPageQueryDTO.getPageSize()); // 使用LEFT JOIN获取分类名称 PageSetmealVO page setmealMapper.pageQuery(setmealPageQueryDTO); return new PageResult(page.getTotal(), page.getResult()); }对应的Mapper XML配置select idpageQuery resultTypecom.sky.vo.SetmealVO SELECT s.*, c.name as categoryName FROM setmeal s LEFT JOIN category c ON s.category_id c.id where if testname ! null AND s.name like concat(%,#{name},%) /if if testcategoryId ! null AND s.category_id #{categoryId} /if if teststatus ! null AND s.status #{status} /if /where ORDER BY s.create_time DESC /select查询优化技巧使用LEFT JOIN确保即使分类不存在也能返回套餐信息为category_id字段建立索引提高关联查询效率使用动态SQL实现灵活的查询条件组合4. 复杂业务场景的解决方案4.1 套餐修改的数据一致性修改套餐是一个复杂的操作通常需要更新套餐基本信息删除原有的套餐-菜品关联新增修改后的关联关系Transactional public void updateWithDishes(SetmealDTO setmealDTO) { // 1. 更新套餐基本信息 Setmeal setmeal new Setmeal(); BeanUtils.copyProperties(setmealDTO, setmeal); setmealMapper.update(setmeal); // 2. 删除原有套餐菜品关联 setmealDishMapper.deleteBySetmealId(setmealDTO.getId()); // 3. 重新插入新的关联关系 ListSetmealDish setmealDishes setmealDTO.getSetmealDishes(); if (setmealDishes ! null setmealDishes.size() 0) { setmealDishes.forEach(dish - { dish.setSetmealId(setmealDTO.getId()); }); setmealDishMapper.insertBatch(setmealDishes); } }这种先删除后插入的模式虽然看起来效率不高但能确保数据一致性避免复杂的差异比对逻辑。4.2 套餐删除的级联操作删除套餐时需要检查业务状态并清理关联数据Transactional public void deleteByIds(ListLong ids) { // 检查套餐是否处于启售状态 ids.forEach(id - { Setmeal setmeal setmealMapper.getById(id); if (setmeal.getStatus() 1) { throw new CustomException(套餐正在售卖中不能删除); } }); // 删除套餐数据 setmealMapper.deleteByIds(ids); // 删除套餐菜品关联数据 setmealDishMapper.deleteBySetmealIds(ids); }关键业务规则启售中的套餐不允许删除需要同时删除套餐表和关联表的数据使用批量操作提高删除效率4.3 事务边界与异常处理在多表操作中事务管理至关重要。以下是一些最佳实践事务注解位置建议在Service方法上添加Transactional而不是在Controller或Mapper层异常传播默认情况下RuntimeException会触发回滚检查异常不会隔离级别根据业务需求选择合适的隔离级别外卖系统通常使用默认级别即可超时设置对于复杂操作可以设置事务超时时间避免长时间锁表Transactional( rollbackFor Exception.class, timeout 30, isolation Isolation.DEFAULT, propagation Propagation.REQUIRED ) public void complexOperation() { // 业务逻辑 }5. 性能优化实践5.1 缓存策略外卖系统的菜单数据通常是读多写少非常适合使用缓存Cacheable(value setmealCache, key #categoryId) public ListSetmeal list(Long categoryId) { return setmealMapper.list(Setmeal.builder() .categoryId(categoryId) .status(1) .build()); }缓存更新策略新增/修改/删除套餐时清除相关缓存设置合理的过期时间避免缓存雪崩考虑使用多级缓存架构5.2 批量操作优化对于套餐菜品关联这类批量操作使用批量SQL可以显著提高性能public void insertBatch(ListSetmealDish setmealDishes) { String sql scriptINSERT INTO setmeal_dish (setmeal_id, dish_id, name, price, copies, sort, create_time, update_time, create_user, update_user) VALUES foreach collectionlist itemitem separator, (#{item.setmealId}, #{item.dishId}, #{item.name}, #{item.price}, #{item.copies}, #{item.sort}, #{item.createTime}, #{item.updateTime}, #{item.createUser}, #{item.updateUser}) /foreach/script; sqlSessionTemplate.insert(com.sky.mapper.SetmealDishMapper.insertBatch, setmealDishes); }5.3 索引优化建议根据查询模式优化索引设计套餐分页查询为category_id, status, create_time建立复合索引套餐菜品关联查询为setmeal_id和dish_id建立索引避免过度索引写操作频繁的表不宜创建过多索引ALTER TABLE setmeal ADD INDEX idx_category_status (category_id, status); ALTER TABLE setmeal_dish ADD INDEX idx_setmeal_dish (setmeal_id, dish_id);6. 实战中的经验分享在实际开发中有几个容易忽视但非常重要的细节冗余字段的同步更新当菜品价格变更时需要同步更新setmeal_dish中的冗余价格字段逻辑删除的考虑是否采用逻辑删除而非物理删除需要根据业务需求权衡并发控制套餐库存或状态的变更需要考虑并发场景必要时使用乐观锁历史数据归档对于已完成的订单关联数据考虑定期归档以提高查询性能一个典型的乐观锁实现示例public boolean updateWithVersion(Setmeal setmeal) { int count setmealMapper.updateWithVersion(setmeal); if (count 0) { throw new OptimisticLockingFailureException(套餐数据已被其他操作修改请刷新后重试); } return true; }对应的Mapper XML:update idupdateWithVersion UPDATE setmeal SET name #{name}, price #{price}, status #{status}, update_time #{updateTime}, update_user #{updateUser}, version version 1 WHERE id #{id} AND version #{version} /update

更多文章