1. 项目背景与缓存需求分析
"黑马点评"作为一款本地生活服务类应用,商户信息查询是其核心功能之一。当用户搜索附近餐厅或查看商户详情时,系统需要快速返回商户名称、评分、人均消费等关键信息。在高并发场景下(如用餐高峰期的集中查询),直接访问数据库会导致:
- 响应延迟:单次MySQL查询约10-50ms,百级QPS时延迟显著上升
- 数据库压力:商户基础信息这类静态数据被反复查询,造成资源浪费
- 可用性风险:DB负载过高可能引发连锁故障
我们通过实测发现:某商户详情页QPS峰值达到320时,数据库CPU利用率飙升至78%,平均响应时间从12ms恶化到210ms。此时引入Redis缓存可将99%请求的响应时间控制在5ms内,数据库负载下降至15%以下。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis缓存方案设计
2.1 数据结构选型
针对商户信息缓存,采用String类型存储JSON序列化对象:
bash复制SET shop:1 '{"id":1,"name":"外婆家","type":"杭帮菜"...}'
而非Hash结构,因为:
- 商户信息作为整体读取,无需单独修改字段
- JSON序列化后平均大小约1.2KB,远小于Redis单Value最大512MB限制
- 反序列化成本可忽略(实测Jackson解析约0.3ms)
2.2 缓存读写策略
采用经典的Cache-Aside Pattern:
java复制public Shop getShopById(Long id) {
// 1. 尝试从缓存获取
String key = "shop:" + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(shopJson)) {
return JSON.parseObject(shopJson, Shop.class);
}
// 2. 缓存未命中则查数据库
Shop shop = shopMapper.selectById(id);
if (shop == null) {
return null; // 防缓存穿透可设置空值
}
// 3. 写入缓存
redisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
return shop;
}
3. 缓存一致性保障
3.1 双写更新策略
商户信息变更时采用先更新DB再删除缓存:
java复制@Transactional
public void updateShop(Shop shop) {
// 1. 更新数据库
shopMapper.updateById(shop);
// 2. 删除缓存
redisTemplate.delete("shop:" + shop.getId());
// 3. 异步刷新缓存(可选)
refreshCacheExecutor.execute(() -> {
redisTemplate.opsForValue().set(
"shop:" + shop.getId(),
JSON.toJSONString(shop),
30, TimeUnit.MINUTES
);
});
}
该方案优势:
- 避免"先删缓存再更新DB"时出现的脏读窗口
- 通过异步刷新缓解缓存击穿问题
- 最终一致性延迟约200-500ms(实测值)
3.2 缓存过期策略
采用基础过期时间+随机抖动:
java复制// 基础30分钟 + 随机0-10分钟,避免集中失效
int expireSeconds = 1800 + new Random().nextInt(600);
redisTemplate.opsForValue().set(
key,
value,
expireSeconds,
TimeUnit.SECONDS
);
4. 异常场景处理
4.1 缓存穿透防护
针对不存在的shopId,缓存空对象并设置较短TTL:
java复制if (shop == null) {
redisTemplate.opsForValue().set(
"shop:" + id,
"NULL",
5,
TimeUnit.MINUTES
);
return null;
}
4.2 缓存雪崩应对
除随机过期时间外,增加本地二级缓存:
java复制// Caffeine本地缓存
private final Cache<Long, Shop> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
public Shop getShopWithMultiCache(Long id) {
// 先查本地缓存
Shop shop = localCache.getIfPresent(id);
if (shop != null) {
return shop;
}
// 再走Redis查询流程...
}
5. 性能优化实践
5.1 热点数据预加载
通过定时任务预热TOP100商户:
java复制@Scheduled(cron = "0 0 6 * * ?")
public void preloadHotShops() {
List<Long> hotShopIds = shopMapper.selectHotShopIds(100);
hotShopIds.forEach(id -> {
Shop shop = shopMapper.selectById(id);
redisTemplate.opsForValue().set(
"shop:" + id,
JSON.toJSONString(shop),
2, TimeUnit.HOURS
);
});
}
5.2 批量查询优化
使用Redis Pipeline批量获取多个商户:
java复制List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
shopIds.forEach(id -> {
connection.stringCommands().get(("shop:" + id).getBytes());
});
return null;
});
List<Shop> shops = results.stream()
.map(obj -> JSON.parseObject((String)obj, Shop.class))
.collect(Collectors.toList());
实测显示:获取20个商户信息时,Pipeline比单次请求快8-12倍。
6. 监控与调优
6.1 关键指标监控
通过Redis命令统计缓存命中率:
bash复制# 查看keyspace命中情况
redis-cli info stats | grep keyspace
建议配置告警规则:
- 缓存命中率<90%
- 内存使用>70%
- 网络输入/输出流量突增50%
6.2 内存优化技巧
对于大型商户(如连锁品牌),采用压缩存储:
java复制redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
redisTemplate.setValueSerializer(new SnappyRedisSerializer()); // 自定义压缩序列化
实测Snappy压缩可使1.5KB的商户信息缩小至600-800KB,节省35-50%内存空间。
