1. 项目概述
"苍穹外卖"作为一款典型的外卖平台应用,其菜品套餐模块的缓存设计直接关系到用户体验和系统性能。在实际运营中,我们发现当用户频繁浏览不同商家页面时,菜品数据的加载速度会显著影响下单转化率。特别是在午晚高峰时段,数据库查询压力激增,导致响应时间从平时的200ms飙升至2秒以上。
针对这一痛点,我们决定对菜品套餐模块实施多级缓存优化。不同于简单的Redis缓存方案,我们采用了本地缓存(Caffeine)+分布式缓存(Redis)+数据库的三层架构。这种设计在最近一次大促活动中经受住了考验,高峰期QPS达到12万时,菜品详情接口的99线仍稳定在150ms以内。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存架构设计
2.1 三级缓存模型解析
我们的缓存架构分为三个层级:
- 本地缓存层:使用Caffeine实现,存储热点菜品数据
- 分布式缓存层:基于Redis集群,保证多实例数据一致性
- 持久化层:MySQL数据库作为最终数据源
这种分层设计主要基于以下考虑:
- 本地缓存响应最快(平均0.5ms),但容量有限
- Redis提供更大存储空间(我们配置了16GB内存),且支持集群部署
- 数据库作为兜底,确保数据最终一致性
关键配置参数示例:
java复制// Caffeine配置 Caffeine.newBuilder() .maximumSize(10_000) // 缓存项上限 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期时间 .refreshAfterWrite(1, TimeUnit.MINUTES) // 刷新间隔 .build();
2.2 缓存数据结构设计
针对菜品套餐这种读多写少的场景,我们采用了两类数据结构:
- 基础信息缓存:使用String类型存储JSON化的菜品详情
- Key格式:
dish:{dishId} - TTL:30分钟
- Key格式:
- 组合查询缓存:使用Hash存储商家维度的菜品列表
- Key格式:
shop_dishes:{shopId} - Field:分类ID
- Value:该分类下的菜品ID列表
- Key格式:
这种设计使得获取商家菜单的复杂度从O(N)降低到O(1),在实测中,菜单加载时间从原来的800ms降至50ms左右。
3. 核心实现细节
3.1 缓存加载策略
我们实现了"预加载+懒加载"的混合模式:
- 预加载:商家营业状态变更时,主动预热该店菜品缓存
- 懒加载:用户首次查询时按需加载,采用双检锁避免缓存击穿
java复制public Dish getDishWithCache(Long dishId) {
// 1. 尝试从本地缓存获取
Dish dish = localCache.getIfPresent(dishId);
if (dish != null) {
return dish;
}
// 2. 尝试从Redis获取
String redisKey = "dish:" + dishId;
String json = redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isNotBlank(json)) {
dish = JSON.parseObject(json, Dish.class);
localCache.put(dishId, dish); // 回填本地缓存
return dish;
}
// 3. 数据库查询(加锁防击穿)
synchronized (this) {
// 二次检查
dish = localCache.getIfPresent(dishId);
if (dish != null) return dish;
// 查询数据库
dish = dishMapper.selectById(dishId);
if (dish != null) {
// 写入两级缓存
redisTemplate.opsForValue().set(
redisKey,
JSON.toJSONString(dish),
30, TimeUnit.MINUTES);
localCache.put(dishId, dish);
}
}
return dish;
}
3.2 缓存更新策略
采用"先更新数据库,再删除缓存"的Cache-Aside模式。对于关键操作(如下架菜品),额外增加了本地缓存失效广播:
java复制public void updateDish(Dish dish) {
// 1. 更新数据库
dishMapper.updateById(dish);
// 2. 删除Redis缓存
redisTemplate.delete("dish:" + dish.getId());
// 3. 通过消息队列通知其他节点失效本地缓存
rabbitTemplate.convertAndSend(
"cache.events",
new CacheEvictMessage("dish", dish.getId()));
}
4. 性能优化实践
4.1 热点数据动态识别
我们基于滑动窗口算法实现热点发现:
- 每5秒统计各菜品ID的访问频次
- 当某菜品QPS超过阈值(默认500)时,将其标记为热点
- 对热点数据采取特殊策略:
- 本地缓存TTL延长至10分钟
- 在Redis中增加副本(通过
dish:{id}:replica1等形式)
python复制# 伪代码:热点检测算法
def detect_hot_items(access_logs):
window_size = 5 # 5秒窗口
threshold = 500 # QPS阈值
counter = defaultdict(int)
for log in access_logs[-window_size:]:
counter[log.dish_id] += 1
hot_items = []
for dish_id, count in counter.items():
if count >= threshold * window_size:
hot_items.append(dish_id)
return hot_items
4.2 缓存雪崩防护
针对缓存集中失效的风险,我们采取了三重防护:
- 差异化过期时间:基础TTL 30分钟±随机5分钟偏移
- 永不过期策略:对核心菜品采用后台异步刷新
- 降级机制:当缓存不可用时,启用本地静态数据+限流
5. 问题排查与实战经验
5.1 典型问题记录
案例1:缓存穿透导致DB负载飙升
- 现象:监控显示大量
dish:-1的查询 - 根因:恶意请求使用非法ID
- 解决方案:
- 布隆过滤器预加载有效ID
- 对不存在的数据缓存空值(TTL 2分钟)
案例2:本地缓存不一致
- 现象:商家修改价格后,部分用户仍看到旧价格
- 根因:集群节点间缓存未同步
- 解决方案:
- 引入Redis Pub/Sub广播变更事件
- 本地缓存TTL缩短至1分钟
5.2 性能对比数据
优化前后关键指标对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 平均响应时间 | 450ms | 65ms | 85%↓ |
| 数据库QPS | 8,000 | 1,200 | 85%↓ |
| 高峰期错误率 | 1.2% | 0.05% | 96%↓ |
| 缓存命中率 | 68% | 93% | 37%↑ |
6. 扩展思考
在实际应用中,我们发现几个值得深入的点:
- 缓存维度选择:除了按ID缓存,我们还实验了按用户地理位置缓存不同价格体系
- 分级存储:将图片等大对象放在CDN,元数据才用Redis缓存
- 预热策略优化:通过分析用户访问模式,在用餐高峰前30分钟主动预热
对于Java技术栈,推荐使用Spring Cache抽象层统一管理缓存注解,但要注意:
- 避免在类内部方法调用时注解失效
- 复杂场景需要直接操作CacheManager
- 使用CacheResolver支持多缓存源切换
