一、缓存穿透:当请求绕过缓存直奔数据库
想象一下,你开了一家图书馆(数据库),为了快速找书,你建了一个索引卡片柜(Redis缓存)。正常情况下,读者先查卡片,找到书号再去书架上拿。但有个捣蛋鬼,天天来查一本根本不存在的书(比如《如何在一周内成为世界首富》)。卡片柜里肯定没有,他每次都得让你把整个图书馆翻个底朝天,结果当然是白费力气。你的体力(数据库资源)被白白消耗,这就是“缓存穿透”。
核心问题: 大量请求查询一个数据库中根本不存在的数据,导致请求直接打到数据库上。
解决方案:
- 缓存空对象: 即使数据库没有,也在缓存里存一个空值(比如
NULL),并设置一个较短的过期时间。这样后续的相同请求在缓存层就被拦截了。 - 布隆过滤器: 在缓存之前,加一个“预检员”。这个“预检员”很聪明,它能快速告诉你“这本书肯定不存在”或者“可能存在”。对于“肯定不存在”的请求,直接返回,保护数据库。
技术栈:Java (Spring Boot)
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper; // 假设是MyBatis的Mapper
/**
* 查询商品信息,解决缓存穿透方案:缓存空对象
* @param productId 商品ID
* @return 商品信息,若不存在返回null
*/
public Product getProductById(Long productId) {
String cacheKey = "product:" + productId;
// 1. 先从缓存中取
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
// 2. 缓存命中,判断是否是空对象(我们约定一个特殊值,比如id为-1的对象)
if (product.getId() == -1L) {
return null; // 缓存中存的是空对象,直接返回null
}
return product; // 返回缓存中的真实数据
}
// 3. 缓存未命中,查询数据库
product = productMapper.selectById(productId);
if (product == null) {
// 4. 数据库也不存在,缓存一个空对象(过期时间设短点,比如60秒)
Product nullProduct = new Product();
nullProduct.setId(-1L); // 用特殊ID标记为空对象
redisTemplate.opsForValue().set(cacheKey, nullProduct, 60, TimeUnit.SECONDS);
return null;
} else {
// 5. 数据库存在,写入缓存(设置正常过期时间,比如30分钟)
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
}
}
二、缓存击穿:热点数据的“猝死”
你的图书馆里有一本超级畅销书(热点数据),比如《哈利波特与魔法石》。这本书的索引卡片(缓存)设置了1小时自动更新。1小时到了,卡片刚好被清空。这时,成千上万的读者同时来借这本书,他们发现卡片柜里没有,于是所有人一窝蜂地冲向同一个书架,瞬间把书架挤塌了(数据库压力激增)。这就是“缓存击穿”。
核心问题: 一个访问量巨大的缓存key在过期瞬间,大量请求直接击穿缓存,落到数据库上。
解决方案:
- 永不过期: 对极少数真正的热点key,设置为逻辑上永不过期。通过后台任务异步更新其值。
- 互斥锁: 只让一个请求去数据库查询和重建缓存,其他请求等待。这就像只派一个管理员去书库取书,其他人排队。
技术栈:Java (Spring Boot)
@Service
public class HotNewsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate; // 用于分布式锁
@Autowired
private NewsMapper newsMapper;
/**
* 获取热点新闻,解决缓存击穿方案:分布式互斥锁
* @param newsId 新闻ID
* @return 新闻详情
*/
public News getHotNews(Long newsId) throws InterruptedException {
String cacheKey = "hot_news:" + newsId;
// 1. 尝试从缓存获取
News news = (News) redisTemplate.opsForValue().get(cacheKey);
if (news != null) {
return news;
}
// 2. 缓存未命中,尝试获取锁
String lockKey = "lock:hot_news:" + newsId;
String clientId = UUID.randomUUID().toString(); // 生成唯一标识,防止误删别人的锁
// 使用SET命令的NX(不存在才设置)和PX(过期时间)参数实现分布式锁
Boolean isLocked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(isLocked)) {
// 3. 获取锁成功,再次检查缓存(双重检查,防止其他线程已经重建好)
try {
news = (News) redisTemplate.opsForValue().get(cacheKey);
if (news != null) {
return news;
}
// 4. 查询数据库(模拟耗时)
Thread.sleep(100);
news = newsMapper.selectById(newsId);
if (news != null) {
// 5. 写入缓存,设置过期时间
redisTemplate.opsForValue().set(cacheKey, news, 1, TimeUnit.HOURS);
} else {
// 处理数据库不存在的情况,可以缓存空值防穿透
redisTemplate.opsForValue().set(cacheKey, new News(), 5, TimeUnit.MINUTES);
}
return news;
} finally {
// 6. 释放锁,确保锁是自己加的才删除(Lua脚本保证原子性)
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey), clientId);
}
} else {
// 7. 获取锁失败,说明有其他线程在重建缓存,等待一小段时间后重试
Thread.sleep(50);
return getHotNews(newsId); // 递归重试,也可以改为循环
}
}
}
三、缓存雪崩:大量缓存同时“罢工”
想象一下,图书馆里大部分图书的索引卡片(缓存)有效期都设成了相同的1小时。1小时到了,所有卡片同时失效。这时涌进来一大批读者,发现卡片柜几乎全空了,于是所有人同时冲向书库,瞬间造成灾难性的拥堵,系统可能直接崩溃。这就是“缓存雪崩”。
核心问题: 大量缓存key在同一时间大面积失效,导致所有请求涌向数据库。
解决方案:
- 差异化过期时间: 这是最有效的方法。给缓存设置过期时间时,加上一个随机因子,让它们在相近但不同的时间点失效。
// 设置过期时间为 基础时间 + 随机偏移量 int expireTime = 3600 + new Random().nextInt(600); // 3600秒 ± 600秒随机 redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS); - 构建高可用缓存集群: 使用Redis Sentinel或Cluster,避免单点故障导致整个缓存层不可用。
- 服务降级与熔断: 当数据库压力过大时,通过Hystrix、Sentinel等组件暂时屏蔽部分非核心请求,或返回默认值,保护数据库。
四、数据一致性难题:缓存和数据库谁说了算?
你更新了图书馆里某本书的信息(比如作者简介),你修改了书架上的书(数据库),但忘了更新索引卡片(缓存)。下次有人通过卡片查这本书,看到的就是过时的信息。反之亦然。保持缓存和数据库的数据同步,是个经典难题。
核心策略:
- 先更新数据库,再删除缓存: 这是更常用的策略,通常能更好地保证一致性。但删除缓存可能失败,需要重试机制。
- 先删除缓存,再更新数据库: 在并发极高时可能产生脏数据,但可以通过“延迟双删”等策略优化。
技术栈:Java (Spring Boot)
@Service
@Transactional
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate; // 使用消息队列进行异步重试
private static final String ORDER_CACHE_PREFIX = "order:";
/**
* 更新订单状态,并处理缓存一致性(先更新DB,再删缓存,异步重试)
* @param orderId 订单ID
* @param newStatus 新状态
*/
public void updateOrderStatus(Long orderId, String newStatus) {
// 1. 更新数据库
orderMapper.updateStatus(orderId, newStatus);
// 2. 删除缓存
String cacheKey = ORDER_CACHE_PREFIX + orderId;
Boolean deleteSuccess = redisTemplate.delete(cacheKey);
// 3. 如果删除失败,发送消息到MQ进行异步重试删除
if (Boolean.FALSE.equals(deleteSuccess)) {
Map<String, Object> message = new HashMap<>();
message.put("cacheKey", cacheKey);
message.put("retryCount", 0);
rabbitTemplate.convertAndSend("cache.delete.retry.exchange",
"cache.delete.key", message);
System.out.println("缓存删除失败,已发送重试消息: " + cacheKey);
}
}
/**
* 消息队列的消费者,负责重试删除缓存
*/
@RabbitListener(queues = "cache.delete.retry.queue")
public void handleCacheDeleteRetry(Map<String, Object> message) {
String cacheKey = (String) message.get("cacheKey");
Integer retryCount = (Integer) message.get("retryCount");
if (retryCount < 3) { // 最多重试3次
Boolean success = redisTemplate.delete(cacheKey);
if (Boolean.FALSE.equals(success)) {
message.put("retryCount", retryCount + 1);
// 再次发送到延迟队列,等待一段时间后重试
rabbitTemplate.convertAndSend("cache.delete.delay.exchange",
"cache.delete.key", message,
msg -> {
msg.getMessageProperties().setDelay(5000); // 延迟5秒
return msg;
});
} else {
System.out.println("重试删除缓存成功: " + cacheKey);
}
} else {
System.out.println("重试删除缓存失败超过3次,请人工介入: " + cacheKey);
// 可以发送报警邮件或短信
}
}
}
五、内存管理与淘汰:房子满了怎么办?
Redis就像你电脑的内存,空间是有限的。当缓存数据把内存塞满时,新的数据就进不来了。Redis提供了几种“清理房间”的策略,叫做内存淘汰策略。你需要根据业务特点来选择。
常见策略:
noeviction: 新写入操作会报错。(默认策略,生产环境慎用)allkeys-lru: 在所有key中,移除最近最少使用的key。这是最常用的通用策略。volatile-lru: 在设置了过期时间的key中,移除最近最少使用的。allkeys-random: 随机移除某个key。volatile-ttl: 在设置了过期时间的key中,移除即将过期的。
如何选择:
如果你的应用有明显的热点数据(如20%的数据承载80%的访问),allkeys-lru 是很好的选择。如果你的数据都有过期时间,且重要性不同,volatile-ttl 可能更合适。务必在 redis.conf 配置文件中根据业务场景进行设置。
六、慢查询与Big Key:隐藏在细节里的性能杀手
有时候Redis变慢,不是因为并发高,而是因为某个操作本身就很耗时。
- 慢查询: 执行时间过长的命令,比如对一个包含百万成员的Set执行
SMEMBERS,或者模糊匹配KEYS *(生产环境绝对禁止!应用SCAN代替)。 - Big Key: 指一个key对应的value非常大,比如一个5MB的String,或者一个包含几十万元素的List/Hash。这种key在传输、序列化/反序列化、删除时都会非常耗时,可能阻塞Redis。
排查与解决:
- 使用
SLOWLOG GET命令查看慢查询日志。 - 使用
redis-cli --bigkeys扫描大key(注意在从节点执行,避免影响主节点性能)。 - 对于Big Key,考虑拆分。比如一个大的Hash可以按字段前缀拆分成多个小的Hash;一个大的List可以按范围拆分成多个List。
应用场景、技术优缺点、注意事项与总结
应用场景: Redis缓存广泛应用于需要快速响应的场景,如:网页内容缓存(文章详情)、会话存储(Session)、排行榜(Sorted Set)、计数器(原子递增)、消息队列(List/Stream)、社交关系(Set)等。它是提升系统性能、降低数据库负载的利器。
技术优缺点:
- 优点: 性能极高(内存操作),数据结构丰富,支持持久化,功能强大(发布订阅、Lua脚本、事务等)。
- 缺点: 作为缓存,数据可能丢失(取决于持久化策略);内存成本较高;集群配置和维护有一定复杂度。
注意事项:
- 不是银弹: 缓存适用于“读多写少”的热点数据。写频繁的数据可能带来严重的一致性问题。
- 容量规划: 必须根据业务数据量规划好内存大小,并设置合理的淘汰策略。
- 监控告警: 必须对Redis的内存使用率、连接数、命中率、慢查询等关键指标进行监控。
- 备份与持久化: 理解RDB和AOF的优缺点,根据数据重要性配置合适的持久化方案。
- 安全: 设置密码,禁用高危命令(如
FLUSHALL,KEYS),绑定网络接口。
文章总结: 使用Redis缓存就像给系统加装了一个高速缓冲区,能极大提升性能。但用好它,必须警惕穿透、击穿、雪崩这“三兄弟”的破坏,妥善处理数据一致性难题,并做好内存管理和性能监控。理解这些常见故障背后的原理,掌握相应的排查和解决方案,是每一位开发者在构建稳健、高性能系统时的必修课。记住,缓存是门艺术,核心在于平衡:速度与一致性、空间与时间、复杂度与收益之间的平衡。
评论