1. 项目背景与核心问题
在烘焙坊小程序的后端开发中,我们遇到了一个典型的性能瓶颈问题。用户端每次展示菜品数据都需要直接查询数据库,当访问量增大时,数据库压力骤增,导致系统响应变慢,用户体验直线下降。这就像一家生意火爆的面包店,每次顾客询问产品信息都要去后厨翻账本,效率自然低下。
通过压力测试发现,在200并发用户请求下,单纯依赖数据库查询的响应时间达到800ms以上,且数据库CPU使用率长期维持在90%以上。这种架构显然无法支撑业务增长,必须引入缓存机制来优化性能。
2. Redis缓存菜品实现方案
2.1 缓存架构设计
我们采用Redis作为缓存中间件,设计了一套分级缓存策略。核心思路是将菜品数据按分类缓存,每个分类对应一个独立的缓存键,结构如下:
dish_{categoryId} -> JSON序列化的菜品列表这种设计有三大优势:
- 分类缓存可以精准控制缓存失效范围
- 避免单个大key导致的Redis性能问题
- 符合业务查询模式(用户通常按分类浏览)
2.2 缓存读写流程
完整的缓存读写流程如下:
public List<Dish> getDishesByCategory(Long categoryId) { // 1. 构建缓存key String cacheKey = "dish_" + categoryId; // 2. 尝试从Redis获取 String cachedData = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cachedData)) { return JSON.parseArray(cachedData, Dish.class); } // 3. 缓存未命中,查询数据库 List<Dish> dishes = dishMapper.selectByCategory(categoryId); // 4. 写入缓存,设置30分钟过期 redisTemplate.opsForValue().set( cacheKey, JSON.toJSONString(dishes), 30, TimeUnit.MINUTES ); return dishes; }2.3 缓存一致性保障
缓存最大的挑战是如何保证与数据库的一致性。我们实现了统一的缓存清理机制:
@Component public class DishCacheCleaner { @Autowired private RedisTemplate<String, String> redisTemplate; public void cleanByCategory(Long categoryId) { String key = "dish_" + categoryId; redisTemplate.delete(key); } public void cleanAll() { Set<String> keys = redisTemplate.keys("dish_*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } }在菜品增删改操作中强制调用清理逻辑:
@PostMapping public Result addDish(@RequestBody DishDTO dishDTO) { dishService.add(dishDTO); // 清理对应分类缓存 cacheCleaner.cleanByCategory(dishDTO.getCategoryId()); return Result.success(); }重要提示:缓存清理必须放在事务提交之后执行,否则可能出现缓存已清理但数据库事务回滚的情况。
3. Spring Cache实现套餐缓存
3.1 Spring Cache集成
对于套餐数据,我们采用更高级的Spring Cache抽象层,底层依然使用Redis实现。首先需要添加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>在启动类上启用缓存:
@SpringBootApplication @EnableCaching public class BakeryApplication { public static void main(String[] args) { SpringApplication.run(BakeryApplication.class, args); } }3.2 缓存注解实战
套餐查询方法使用@Cacheable:
@GetMapping("/list") @Cacheable(cacheNames = "setmealCache", key = "#categoryId") public Result<List<Setmeal>> list(Long categoryId) { // 查询数据库逻辑 List<Setmeal> setmeals = setmealService.listByCategory(categoryId); return Result.success(setmeals); }数据变更方法使用@CacheEvict:
@PostMapping @CacheEvict(cacheNames = "setmealCache", allEntries = true) public Result addSetmeal(@RequestBody SetmealDTO setmealDTO) { setmealService.add(setmealDTO); return Result.success(); }3.3 缓存配置优化
在application.yml中配置Redis缓存行为:
spring: cache: type: redis redis: time-to-live: 1800s # 30分钟过期 key-prefix: "BAKERY:" # 统一前缀 use-key-prefix: true redis: host: 127.0.0.1 port: 63794. 购物车功能完整实现
4.1 数据库设计精要
购物车表设计遵循以下原则:
- 用户隔离:通过user_id区分不同用户的购物车
- 商品区分:同时支持单品和套餐,通过dish_id/setmeal_id区分
- 数据冗余:存储商品名称、图片、单价等关键信息,避免频繁联表查询
CREATE TABLE `shopping_cart` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(32) DEFAULT NULL, `image` varchar(255) DEFAULT NULL, `user_id` bigint NOT NULL, `dish_id` bigint DEFAULT NULL, `setmeal_id` bigint DEFAULT NULL, `dish_flavor` varchar(50) DEFAULT NULL, `number` int NOT NULL DEFAULT '1', `amount` decimal(10,2) NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;4.2 核心业务逻辑实现
添加购物车
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) { // 构建查询条件 ShoppingCart condition = new ShoppingCart(); BeanUtils.copyProperties(shoppingCartDTO, condition); condition.setUserId(BaseContext.getCurrentId()); // 查询是否已存在相同商品 List<ShoppingCart> existingItems = shoppingCartMapper.list(condition); if (!existingItems.isEmpty()) { // 存在则数量+1 ShoppingCart item = existingItems.get(0); item.setNumber(item.getNumber() + 1); shoppingCartMapper.updateNumberById(item); } else { // 不存在则新增记录 ShoppingCart newItem = new ShoppingCart(); BeanUtils.copyProperties(shoppingCartDTO, newItem); newItem.setUserId(BaseContext.getCurrentId()); newItem.setCreateTime(LocalDateTime.now()); newItem.setNumber(1); // 补充商品信息 if (shoppingCartDTO.getDishId() != null) { Dish dish = dishMapper.getById(shoppingCartDTO.getDishId()); newItem.setName(dish.getName()); newItem.setImage(dish.getImage()); newItem.setAmount(dish.getPrice()); } else { Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId()); newItem.setName(setmeal.getName()); newItem.setImage(setmeal.getImage()); newItem.setAmount(setmeal.getPrice()); } shoppingCartMapper.insert(newItem); } }减少购物车商品数量
public void subShoppingCart(ShoppingCartDTO shoppingCartDTO) { // 必须添加@RequestBody注解才能接收完整参数 ShoppingCart condition = new ShoppingCart(); BeanUtils.copyProperties(shoppingCartDTO, condition); condition.setUserId(BaseContext.getCurrentId()); List<ShoppingCart> items = shoppingCartMapper.list(condition); if (items.isEmpty()) { throw new BusinessException("购物车商品不存在"); } ShoppingCart item = items.get(0); if (item.getNumber() == 1) { // 数量为1直接删除 shoppingCartMapper.delete(item.getId()); } else { // 数量减1 item.setNumber(item.getNumber() - 1); shoppingCartMapper.updateNumberById(item); } }4.3 踩坑实录与解决方案
问题现象:删除购物车商品时,有时会错误删除其他商品。
排查过程:
- 添加详细日志记录入参和查询条件
- 发现dish_flavor参数未正确传递
- 检查Controller层发现漏加@RequestBody注解
解决方案:
@PostMapping("/sub") public Result subItem(@RequestBody ShoppingCartDTO shoppingCartDTO) { // 正确接收JSON请求体 shoppingCartService.subShoppingCart(shoppingCartDTO); return Result.success(); }经验总结:对于复杂参数的接收,必须明确使用@RequestBody注解。同时建议:
- 关键业务方法添加详细日志
- 使用Swagger等工具确保接口文档准确
- 编写完整的单元测试覆盖边界条件
5. 性能优化成果
经过上述优化后,系统性能得到显著提升:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 平均响应时间 | 800ms | 120ms | 85%↓ |
| 数据库QPS | 1500 | 200 | 87%↓ |
| 最大并发支持 | 300 | 2000 | 566%↑ |
缓存命中率维持在90%以上,数据库负载降低到正常水平。购物车操作的TPS从原来的150提升到800+,完全满足业务需求。