元素码农
基础
UML建模
数据结构
算法
设计模式
网络
TCP/IP协议
HTTPS安全机制
WebSocket实时通信
数据库
sqlite
postgresql
clickhouse
后端
rust
go
java
php
mysql
redis
mongodb
etcd
nats
zincsearch
前端
浏览器
javascript
typescript
vue3
react
游戏
unity
unreal
C++
C#
Lua
App
android
ios
flutter
react-native
安全
Web安全
测试
软件测试
自动化测试 - Playwright
人工智能
Python
langChain
langGraph
运维
linux
docker
工具
git
svn
🌞
🌙
目录
▶
Redis核心
▶
数据结构
字符串实现
哈希表实现
列表实现
集合实现
有序集合实现
▶
内存管理
内存分配策略
淘汰算法
▶
持久化
▶
RDB机制
快照生成原理
文件格式解析
▶
AOF机制
命令追加策略
重写过程分析
▶
高可用
▶
主从复制
SYNC原理
增量复制
▶
哨兵机制
故障检测
领导选举
▶
高级特性
▶
事务系统
ACID实现
WATCH原理
▶
Lua脚本
沙盒环境
执行管道
▶
实战问题
▶
缓存问题
缓存雪崩
缓存穿透
缓存击穿
缓存预热
▶
数据一致性
读写一致性
双写一致性
▶
性能优化
大key处理
热点key优化
发布时间:
2025-04-20 09:10
↑
☰
# Redis缓存击穿问题详解与解决方案 ## 什么是缓存击穿 缓存击穿是指热点数据的key在缓存中过期的瞬间,大量并发请求直接访问数据库,导致数据库瞬时压力剧增的现象。与缓存雪崩和缓存穿透的区别在于: 1. **缓存雪崩**:大量缓存同时过期或缓存服务器宕机 2. **缓存穿透**:查询不存在的数据,每次都会穿透到数据库 3. **缓存击穿**:单个热点key过期,大量并发请求同时查询这个key 缓存击穿的特点是: - 只涉及单个热点key - 该key是高频访问的热点数据 - 问题出现在key过期的瞬间 ## 缓存击穿的危害 缓存击穿虽然只影响单个key,但由于这类key通常是系统中的热点数据,访问量极大,因此可能造成以下危害: 1. **数据库负载突增**:在热点key过期的瞬间,大量请求直接访问数据库,可能导致数据库连接池耗尽。 2. **响应延迟增加**:由于数据库负载增加,系统响应时间会显著延长。 3. **连锁反应**:热点数据查询变慢可能影响依赖它的其他服务,产生连锁反应。 4. **用户体验下降**:系统响应变慢,直接影响用户体验。 ## 缓存击穿的解决方案 ### 1. 互斥锁(Mutex) 使用互斥锁是解决缓存击穿最常用的方法。当缓存失效时,只允许一个请求去查询数据库并更新缓存,其他请求等待或返回旧值: ```java // Java示例 - 使用Redis分布式锁 public UserInfo getUserInfo(Long userId) { String cacheKey = "user:" + userId; String lockKey = "lock:user:" + userId; // 查询缓存 UserInfo userInfo = redisTemplate.opsForValue().get(cacheKey); if (userInfo != null) { return userInfo; // 缓存命中,直接返回 } // 尝试获取锁,设置超时时间为3秒 boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); try { if (locked) { // 获取锁成功,再次检查缓存(双重检查,防止其他线程已更新缓存) userInfo = redisTemplate.opsForValue().get(cacheKey); if (userInfo != null) { return userInfo; } // 查询数据库 userInfo = userMapper.selectById(userId); if (userInfo != null) { // 更新缓存,设置过期时间 redisTemplate.opsForValue().set(cacheKey, userInfo, calculateExpireTime(), TimeUnit.SECONDS); } return userInfo; } else { // 获取锁失败,说明其他线程正在更新缓存,等待一段时间后重试 Thread.sleep(50); return getUserInfo(userId); // 递归调用,重试获取数据 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取用户信息被中断", e); } finally { // 释放锁,注意:实际应用中应该确保只释放自己的锁 if (locked) { redisTemplate.delete(lockKey); } } } // 计算过期时间,增加随机值防止同时过期 private int calculateExpireTime() { return 3600 + new Random().nextInt(300); // 基础过期时间1小时,增加0-5分钟随机值 } ``` ```go // Go示例 - 使用Redis分布式锁 func GetUserInfo(userId int64) (*UserInfo, error) { cacheKey := fmt.Sprintf("user:%d", userId) lockKey := fmt.Sprintf("lock:user:%d", userId) // 查询缓存 userInfoJson, err := redisClient.Get(ctx, cacheKey).Result() if err == nil { // 缓存命中 var userInfo UserInfo json.Unmarshal([]byte(userInfoJson), &userInfo) return &userInfo, nil } else if err != redis.Nil { // 发生了除了key不存在之外的错误 return nil, err } // 尝试获取锁,设置超时时间为3秒 locked, err := redisClient.SetNX(ctx, lockKey, "1", 3*time.Second).Result() if err != nil { return nil, err } if locked { defer redisClient.Del(ctx, lockKey) // 确保锁会被释放 // 再次检查缓存(双重检查) userInfoJson, err := redisClient.Get(ctx, cacheKey).Result() if err == nil { var userInfo UserInfo json.Unmarshal([]byte(userInfoJson), &userInfo) return &userInfo, nil } else if err != redis.Nil { return nil, err } // 查询数据库 userInfo, err := db.QueryUserInfo(userId) if err != nil { return nil, err } if userInfo != nil { // 更新缓存,设置过期时间 expireTime := 3600 + rand.Intn(300) // 基础过期时间1小时,增加0-5分钟随机值 userInfoJson, _ := json.Marshal(userInfo) redisClient.Set(ctx, cacheKey, userInfoJson, time.Duration(expireTime)*time.Second) } return userInfo, nil } else { // 获取锁失败,短暂等待后重试 time.Sleep(50 * time.Millisecond) return GetUserInfo(userId) // 递归调用,重试获取数据 } } ``` **互斥锁方案的优缺点**: - **优点**:实现简单,保证了数据的一致性,避免了缓存击穿。 - **缺点**:可能会导致系统的吞吐量下降;递归调用可能导致栈溢出;锁超时设置不当可能导致死锁。 ### 2. 热点数据永不过期 对于系统中的核心热点数据,可以设置为永不过期,或者使用后台线程定期更新缓存: ```java // Java示例 - 后台定时更新缓存 @Component @EnableScheduling public class HotDataCacheRefresher { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private UserMapper userMapper; // 存储热点数据的ID列表 private List<Long> hotUserIds; @PostConstruct public void init() { // 初始化热点数据列表 hotUserIds = userMapper.selectHotUserIds(); } // 每5分钟更新一次热点数据缓存 @Scheduled(fixedRate = 300000) public void refreshHotDataCache() { for (Long userId : hotUserIds) { String cacheKey = "user:" + userId; UserInfo userInfo = userMapper.selectById(userId); if (userInfo != null) { // 更新缓存,不设置过期时间 redisTemplate.opsForValue().set(cacheKey, userInfo); } } } // 定期更新热点数据列表 @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void updateHotDataList() { hotUserIds = userMapper.selectHotUserIds(); } } ``` **永不过期方案的优缺点**: - **优点**:彻底避免了缓存击穿问题;减少了数据库的压力。 - **缺点**:需要额外的维护机制;可能会有一定的数据不一致窗口期;占用更多的缓存空间。 ### 3. 提前更新缓存 在缓存即将过期前,提前异步更新缓存数据: ```java // Java示例 - 提前更新缓存 public UserInfo getUserInfo(Long userId) { String cacheKey = "user:" + userId; // 查询缓存 ValueOperations<String, Object> ops = redisTemplate.opsForValue(); UserInfo userInfo = (UserInfo) ops.get(cacheKey); if (userInfo != null) { // 获取缓存剩余过期时间 Long ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS); // 如果过期时间小于5分钟,异步更新缓存 if (ttl != null && ttl > 0 && ttl < 300) { CompletableFuture.runAsync(() -> { // 使用互斥锁防止多个线程同时更新 String refreshLockKey = "refresh:lock:user:" + userId; boolean locked = redisTemplate.opsForValue().setIfAbsent(refreshLockKey, "1", 10, TimeUnit.SECONDS); if (locked) { try { // 查询数据库获取最新数据 UserInfo latestUserInfo = userMapper.selectById(userId); if (latestUserInfo != null) { // 更新缓存,重置过期时间 ops.set(cacheKey, latestUserInfo, calculateExpireTime(), TimeUnit.SECONDS); } } finally { // 释放锁 redisTemplate.delete(refreshLockKey); } } }); } return userInfo; } // 缓存未命中,使用互斥锁方案处理 // ... (与前面互斥锁示例相同) } ``` **提前更新方案的优缺点**: - **优点**:用户无需等待缓存更新;避免了缓存击穿。 - **缺点**:实现相对复杂;可能会增加系统的负载。 ### 4. 二级缓存 使用本地缓存作为一级缓存,Redis作为二级缓存,即使Redis缓存失效,仍可以通过本地缓存提供服务: ```java // Java示例 - 使用Caffeine作为本地缓存 import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; public class UserService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private UserMapper userMapper; // 创建本地缓存 private LoadingCache<Long, UserInfo> localCache = Caffeine.newBuilder() .maximumSize(10000) // 最多缓存10000个用户 .expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存5分钟过期 .build(userId -> { // 缓存加载函数,当本地缓存未命中时调用 return getUserInfoFromRedisOrDb(userId); }); public UserInfo getUserInfo(Long userId) { // 先查本地缓存 return localCache.get(userId); } private UserInfo getUserInfoFromRedisOrDb(Long userId) { String cacheKey = "user:" + userId; // 查询Redis缓存 UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(cacheKey); if (userInfo != null) { return userInfo; } // Redis缓存未命中,使用互斥锁查询数据库 String lockKey = "lock:user:" + userId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); try { if (locked) { // 再次检查Redis缓存 userInfo = (UserInfo) redisTemplate.opsForValue().get(cacheKey); if (userInfo != null) { return userInfo; } // 查询数据库 userInfo = userMapper.selectById(userId); if (userInfo != null) { // 更新Redis缓存 redisTemplate.opsForValue().set(cacheKey, userInfo, calculateExpireTime(), TimeUnit.SECONDS); } return userInfo; } else { // 获取锁失败,短暂等待后重试 Thread.sleep(50); return getUserInfoFromRedisOrDb(userId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取用户信息被中断", e); } finally { if (locked) { redisTemplate.delete(lockKey); } } } } ``` **二级缓存方案的优缺点**: - **优点**:提供了更高的可用性;减轻了Redis的压力。 - **缺点**:增加了系统复杂度;可能会有数据一致性问题。 ## 实践建议 1. **选择合适的方案**:根据业务特点和系统架构选择合适的解决方案。对于访问量特别大的热点数据,可以考虑永不过期方案;对于一般的热点数据,互斥锁方案通常是最简单有效的。 2. **合理设置过期时间**:为不同类型的数据设置不同的过期时间,并添加随机因子避免同时过期。 3. **监控热点数据**:建立有效的监控系统,及时发现热点数据,并对其进行特殊处理。 4. **压力测试**:在上线前进行充分的压力测试,评估系统在缓存失效情况下的承受能力。 5. **降级策略**:当系统负载过高时,可以启动降级策略,返回默认值或旧数据,避免系统崩溃。 ## 总结 缓存击穿是分布式系统中常见的问题,它可能导致数据库负载突增,影响系统性能。通过互斥锁、热点数据永不过期、提前更新缓存和二级缓存等策略,可以有效预防和应对缓存击穿问题。 在实际应用中,通常需要结合多种策略,并根据业务特点和系统架构进行定制,以构建一个高性能、高可用的缓存系统。同时,持续的监控和优化也是保障系统稳定运行的关键。