好的,各位听众,欢迎来到今天的“Redis缓存那些事儿”讲座。今天咱们要聊的是Redis作为分布式缓存时,那些让人头疼的“穿透”、“雪崩”和“击穿”,以及怎么用各种姿势优雅地解决它们。
开场白:缓存,你又爱又恨的小妖精
缓存,这玩意儿就像你家冰箱,放点常用的东西进去,拿的时候嗖嗖快,但用不好,它也能变成细菌滋生的温床。在分布式系统中,Redis就是这个冰箱,它能加速你的数据访问,减轻数据库压力,但如果姿势不对,就会引发各种奇奇怪怪的问题。
第一幕:缓存穿透,查无此人的尴尬
啥是缓存穿透?简单来说,就是用户请求的数据,Redis里没有,数据库里也没有。每次请求都直奔数据库,就像你每次都饿着肚子去超市买菜,冰箱永远是空的!
问题描述:
- 恶意攻击:黑客故意请求大量不存在的数据,让数据库不堪重负。
- 数据异常:程序bug导致请求的数据ID永远不存在。
解决方案:
-
布隆过滤器 (Bloom Filter):事前过滤,防止坏人进门
这玩意儿就像你家门口的保安,能快速告诉你这个人是不是“可疑人员”(可能存在于数据库)。它是一种概率型数据结构,能告诉你某个元素“可能存在”或“绝对不存在”。
- 优点: 占用空间小,判断速度快。
- 缺点: 有误判率(False Positive),可能会把不存在的元素判断为存在。
代码示例 (Java + Redisson):
import org.redisson.Redisson; import org.redisson.api.RBloomFilter; import org.redisson.config.Config; public class BloomFilterExample { public static void main(String[] args) { // 1. 配置 Redisson Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); // 替换成你的 Redis 地址 Redisson redisson = (Redisson) Redisson.create(config); // 2. 获取 Bloom Filter 对象 RBloomFilter<String> bloomFilter = redisson.getBloomFilter("myBloomFilter"); // 3. 初始化 Bloom Filter (预计元素数量和误判率) bloomFilter.tryInit(100000, 0.01); // 预计10万元素,误判率1% // 4. 将数据库中的数据添加到 Bloom Filter // 假设我们从数据库中查询到了一些ID String[] existingIds = {"1", "2", "3", "4", "5"}; for (String id : existingIds) { bloomFilter.add(id); } // 5. 检查某个ID是否存在 String idToCheck = "6"; if (bloomFilter.contains(idToCheck)) { System.out.println("ID " + idToCheck + " 可能存在"); // 可能会误判 // 进一步查数据库,如果数据库不存在,则进行缓存空对象的操作 } else { System.out.println("ID " + idToCheck + " 绝对不存在"); // 确定不存在,直接返回 // 直接返回,避免访问数据库 } // 6. 关闭 Redisson redisson.shutdown(); } }
解释:
- 首先,我们需要配置并连接到 Redis。
- 然后,我们获取一个
RBloomFilter
对象,并初始化它,指定预计的元素数量和误判率。 - 接着,我们将数据库中已存在的ID添加到 Bloom Filter 中。
- 最后,当我们收到一个请求时,先检查该ID是否存在于 Bloom Filter 中。如果不存在,则直接返回,避免访问数据库。如果存在,则进一步查询数据库,以确认数据是否存在。
-
缓存空对象 (Null Object Caching):就算啥也没有,也先占个位子
当数据库中不存在请求的数据时,我们可以在 Redis 中缓存一个空对象(null 或特定标记值),下次再请求相同的数据时,Redis 直接返回空对象,避免访问数据库。
- 优点: 简单有效,避免了频繁访问数据库。
- 缺点: 缓存了无意义的数据,占用 Redis 空间。需要设置合理的过期时间。
代码示例 (Java):
import redis.clients.jedis.Jedis; public class NullObjectCachingExample { public static void main(String[] args) { Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址 String key = "product:100"; String value = jedis.get(key); if (value == null) { // Redis 中不存在,查询数据库 String product = getProductFromDatabase(100); // 假设从数据库查询商品信息 if (product == null) { // 数据库中也不存在,缓存空对象 jedis.setex(key, 60, "NULL"); // 设置过期时间为 60 秒 System.out.println("缓存空对象"); } else { // 数据库中存在,缓存数据 jedis.setex(key, 3600, product); // 设置过期时间为 3600 秒 System.out.println("缓存商品信息"); } } else { if ("NULL".equals(value)) { System.out.println("从 Redis 获取到空对象"); // 返回空对象,或者根据业务逻辑进行处理 } else { System.out.println("从 Redis 获取到商品信息"); // 返回商品信息 } } jedis.close(); } // 模拟从数据库查询商品信息 private static String getProductFromDatabase(int productId) { // 假设数据库中不存在 productId 为 100 的商品 return null; } }
解释:
- 首先,我们尝试从 Redis 中获取数据。
- 如果 Redis 中不存在,则查询数据库。
- 如果数据库中也不存在,则在 Redis 中缓存一个值为 "NULL" 的字符串,并设置一个较短的过期时间。
- 下次再请求相同的数据时,Redis 直接返回 "NULL",我们可以根据业务逻辑进行处理,例如返回一个默认值或错误信息。
第二幕:缓存雪崩,集体罢工的危机
缓存雪崩就像多米诺骨牌,如果大量的缓存同时失效,导致所有请求都涌向数据库,数据库瞬间崩溃。
问题描述:
- 缓存失效:大量缓存同时过期,例如设置了相同的过期时间。
- Redis 宕机:Redis 集群整体故障。
解决方案:
-
随机过期时间:错峰出行,避免堵车
给缓存设置过期时间时,加上一个随机数,避免大量缓存同时过期。
- 优点: 简单有效,能有效避免缓存同时过期。
- 缺点: 无法完全避免少量缓存同时过期。
代码示例 (Java):
import redis.clients.jedis.Jedis; import java.util.Random; public class RandomExpirationExample { public static void main(String[] args) { Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址 Random random = new Random(); String key = "product:1"; String value = "product data"; // 设置过期时间,基础时间加上一个 0 到 10 分钟的随机数 int baseExpiration = 3600; // 基础过期时间 1 小时 int randomExpiration = random.nextInt(600); // 随机过期时间 0 到 10 分钟 int expiration = baseExpiration + randomExpiration; jedis.setex(key, expiration, value); System.out.println("设置 key " + key + " 的过期时间为 " + expiration + " 秒"); jedis.close(); } }
解释:
- 我们生成一个 0 到 600 秒(10 分钟)的随机数。
- 将基础过期时间(1 小时)加上随机数,得到最终的过期时间。
- 使用
setex
命令设置 key 的过期时间。
-
互斥锁 (Mutex Lock):排队通行,保护数据库
当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。
- 优点: 保证只有一个线程访问数据库,避免数据库压力过大。
- 缺点: 增加了请求的响应时间,因为需要等待锁的释放。
代码示例 (Java):
import redis.clients.jedis.Jedis; import redis.clients.jedis.params.SetParams; public class MutexLockExample { private static final String LOCK_KEY = "product:1:lock"; private static final String LOCK_VALUE = "lock"; private static final int LOCK_EXPIRE_TIME = 5; // 锁的过期时间 5 秒 public static void main(String[] args) { Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址 String key = "product:1"; String value = jedis.get(key); if (value == null) { // 缓存失效,尝试获取锁 if (tryGetLock(jedis)) { try { // 获取到锁,查询数据库 String product = getProductFromDatabase(1); // 假设从数据库查询商品信息 if (product != null) { // 数据库中存在,缓存数据 jedis.setex(key, 3600, product); // 设置过期时间为 3600 秒 System.out.println("缓存商品信息"); } else { // 数据库中不存在,缓存空对象 jedis.setex(key, 60, "NULL"); // 设置过期时间为 60 秒 System.out.println("缓存空对象"); } } finally { // 释放锁 releaseLock(jedis); } } else { // 没有获取到锁,等待一段时间后重试 System.out.println("没有获取到锁,稍后重试"); try { Thread.sleep(100); // 睡眠 100 毫秒 } catch (InterruptedException e) { e.printStackTrace(); } // 可以递归调用当前方法,或者直接返回一个默认值 } } else { if ("NULL".equals(value)) { System.out.println("从 Redis 获取到空对象"); // 返回空对象,或者根据业务逻辑进行处理 } else { System.out.println("从 Redis 获取到商品信息"); // 返回商品信息 } } jedis.close(); } // 尝试获取锁 private static boolean tryGetLock(Jedis jedis) { SetParams params = SetParams.setParams().nx().ex(LOCK_EXPIRE_TIME); String result = jedis.set(LOCK_KEY, LOCK_VALUE, params); return "OK".equals(result); } // 释放锁 private static void releaseLock(Jedis jedis) { jedis.del(LOCK_KEY); } // 模拟从数据库查询商品信息 private static String getProductFromDatabase(int productId) { // 假设数据库中存在 productId 为 1 的商品 return "product data from database"; } }
解释:
- 我们使用 Redis 的
SETNX
命令来尝试获取锁。SETNX
命令只有在 key 不存在时才会设置 key 的值,并返回 1,否则返回 0。 - 如果获取到锁,则查询数据库,并将结果写入缓存。
- 最后,释放锁,使用
DEL
命令删除 key。 - 为了防止死锁,我们设置了锁的过期时间。即使获取锁的线程发生异常,锁也会在过期时间后自动释放。
-
服务降级 (Service Degradation):丢卒保车,保证核心服务
在缓存雪崩发生时,可以暂时关闭一些非核心服务,释放资源,保证核心服务的正常运行。
- 优点: 保证核心服务的可用性。
- 缺点: 用户体验可能会受到影响。
实现方式:
- 使用熔断器:当某个服务的错误率超过一定阈值时,自动熔断该服务,防止雪崩效应蔓延。
- 限制流量:限制访问量,防止数据库压力过大。
- 返回默认值:当缓存失效时,直接返回一个默认值,而不是访问数据库。
-
构建高可用 Redis 集群:多条腿走路,防止摔倒
使用 Redis 集群,例如 Redis Sentinel 或 Redis Cluster,提高 Redis 的可用性。
- 优点: 提高了 Redis 的可用性,降低了 Redis 宕机的风险。
- 缺点: 增加了部署和维护的复杂度。
第三幕:缓存击穿,明星数据的烦恼
缓存击穿是指一个热点 key(访问频率非常高的 key)失效时,大量的请求同时访问数据库,导致数据库压力过大。
问题描述:
- 热点 key 过期:例如秒杀商品,在秒杀活动结束后,缓存失效。
- 热点 key 被意外删除。
解决方案:
-
永不过期 (Never Expire):爱它,就让它永远存在
对于热点 key,可以设置永不过期,或者设置一个非常长的过期时间。
- 优点: 简单有效,能有效避免缓存击穿。
- 缺点: 如果数据发生变化,需要手动更新缓存。
代码示例 (Java):
import redis.clients.jedis.Jedis; public class NeverExpireExample { public static void main(String[] args) { Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址 String key = "hot_product:1"; String value = "hot product data"; // 设置永不过期 jedis.set(key, value); System.out.println("设置 key " + key + " 永不过期"); jedis.close(); } }
解释:
- 使用
set
命令设置 key 的值,不设置过期时间。
-
提前更新缓存:未雨绸缪,防患于未然
在热点 key 即将过期时,提前更新缓存,避免在过期后大量请求同时访问数据库。
- 优点: 能有效避免缓存击穿。
- 缺点: 需要监控 key 的过期时间,并提前更新缓存。
实现方式:
- 使用定时任务:定时检查热点 key 的过期时间,并在即将过期时更新缓存。
- 使用消息队列:当数据发生变化时,发送消息到消息队列,由消费者更新缓存。
-
互斥锁 (Mutex Lock):同缓存雪崩解决方案
当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。
- 优点: 保证只有一个线程访问数据库,避免数据库压力过大。
- 缺点: 增加了请求的响应时间,因为需要等待锁的释放。
总结:
问题 | 描述 | 解决方案 | 优点 | 缺点 |
---|---|---|---|---|
穿透 | 请求的数据在缓存和数据库中都不存在,导致所有请求都直接访问数据库。 | 1. 布隆过滤器:在访问缓存之前,先使用布隆过滤器判断数据是否存在,如果不存在,则直接返回。 2. 缓存空对象:当数据库中不存在请求的数据时,在缓存中缓存一个空对象,避免每次都访问数据库。 | 1. 布隆过滤器:占用空间小,判断速度快。 2. 缓存空对象:简单有效,避免了频繁访问数据库。 | 1. 布隆过滤器:有误判率。 2. 缓存空对象:缓存了无意义的数据,占用缓存空间。需要设置合理的过期时间。 |
雪崩 | 大量缓存同时失效,导致所有请求都涌向数据库,数据库瞬间崩溃。 | 1. 随机过期时间:给缓存设置过期时间时,加上一个随机数,避免大量缓存同时过期。 2. 互斥锁:当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。 3. 服务降级:在缓存雪崩发生时,可以暂时关闭一些非核心服务,释放资源,保证核心服务的正常运行。 4. 构建高可用 Redis 集群:使用 Redis 集群,例如 Redis Sentinel 或 Redis Cluster,提高 Redis 的可用性。 | 1. 随机过期时间:简单有效,能有效避免缓存同时过期。 2. 互斥锁:保证只有一个线程访问数据库,避免数据库压力过大。 3. 服务降级:保证核心服务的可用性。 4. 构建高可用 Redis 集群:提高了 Redis 的可用性,降低了 Redis 宕机的风险。 | 1. 随机过期时间:无法完全避免少量缓存同时过期。 2. 互斥锁:增加了请求的响应时间。 3. 服务降级:用户体验可能会受到影响。 4. 构建高可用 Redis 集群:增加了部署和维护的复杂度。 |
击穿 | 一个热点 key 失效时,大量的请求同时访问数据库,导致数据库压力过大。 | 1. 永不过期:对于热点 key,可以设置永不过期,或者设置一个非常长的过期时间。 2. 提前更新缓存:在热点 key 即将过期时,提前更新缓存,避免在过期后大量请求同时访问数据库。 3. 互斥锁:当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。 | 1. 永不过期:简单有效,能有效避免缓存击穿。 2. 提前更新缓存:能有效避免缓存击穿。 3. 互斥锁:保证只有一个线程访问数据库,避免数据库压力过大。 | 1. 永不过期:如果数据发生变化,需要手动更新缓存。 2. 提前更新缓存:需要监控 key 的过期时间,并提前更新缓存。 3. 互斥锁:增加了请求的响应时间。 |
结尾:缓存之路,道阻且长,行则将至
缓存的使用是一门艺术,需要根据具体的业务场景选择合适的解决方案。没有银弹,只有不断地学习和实践,才能掌握缓存的精髓。希望今天的讲座能对大家有所帮助,谢谢!