1. 项目背景与核心诉求
在本地生活服务类应用中,商户信息的快速访问直接影响用户体验。我们近期对"黑马点评"系统进行性能优化时发现:当用户密集查询热门商铺信息时,数据库负载激增,响应时间从平均200ms飙升至1.2秒以上。通过火焰图分析,80%的查询时间消耗在重复的商户数据IO操作上。
这个现象引出了两个关键优化点:
- 高频访问的单个商户详情(如月销过万的网红餐厅)
- 地理位置查询返回的商铺列表(如"3公里内评分前10的咖啡厅")
2. 缓存方案设计
2.1 技术选型对比
| 方案 | 读写性能 | 内存占用 | 数据结构支持 | 集群支持 | 最终选择 |
|---|---|---|---|---|---|
| Redis String | ★★★★★ | 中等 | 简单KV | 完善 | 商户详情 |
| Redis Hash | ★★★★☆ | 较低 | 结构化存储 | 完善 | 备用方案 |
| Memcached | ★★★★☆ | 最低 | 简单KV | 有限 | 未采用 |
| LocalCache | ★★★★★ | 视配置 | 多样 | 不支持 | 二级缓存 |
选择Redis作为主缓存的原因:
- 支持丰富的数据结构,未来可扩展使用Hash存储完整商户对象
- 原生支持集群模式,避免单点故障
- 持久化机制保证缓存异常时数据可恢复
2.2 缓存结构设计
商户详情缓存采用标准KV结构:
code复制key: shop:{shopId}
value: {
"id": 123,
"name": "XX火锅",
"avgPrice": 85,
"sold": 12543,
"...": "..."
}
商铺列表缓存采用分页结构:
code复制key: shops:{geoHash}:{typeId}:{page}
value: [shopId1, shopId2,...]
关键设计点:列表缓存只存储ID,通过二次查询获取完整数据。虽然增加了一次查询,但避免了列表数据变更时的批量更新问题。
3. 核心实现细节
3.1 双写一致性保障
采用"先更新数据库再删除缓存"的策略,配合消息队列实现最终一致性:
java复制// 商户更新操作示例
@Transactional
public void updateShop(Shop shop) {
// 1. 更新数据库
shopMapper.updateById(shop);
// 2. 删除缓存
redisTemplate.delete("shop:" + shop.getId());
// 3. 发送延迟消息(补偿用)
mqSender.sendDelayMessage(
new CacheMessage(shop.getId(), 1),
5, TimeUnit.SECONDS
);
}
补偿机制设计:
- 首次删除失败后,5秒后通过消息队列重试
- 设置最大重试次数3次
- 最终失败时记录到死信队列人工处理
3.2 缓存穿透防护
针对商铺列表查询的防护措施:
java复制public List<Shop> queryShopsByType(int typeId, int page) {
String key = "shops:" + typeId + ":" + page;
// 布隆过滤器预检
if (!bloomFilter.mightContain(key)) {
return Collections.emptyList();
}
// 查询缓存
String cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
if (cache.equals("NULL")) { // 空值缓存
return Collections.emptyList();
}
return JSON.parseArray(cache, Shop.class);
}
// 查询数据库
List<Shop> shops = shopMapper.queryByType(typeId, page);
// 写入缓存
if (shops.isEmpty()) {
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(shops),
30,
TimeUnit.MINUTES
);
}
return shops;
}
4. 性能优化实战
4.1 热点Key发现与处理
通过Redis的MONITOR命令发现热点Key后,采取以下措施:
- 本地缓存加持:
java复制@Cacheable(value = "shop", key = "#id")
public Shop getById(Long id) {
// 先查Redis...
}
- Key分片策略:
code复制原Key: shop:123
分片Key: shop:{123%10}:123 // 分成10个slot
- 随机过期时间避免缓存雪崩:
java复制int baseTTL = 30 * 60; // 30分钟
int randomTTL = baseTTL + new Random().nextInt(300); // 加0-5分钟随机值
redisTemplate.expire(key, randomTTL, TimeUnit.SECONDS);
4.2 缓存预热方案
开发定时任务在流量低谷期预热:
java复制@Scheduled(cron = "0 0 4 * * ?") // 每天4点执行
public void preloadHotShops() {
// 1. 查询昨日热榜
List<Long> hotIds = statsMapper.getYesterdayHotShops(100);
// 2. 批量查询并缓存
List<Shop> shops = shopMapper.selectBatchIds(hotIds);
shops.forEach(shop -> {
String key = "shop:" + shop.getId();
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(shop),
6, TimeUnit.HOURS
);
});
}
5. 监控与调优
5.1 监控指标配置
-
Redis关键指标监控:
- 缓存命中率(>85%为健康)
- 内存使用率(<70%警戒线)
- 命令耗时(P99 < 10ms)
-
业务指标埋点:
java复制// 通过AOP记录缓存操作
@Around("execution(* com.heima.cache..*.*(..))")
public Object monitorCache(ProceedingJoinPoint pjp) {
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
Metrics.timer("cache.operation.time").record(cost);
}
}
5.2 参数调优经验
- 连接池配置优化:
yaml复制spring:
redis:
lettuce:
pool:
max-active: 50 # 根据QPS调整,建议QPS/100
max-idle: 20
min-idle: 5
max-wait: 1000ms
- Redis服务器配置:
code复制# redis.conf关键参数
maxmemory 8gb
maxmemory-policy allkeys-lru
timeout 300
tcp-keepalive 60
6. 踩坑实录
-
缓存雪崩事故:
- 现象:某次大促期间大量缓存同时过期,数据库QPS瞬间翻10倍
- 解决:增加随机TTL后,过期时间离散分布在30-35分钟之间
-
大Key问题:
- 发现:某个商铺的评论列表缓存达到1.2MB
- 优化:改为分页缓存,单页不超过50条评论
-
热点Key争抢:
- 现象:某网红店铺详情Key的QPS达到1w+
- 方案:采用本地缓存+Redis多级缓存,压力下降90%
关键教训:任何缓存设置必须带过期时间!我们曾因忘记设置TTL导致内存溢出,最终只能手动flushDB。