1. 苍穹外卖项目实战:套餐管理与Redis应用实录
作为一名长期奋战在一线的Java开发者,最近在参与苍穹外卖系统的开发过程中,遇到了不少值得记录的技术点。本文将重点分享套餐管理模块的实现细节和Redis的实战应用,这些都是外卖系统中非常核心的功能模块。
2. 套餐管理模块设计与实现
2.1 业务需求分析
外卖系统的套餐管理主要涉及以下几个核心功能:
- 新增套餐(包含套餐基本信息及关联菜品)
- 修改套餐信息
- 批量删除套餐
- 套餐状态管理
这些操作都需要同时维护setmeal表(套餐主表)和setmeal_dish表(套餐-菜品关联表)的数据一致性,这对事务管理提出了较高要求。
2.2 数据库表结构设计
套餐管理涉及的两张核心表结构如下:
setmeal表(套餐主表)
sql复制CREATE TABLE `setmeal` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`category_id` bigint NOT NULL COMMENT '分类id',
`name` varchar(32) NOT NULL COMMENT '套餐名称',
`price` decimal(10,2) NOT NULL COMMENT '套餐价格',
`status` int DEFAULT NULL COMMENT '状态 0:停用 1:启用',
`description` varchar(255) DEFAULT NULL COMMENT '描述信息',
`image` varchar(255) DEFAULT NULL COMMENT '图片',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`create_user` bigint NOT NULL COMMENT '创建人',
`update_user` bigint NOT NULL COMMENT '修改人',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='套餐';
setmeal_dish表(套餐菜品关联表)
sql复制CREATE TABLE `setmeal_dish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`setmeal_id` bigint DEFAULT NULL COMMENT '套餐id',
`dish_id` bigint DEFAULT NULL COMMENT '菜品id',
`name` varchar(32) 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`)
) ENGINE=InnoDB COMMENT='套餐菜品关系';
2.3 新增套餐实现细节
新增套餐是套餐管理中最复杂的操作之一,因为它需要同时操作两张表并保持事务一致性。
Controller层实现
java复制@PostMapping
public Result save(@RequestBody SetmealDTO setmealDTO) {
setmealService.saveWithDishes(setmealDTO);
return Result.success();
}
Service层核心逻辑
java复制@Transactional
public void saveWithDishes(SetmealDTO setmealDTO) {
// 1.保存套餐基本信息
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);
setmealMapper.insert(setmeal);
// 2.获取套餐ID
Long setmealId = setmeal.getId();
// 3.保存套餐和菜品的关联关系
List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
setmealDishes.forEach(dish -> {
dish.setSetmealId(setmealId);
});
setmealDishMapper.insertBatch(setmealDishes);
}
关键点说明:
@Transactional注解确保整个操作在事务中执行- MyBatis的
useGeneratedKeys="true"配置用于获取自增主键 - 批量插入使用MyBatis的
<foreach>标签实现
注意:在实际开发中,我们还需要考虑套餐图片的上传处理、价格校验等业务逻辑,这些都需要在Service层进行完善。
2.4 修改套餐实现要点
修改套餐相比新增更加复杂,因为需要处理原有数据的清理和新数据的插入。
Service层核心逻辑
java复制@Transactional
public void updateWithDishes(SetmealDTO setmealDTO) {
// 1.更新套餐基本信息
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);
setmealMapper.update(setmeal);
// 2.删除原有菜品关联
setmealDishMapper.deleteBySetmealId(setmealDTO.getId());
// 3.插入新的菜品关联
List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
setmealDishes.forEach(dish -> {
dish.setSetmealId(setmealDTO.getId());
});
setmealDishMapper.insertBatch(setmealDishes);
}
常见问题排查:
- 修改后数据未更新:检查SQL语句是否正确包含WHERE条件
- 关联菜品未更新:确认事务注解是否生效
- 性能问题:大批量操作时考虑分批处理
2.5 批量删除套餐实现
批量删除需要特别注意数据一致性问题,必须确保套餐主表和关联表的数据同步删除。
Service层实现
java复制@Transactional
public void deleteBatch(List<Long> ids) {
// 1.检查套餐状态(启用的套餐不能删除)
ids.forEach(id -> {
Setmeal setmeal = setmealMapper.getById(id);
if (setmeal.getStatus() == StatusConstant.ENABLE) {
throw new BusinessException(MessageConstant.SETMEAL_ON_SALE);
}
});
// 2.删除套餐数据
setmealMapper.deleteByIds(ids);
// 3.删除关联的菜品数据
setmealDishMapper.deleteBySetmealIds(ids);
}
注意事项:
- 删除前必须检查业务状态
- 关联删除操作要放在同一个事务中
- 考虑使用逻辑删除替代物理删除
3. Redis在外卖系统中的应用
3.1 Redis安装与配置
在Windows环境下安装Redis相对简单:
- 下载Redis Windows版本
- 解压到指定目录
- 启动Redis服务:
code复制redis-server.exe redis.windows.conf
提示:生产环境建议使用Linux服务器运行Redis,性能更稳定。
3.2 Redis五种数据类型详解
3.2.1 String(字符串)
String是Redis最基本的数据类型,常用操作:
bash复制SET key value # 设置键值
GET key # 获取值
INCR key # 值自增1
DECR key # 值自减1
应用场景:
- 缓存简单数据
- 计数器
- 分布式锁
3.2.2 Hash(哈希)
Hash适合存储对象,常用操作:
bash复制HSET key field value # 设置字段值
HGET key field # 获取字段值
HGETALL key # 获取所有字段和值
应用场景:
- 存储用户信息
- 商品详情
- 配置信息
3.2.3 List(列表)
List是有序可重复的集合,常用操作:
bash复制LPUSH key value # 左侧插入
RPUSH key value # 右侧插入
LPOP key # 左侧弹出
RPOP key # 右侧弹出
应用场景:
- 消息队列
- 最新消息排行
- 记录用户操作日志
3.2.4 Set(集合)
Set是无序不重复的集合,常用操作:
bash复制SADD key member # 添加元素
SMEMBERS key # 获取所有元素
SISMEMBER key member # 判断元素是否存在
应用场景:
- 标签系统
- 好友关系
- 唯一计数器
3.2.5 ZSet(有序集合)
ZSet是有序不重复的集合,每个元素关联一个分数,常用操作:
bash复制ZADD key score member # 添加元素
ZRANGE key start stop # 按分数范围获取元素
ZREVRANK key member # 获取元素排名
应用场景:
- 排行榜
- 带权重的队列
- 范围查询
3.3 Redis在外卖系统中的典型应用
3.3.1 店铺营业状态管理
使用String类型存储店铺营业状态:
java复制// 设置营业状态
redisTemplate.opsForValue().set("shop:status", "1");
// 获取营业状态
String status = redisTemplate.opsForValue().get("shop:status");
优化建议:
- 设置合理的过期时间
- 考虑使用Hash存储更多店铺信息
- 实现本地缓存减少Redis访问
3.3.2 热门套餐缓存
使用Hash缓存热门套餐信息:
java复制// 缓存套餐信息
Map<String, Object> mealMap = new HashMap<>();
mealMap.put("name", "超值午餐套餐");
mealMap.put("price", "38.00");
redisTemplate.opsForHash().putAll("setmeal:1", mealMap);
// 获取缓存
Map<Object, Object> cachedMeal = redisTemplate.opsForHash().entries("setmeal:1");
3.3.3 订单号生成
利用Redis的原子性操作生成唯一订单号:
java复制// 生成订单号
Long orderNum = redisTemplate.opsForValue().increment("order:serial");
String orderNo = "ORD" + String.format("%08d", orderNum);
4. 开发中的经验与教训
4.1 事务管理要点
-
@Transactional注解要正确使用:
- 确保方法为public
- 异常类型要匹配(默认只回滚RuntimeException)
- 避免同类内方法调用导致注解失效
-
事务隔离级别选择:
- 外卖系统通常使用READ_COMMITTED级别
- 高并发场景考虑使用乐观锁
-
事务传播行为:
- 默认PROPAGATION_REQUIRED在大多数情况下适用
- 特殊场景考虑使用PROPAGATION_REQUIRES_NEW
4.2 MyBatis使用技巧
- 主键回显配置:
xml复制<insert id="insert" parameterType="Setmeal" useGeneratedKeys="true" keyProperty="id">
<!-- SQL语句 -->
</insert>
- 批量插入优化:
java复制@Insert("<script>" +
"INSERT INTO setmeal_dish (setmeal_id, dish_id, name, price, copies) " +
"VALUES " +
"<foreach collection='list' item='item' separator=','>" +
"(#{item.setmealId}, #{item.dishId}, #{item.name}, #{item.price}, #{item.copies})" +
"</foreach>" +
"</script>")
void insertBatch(List<SetmealDish> setmealDishes);
- 动态SQL使用:
xml复制<update id="update" parameterType="Setmeal">
UPDATE setmeal
<set>
<if test="name != null">name = #{name},</if>
<if test="price != null">price = #{price},</if>
<if test="status != null">status = #{status},</if>
</set>
WHERE id = #{id}
</update>
4.3 Redis使用注意事项
- 连接池配置:
properties复制# 最大连接数
spring.redis.lettuce.pool.max-active=8
# 最大空闲连接
spring.redis.lettuce.pool.max-idle=8
# 最小空闲连接
spring.redis.lettuce.pool.min-idle=0
- 序列化配置:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
- 缓存穿透防护:
java复制public Setmeal getById(Long id) {
// 1.从缓存查询
String key = "setmeal:" + id;
Setmeal setmeal = (Setmeal) redisTemplate.opsForValue().get(key);
// 2.缓存不存在则查询数据库
if (setmeal == null) {
setmeal = setmealMapper.getById(id);
if (setmeal != null) {
// 3.数据库存在则写入缓存
redisTemplate.opsForValue().set(key, setmeal, 30, TimeUnit.MINUTES);
} else {
// 4.数据库也不存在,缓存空对象防止穿透
redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
}
}
return setmeal instanceof NullValue ? null : setmeal;
}
5. 性能优化建议
5.1 数据库层面优化
-
索引优化:
- 为setmeal表的category_id字段添加索引
- 为setmeal_dish表的setmeal_id和dish_id字段添加联合索引
-
SQL优化:
- 避免使用SELECT *
- 大数据量查询考虑分页
- 复杂查询考虑使用JOIN优化
5.2 Redis层面优化
-
内存优化:
- 控制单个Key的大小不超过1MB
- 使用Hash分片存储大对象
- 设置合理的过期时间
-
命令优化:
- 使用Pipeline批量操作
- 避免使用KEYS命令
- 复杂操作考虑使用Lua脚本
5.3 应用层优化
-
缓存策略:
- 热点数据预加载
- 多级缓存架构(Redis + 本地缓存)
- 缓存更新策略(先更新数据库再删除缓存)
-
异步处理:
- 非核心流程使用消息队列异步处理
- 日志记录异步化
- 统计报表异步生成
在实际开发中,我深刻体会到事务管理的重要性,特别是在套餐管理这种需要维护多表一致性的场景。一个常见的错误是忘记添加@Transactional注解,导致部分操作成功而部分失败,产生脏数据。通过这次项目实践,我总结了以下经验:
- 对于写操作,默认就应该加上事务管理
- 事务范围要合理,不宜过大也不宜过小
- 要明确事务的隔离级别和传播行为
- 注意事务方法内的异常处理
Redis的使用也让我认识到缓存设计的复杂性。最初我们直接将对象序列化存储,后来发现某些场景下Hash结构更合适。通过不断优化,我们最终将套餐详情的读取性能提升了10倍以上。