1. 项目背景与核心挑战
"苍穹外卖"作为一款高频交易的外卖平台应用,其菜品套餐模块面临着典型的读多写少场景。根据我们线上监控数据显示,在午晚高峰时段,单个套餐详情页的QPS峰值可达1200+,而同一时段内管理端对菜品信息的更新操作平均每分钟不足5次。这种巨大的读写比例差,使得传统直接查询数据库的方案在高峰期经常出现数据库连接池耗尽、响应时间飙升的问题。
去年双十一大促期间,我们就曾因为未做缓存层,导致MySQL主库CPU持续保持在95%以上,触发了多次告警。当时临时扩容数据库节点虽然缓解了问题,但成本高昂且非长久之计。这次教训让我们深刻认识到:在外卖这类高并发场景中,合理的缓存设计不是可选项,而是必选项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存方案选型与技术对比
2.1 多级缓存架构设计
我们最终采用了经典的"本地缓存+分布式缓存"二级架构:
- 第一级:Caffeine本地缓存(每个应用实例内存中)
- 超时时间:60秒
- 最大容量:1000个菜品对象
- 第二级:Redis集群缓存
- 超时时间:5分钟
- 数据结构:Hash存储套餐完整信息
这种分层设计主要基于以下考虑:
- 本地缓存响应时间在0.5ms以内,能扛住90%以上的读请求
- Redis作为分布式缓存保证各节点数据一致性
- 两级缓存的超时时间差形成了自然的错峰更新机制
2.2 关键技术指标对比
| 指标 | 直接查DB | 纯Redis | 两级缓存 |
|---|---|---|---|
| 平均响应时间 | 120ms | 8ms | 1.2ms |
| 数据库QPS | 1200 | 60 | 15 |
| 网络IO消耗 | 低 | 高 | 中 |
| 数据一致性 | 强 | 弱 | 最终一致 |
3. 核心实现细节
3.1 缓存加载策略
我们采用"预加载+懒加载"混合模式:
java复制// 伪代码示例
public Dish getDishWithCache(Long dishId) {
// 1. 查本地缓存
Dish dish = caffeineCache.getIfPresent(dishId);
if (dish != null) {
return dish;
}
// 2. 查Redis
String redisKey = "dish:" + dishId;
dish = redisTemplate.opsForHash().get(redisKey, "info");
if (dish != null) {
// 回填本地缓存
caffeineCache.put(dishId, dish);
return dish;
}
// 3. 查数据库
dish = dishMapper.selectById(dishId);
if (dish != null) {
// 双写缓存
redisTemplate.opsForHash().put(redisKey, "info", dish);
redisTemplate.expire(redisKey, 5, TimeUnit.MINUTES);
caffeineCache.put(dishId, dish);
}
return dish;
}
3.2 缓存更新策略
针对不同的数据变更场景,我们设计了三种更新机制:
-
主动失效(管理端操作时触发)
- 删除Redis对应key
- 广播MQ消息通知各节点清理本地缓存
-
定时刷新(凌晨低峰期)
- 全量重建热门套餐的Redis缓存
- 通过配置中心下发指令清空本地缓存
-
兜底机制
- 所有缓存设置合理的TTL
- 本地缓存比Redis缓存更短的超时时间
4. 踩坑实录与性能优化
4.1 典型问题排查
问题现象:某次大促期间出现部分用户看到旧套餐价格
- 根因分析:本地缓存未及时失效,且Redis集群某个从节点同步延迟
- 解决方案:
- 引入Redisson的RTopic实现跨节点缓存失效通知
- 为关键数据增加版本号校验
问题现象:高峰期Redis连接数暴涨
- 根因分析:缓存击穿导致大量请求穿透到Redis
- 解决方案:
- 使用Redis的SETNX实现互斥锁
- 对空结果也进行短时间缓存
4.2 性能优化指标
优化前后关键指标对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 99线响应时间 | 450ms | 35ms |
| DB平均QPS | 800 | 50 |
| Redis峰值带宽 | 60MB/s | 15MB/s |
| 错误率 | 0.8% | 0.02% |
5. 扩展思考与实践建议
在实际落地过程中,我们发现几个值得注意的细节:
-
缓存key设计:建议采用"业务前缀:ID"的格式,如
dish:123。避免不同业务间的key冲突,也便于统计缓存使用情况。 -
本地缓存容量:需要根据JVM堆内存大小合理设置。我们的经验公式是:
最大条目数 = 可用堆内存(MB) * 1024 / 平均对象大小(KB)。 -
监控告警:必须对以下指标建立监控:
- 缓存命中率(区分本地/Redis)
- 缓存加载耗时
- 穿透请求量
-
压测建议:在预发布环境模拟以下场景:
- 缓存全量失效时的系统表现
- 突发流量增长时的自动扩容能力
- 网络分区时的降级方案
这套缓存方案上线后,我们的系统在保持数据库负载降低92%的同时,高峰期接口响应速度提升了40倍。更重要的是,它为后续的秒杀活动、智能推荐等场景提供了可复用的缓存基础设施。
