1. 项目背景与核心需求
在餐饮管理系统开发过程中,菜品数据管理是最基础也是最重要的模块之一。删除菜品功能看似简单,但实际开发中需要考虑数据完整性、业务关联性和操作安全性等多重因素。最近在重构一个外卖平台的菜品管理模块时,我系统梳理了删除菜品接口的实现方案,发现其中有不少值得记录的细节。
这个接口的核心需求很明确:当商家需要下架某个菜品时,系统需要提供安全可靠的删除机制。但这里的"删除"在技术实现上可以有多种理解——是直接从数据库物理删除记录?还是标记为逻辑删除状态?不同的选择会导致完全不同的实现路径。
2. 技术方案选型
2.1 物理删除 vs 逻辑删除
物理删除方案是直接执行DELETE SQL语句,将数据从数据库表中彻底移除。这种方案的优点是:
- 存储空间立即释放
- 数据库表不会积累无效数据
- 查询性能不受影响
但缺点也很明显:
- 无法恢复误删数据
- 关联数据可能产生孤儿记录
- 无法追踪历史操作
逻辑删除方案则是通过更新is_deleted状态字段(通常设置为1或true),配合查询时增加WHERE is_deleted=0条件。其优势在于:
- 可随时恢复数据
- 完整保留操作记录
- 避免关联数据异常
经过权衡,我们选择了逻辑删除方案,主要基于以下考虑:
- 餐饮行业合规要求保存至少6个月的经营数据
- 商家经常需要临时下架后又重新上架相同菜品
- 需要统计分析下架菜品的历史数据
2.2 接口设计要点
基于RESTful规范,删除菜品应该使用DELETE方法,但考虑到逻辑删除的本质是更新操作,这里存在设计争议。最终我们采用的方案是:
- 保留DELETE /dishes/{id} 作为接口路径
- 内部实际执行UPDATE语句
- 响应状态码使用200而非204(因为返回了操作结果详情)
接口请求示例:
http复制DELETE /api/v1/dishes/123 HTTP/1.1
Authorization: Bearer xxxx
成功响应:
json复制{
"code": 200,
"data": {
"id": 123,
"status": "deactivated"
}
}
3. 核心实现细节
3.1 数据库设计
菜品表关键字段设计:
sql复制CREATE TABLE `dishes` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '菜品名称',
`price` decimal(10,2) NOT NULL COMMENT '售价',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1-上架 0-下架',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '0-未删除 1-已删除',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
`delete_by` bigint DEFAULT NULL COMMENT '操作人ID',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 服务层实现
Java Spring Boot示例代码:
java复制@Transactional
public DishVO deleteDish(Long id, Long operatorId) {
Dish dish = dishRepository.findById(id)
.orElseThrow(() -> new BusinessException("菜品不存在"));
// 校验菜品状态
if (dish.getIsDeleted() == 1) {
throw new BusinessException("菜品已删除");
}
// 校验关联订单
if (orderService.existsByDishId(id)) {
throw new BusinessException("存在关联订单,不可删除");
}
// 执行逻辑删除
dish.setIsDeleted(1);
dish.setDeleteTime(LocalDateTime.now());
dish.setDeleteBy(operatorId);
dishRepository.save(dish);
// 同步更新ES索引
searchService.updateDishStatus(id, false);
return convertToVO(dish);
}
关键点说明:
- 使用@Transactional保证操作原子性
- 前置校验包括存在性校验和状态校验
- 记录操作人和操作时间满足审计要求
- 同步更新搜索引擎状态保持数据一致
3.3 关联数据处理
删除菜品时需要特别处理以下关联数据:
-
菜品分类关系
需要同步删除dish_category_relation表中的关联记录,避免出现无效关联 -
购物车引用
主动清理购物车中已删除菜品的条目,避免用户结算时报错 -
库存记录
保留历史库存快照,但标记为无效状态,供后续统计分析使用
处理方案:
java复制// 在deleteDish方法中追加
relationRepository.deleteByDishId(id);
cartService.clearInvalidItems(Collections.singletonList(id));
inventoryService.markAsInvalid(id);
4. 安全与权限控制
4.1 操作权限校验
采用RBAC模型进行权限控制:
- 普通员工:只能删除自己创建的菜品
- 店长:可删除本店所有菜品
- 管理员:无限制
权限校验实现:
java复制@PreAuthorize("hasPermission(#id, 'dish', 'delete')")
public DishVO deleteDish(Long id, Long operatorId) {
// ...
}
4.2 防误删机制
引入以下防护措施:
- 二次确认:前端调用删除接口前需要用户确认
- 操作日志:记录完整的操作流水
- 延迟生效:重要菜品删除后进入24小时缓冲期,期间可撤销
操作日志表设计:
sql复制CREATE TABLE `operation_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`operator_id` bigint NOT NULL,
`operation_type` varchar(20) NOT NULL,
`target_id` bigint NOT NULL,
`target_type` varchar(50) NOT NULL,
`operation_time` datetime NOT NULL,
`old_value` json DEFAULT NULL,
`new_value` json DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_operator` (`operator_id`),
KEY `idx_target` (`target_type`,`target_id`)
);
5. 性能优化实践
5.1 批量删除优化
当需要批量下架菜品时,采用批量操作接口避免循环调用:
java复制@PostMapping("/batch-delete")
public Result batchDelete(@RequestBody BatchDeleteDTO dto) {
// 使用IN语句一次性更新
dishRepository.batchDelete(
dto.getIds(),
dto.getOperatorId(),
LocalDateTime.now()
);
// 异步处理关联数据
asyncTask.cleanRelations(dto.getIds());
return Result.success();
}
对应的MyBatis批量更新:
xml复制<update id="batchDelete">
UPDATE dishes
SET is_deleted = 1,
delete_by = #{operatorId},
delete_time = #{deleteTime}
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND is_deleted = 0
</update>
5.2 查询优化
逻辑删除后,所有查询都需要增加is_deleted=0条件。为避免遗漏,我们采用以下方案:
- MyBatis拦截器自动追加条件
java复制@Intercepts({
@Signature(type= Executor.class, method="query",
args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DeleteInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 解析SQL自动添加is_deleted条件
// ...
}
}
- JPA中通过@Where注解实现
java复制@Entity
@Table(name = "dishes")
@Where(clause = "is_deleted = 0")
public class Dish {
// ...
}
6. 异常处理与事务管理
6.1 自定义异常体系
定义业务异常类处理各种错误场景:
java复制public enum DishErrorCode {
DISH_NOT_FOUND(4041, "菜品不存在"),
DISH_ALREADY_DELETED(4001, "菜品已删除"),
DISH_HAS_ORDERS(4002, "存在关联订单"),
// ...
}
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(DishErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
}
6.2 事务边界控制
对于包含多个操作的删除流程,需要合理控制事务边界:
java复制@Service
@RequiredArgsConstructor
public class DishServiceImpl implements DishService {
private final DishRepository dishRepository;
private final InventoryService inventoryService;
private final SearchService searchService;
@Transactional(propagation = Propagation.REQUIRED)
public void deleteDish(Long id) {
// 主事务:更新菜品状态
Dish dish = updateDishStatus(id);
// 新事务:异步更新库存
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(Propagation.REQUIRES_NEW);
transactionTemplate.execute(status -> {
inventoryService.markAsInvalid(dish.getId());
return null;
});
// 无事务:更新搜索引擎
searchService.updateDishStatus(dish.getId(), false);
}
}
7. 测试要点
7.1 单元测试用例
关键测试场景:
java复制@Test
public void testDeleteDish_Success() {
// 准备测试数据
Dish dish = new Dish();
dish.setId(1L);
dish.setIsDeleted(0);
when(dishRepository.findById(1L)).thenReturn(Optional.of(dish));
// 执行测试
dishService.deleteDish(1L, 100L);
// 验证结果
assertThat(dish.getIsDeleted()).isEqualTo(1);
verify(dishRepository).save(dish);
}
@Test
public void testDeleteDish_AlreadyDeleted() {
Dish dish = new Dish();
dish.setId(1L);
dish.setIsDeleted(1);
when(dishRepository.findById(1L)).thenReturn(Optional.of(dish));
assertThatThrownBy(() -> dishService.deleteDish(1L, 100L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("菜品已删除");
}
7.2 集成测试方案
使用Testcontainers进行数据库集成测试:
java复制@Testcontainers
@SpringBootTest
class DishServiceIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
// ...
}
@Test
void testDeleteDishWithRelations() {
// 初始化测试数据
Long dishId = testDataHelper.createTestDish();
testDataHelper.createOrderWithDish(dishId);
// 验证删除失败
assertThatThrownBy(() -> dishService.deleteDish(dishId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("存在关联订单");
}
}
8. 实际踩坑记录
8.1 缓存一致性问题
初期实现时忽略了缓存更新,导致:
- 菜品删除后仍可能出现在推荐列表
- 购物车价格计算基于缓存中的旧数据
解决方案:
- 采用双删策略:
java复制public void deleteDish(Long id) {
// 第一删
cache.evict("dish::" + id);
// 执行数据库删除
dishRepository.deleteById(id);
// 延迟第二删
asyncTask.delay(1000, () -> cache.evict("dish::" + id));
}
- 使用发布/订阅模式通知相关服务:
java复制@TransactionalEventListener(phase = AFTER_COMMIT)
public void handleDishDeleted(DishDeletedEvent event) {
redisTemplate.convertAndSend("dish.deleted", event.getDishId());
// 其他微服务订阅该消息处理本地缓存
}
8.2 批量删除超时
当一次性删除上千条记录时,出现:
- 数据库连接超时
- 事务时间过长导致锁等待
优化方案:
- 分批次处理(每批100条)
java复制List<Long> allIds = getIdsToDelete();
int batchSize = 100;
Iterables.partition(allIds, batchSize).forEach(batch -> {
dishRepository.batchDelete(batch);
});
- 采用异步任务+进度查询:
java复制@PostMapping("/async-batch-delete")
public Result asyncBatchDelete(@RequestBody BatchDeleteDTO dto) {
String taskId = taskService.submit(() -> {
dishService.batchDelete(dto.getIds());
});
return Result.success(taskId);
}
@GetMapping("/task-progress/{taskId}")
public Result getProgress(@PathVariable String taskId) {
return Result.success(taskService.getProgress(taskId));
}
9. 监控与运维
9.1 关键指标监控
配置以下监控项:
- 删除操作成功率
- 平均响应时间
- 关联数据清理耗时
- 失败原因分布
Prometheus配置示例:
yaml复制- pattern: '/api/dishes/*'
name: 'dish_api'
labels:
method: '$1'
status: '$2'
metrics:
- name: 'dish_delete_total'
type: 'counter'
help: 'Total dish delete requests'
- name: 'dish_delete_duration_seconds'
type: 'histogram'
help: 'Dish delete latency'
9.2 日志追踪方案
通过MDC实现全链路日志追踪:
java复制public DishVO deleteDish(Long id, Long operatorId) {
MDC.put("dishId", String.valueOf(id));
MDC.put("operator", String.valueOf(operatorId));
try {
log.info("Start deleting dish");
// 业务逻辑
log.info("Successfully deleted dish");
return result;
} finally {
MDC.clear();
}
}
日志查询语句:
sql复制-- 查询特定菜品的删除记录
SELECT * FROM logs
WHERE message LIKE '%deleting dish%'
AND context.dishId = '123'
ORDER BY timestamp DESC
10. 扩展思考
10.1 回收站功能设计
很多场景下需要回收站功能:
- 前端新增/deleted端点展示已删除菜品
- 支持按时间范围查询
- 提供彻底删除和恢复操作
接口示例:
java复制@GetMapping("/deleted")
public Page<DishVO> getDeletedDishes(
@RequestParam(required = false) String name,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate startDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return dishService.queryDeletedDishes(name, startDate, page, size);
}
@PostMapping("/{id}/restore")
public Result restoreDish(@PathVariable Long id) {
dishService.restoreDish(id);
return Result.success();
}
10.2 删除策略抽象
将删除策略抽象为可插拔组件:
java复制public interface DeleteStrategy<T> {
void preCheck(T entity);
void executeDelete(T entity);
void postProcess(T entity);
}
@Component
@Primary
public class LogicalDeleteStrategy implements DeleteStrategy<Dish> {
// 实现逻辑删除
}
@Component
@ConditionalOnProperty(name = "delete.strategy", havingValue = "physical")
public class PhysicalDeleteStrategy implements DeleteStrategy<Dish> {
// 实现物理删除
}
这样可以通过配置灵活切换删除策略:
yaml复制delete:
strategy: logical # 或 physical
在开发过程中,我发现删除功能的鲁棒性往往决定了整个系统的数据可靠性。特别是在餐饮行业,菜品数据的频繁变更需要格外谨慎处理。建议在实现基础功能后,至少添加以下防护措施:
- 数据库定期备份+binlog保留
- 重要操作的多级审批流程
- 敏感操作的二次认证
- 操作风险的实时监控告警