1. 问题背景与现象描述
最近在开发黑马点评项目的店铺类型查询功能时,遇到了一个典型的高并发场景下的性能问题。当用户频繁刷新店铺列表页面时,数据库查询压力陡增,响应时间从最初的200ms逐渐恶化到1.5秒以上。更棘手的是,在添加Redis缓存后,虽然数据库压力得到了缓解,但又出现了新的问题——部分店铺图片加载不出来,控制台报错"Failed to load resource: net::ERR_CACHE_MISS"。
这种情况在电商类应用中非常典型。当系统流量突然增长时,如果没有合理的缓存策略,数据库很容易成为瓶颈。而当我们引入缓存层后,如果处理不当,又可能引发新的问题。接下来我将详细分析这个问题的完整解决过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原始方案的问题诊断
2.1 无缓存时的性能瓶颈
在没有引入缓存的情况下,每次查询店铺类型都会直接访问MySQL数据库。通过Arthas监控发现,当QPS达到200时,数据库CPU使用率已经超过80%,查询响应时间明显变长。关键问题点在于:
- 店铺类型数据属于基础数据,变更频率低(平均每天更新不到5次)
- 但查询频率极高(首页每次加载都需要查询)
- 原始SQL没有优化,使用了SELECT * 查询所有字段
- 没有使用数据库连接池,每次查询都新建连接
2.2 缓存方案初版的问题
第一版缓存方案采用了最简单的Redis String结构存储序列化后的店铺数据:
java复制// 伪代码示例
public List<ShopType> queryTypeList() {
// 1. 先查缓存
String key = "shop:types";
String cacheValue = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(cacheValue)) {
return JSON.parseArray(cacheValue, ShopType.class);
}
// 2. 查数据库
List<ShopType> types = shopTypeMapper.selectList();
// 3. 写入缓存
redisTemplate.opsForValue().set(key, JSON.toJSONString(types));
return types;
}
这个方案虽然缓解了数据库压力,但带来了新的问题:
- 缓存击穿:当缓存过期时,大量请求同时打到数据库
- 图片加载失败:因为店铺数据中的图片URL是相对路径,而前端页面部署的域名与后端不同
- 数据一致性问题:当管理员更新店铺类型时,缓存未及时清除
3. 缓存方案优化与实现
3.1 Redis数据结构选型
经过分析,我们决定采用更合适的Redis数据结构:
- 使用Hash结构存储店铺类型,便于单独更新字段
- 为每个店铺类型设置独立key,避免大value问题
- 引入布隆过滤器防止缓存穿透
优化后的存储结构如下:
code复制shop:type:1 -> {
"id": 1,
"name": "美食",
"icon": "/images/food.png",
"sort": 1
}
shop:type:2 -> {
"id": 2,
"name": "休闲娱乐",
"icon": "/images/entertainment.png",
"sort": 2
}
3.2 缓存读写策略
采用Cache Aside Pattern模式,并添加了互斥锁防止缓存击穿:
java复制public List<ShopType> queryTypeList() {
// 1. 先查缓存
List<ShopType> types = getFromCache();
if (types != null) {
return types;
}
// 2. 获取分布式锁
String lockKey = "lock:shop:types";
try {
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
// 获取锁失败,短暂休眠后重试
Thread.sleep(50);
return queryTypeList();
}
// 3. 二次检查缓存
types = getFromCache();
if (types != null) {
return types;
}
// 4. 查询数据库
types = shopTypeMapper.selectList();
// 5. 写入缓存
setToCache(types);
return types;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
3.3 缓存过期与更新策略
- 设置合理的过期时间:基础数据TTL设为1小时 + 随机偏移量(防止同时过期)
- 实现双删策略保证数据一致性:
java复制@Transactional
public void updateShopType(ShopType type) {
// 1. 更新数据库
shopTypeMapper.updateById(type);
// 2. 删除缓存
redisTemplate.delete("shop:type:" + type.getId());
// 3. 延迟再次删除(通过消息队列)
sendDelayMessage("shop:type:" + type.getId());
}
4. 图片加载问题解决方案
4.1 问题根源分析
图片加载失败的原因主要有:
- 前端页面部署在https://www.example.com
- 后端接口返回的图片路径是相对路径如"/images/food.png"
- 浏览器尝试从https://www.example.com/images/food.png加载图片
- 但实际图片存储在CDN或对象存储上
4.2 解决方案实现
方案一:返回完整URL
在后端处理数据时,将相对路径转换为绝对路径:
java复制public List<ShopType> processImageUrls(List<ShopType> types) {
String cdnDomain = "https://cdn.example.com";
return types.stream()
.map(type -> {
type.setIcon(cdnDomain + type.getIcon());
return type;
})
.collect(Collectors.toList());
}
方案二:前端统一处理
在前端请求拦截器中统一添加域名前缀:
javascript复制axios.interceptors.response.use(response => {
if (response.data && Array.isArray(response.data)) {
response.data.forEach(item => {
if (item.icon && item.icon.startsWith('/')) {
item.icon = process.env.VUE_APP_CDN_URL + item.icon;
}
});
}
return response;
});
方案三:Nginx反向代理
通过Nginx配置将图片请求代理到正确的存储位置:
nginx复制location /images/ {
proxy_pass https://cdn.example.com/images/;
proxy_set_header Host cdn.example.com;
}
5. 性能优化与监控
5.1 缓存命中率监控
通过Redis的INFO命令监控缓存命中率:
bash复制# 监控命令
redis-cli info stats | grep keyspace_hits
redis-cli info stats | grep keyspace_misses
# 计算命中率
hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
5.2 慢查询优化
- 添加Redis慢查询日志:
bash复制# redis.conf配置
slowlog-log-slower-than 10000 # 10毫秒
slowlog-max-len 128
- 使用Redis的BIGKEYS命令分析大key:
bash复制redis-cli --bigkeys
5.3 JVM调优
针对缓存服务调整JVM参数:
code复制-Xms2g -Xmx2g -XX:MaxMetaspaceSize=512m
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
6. 踩坑经验与注意事项
-
序列化问题:使用RedisTemplate时,确保key和value的序列化方式一致。我们曾经因为key使用String序列化而value使用Jackson序列化导致查询失败。
-
缓存雪崩防护:设置缓存过期时间时一定要加随机值,我们曾经因为所有缓存同时过期导致数据库瞬间被打满。
-
连接池配置:Redis连接池参数需要根据实际QPS调整,默认值通常不够用。我们遇到过因为连接池耗尽导致的请求超时。
-
本地缓存配合:对于极高频访问且极少变更的数据,可以添加Caffeine本地缓存作为二级缓存:
java复制@Bean
public CaffeineCacheManager cacheManager() {
Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES);
return new CaffeineCacheManager("shopTypes", caffeine);
}
- 图片处理建议:对于电商类应用,建议从一开始就使用完整的CDN URL存储图片路径,避免后期迁移成本。
这个优化过程让我深刻体会到,缓存看似简单,但要在生产环境中用好却需要考虑很多细节。特别是在高并发场景下,一个小问题就可能被放大成严重故障。建议大家在设计缓存方案时,一定要结合业务特点进行全面考虑,并做好充分的压力测试。
