熬夜两周,我终于搞懂了 Redis 的缓存穿透、击穿和雪崩!
熬夜两周,我终于搞懂了 Redis 的缓存穿透、击穿和雪崩
一名 Java 实习生的踩坑实录,从青铜到钻石的进阶之路
前言:那次让我"社死"的代码评审
大家好,我是小明,一名刚入职三个月的 Java 实习生。说出来不怕大家笑话,上周我自信满满地提交了一段自认为"完美"的代码,结果被技术总监(我的导师)问得哑口无言。那种感觉,就像考试时觉得自己能拿满分,结果被老师当众指出算错了一道大题——真的是脚趾都能抠出三室一厅来。
事情是这样的:我的任务是给商品详情页加一个缓存,因为每次用户访问商品信息都要查数据库,压力太大了。我心想,这不就是简单的读缓存、缓存没有就读数据库、然后把数据写回缓存吗?于是我三下五除二就写完了,代码大概是这个样子的:
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Product getProductById(Long productId) {
// 1. 先查缓存
String cacheKey = "product:" + productId;
String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
// 2. 缓存命中,直接返回
if (StringUtils.hasText(cacheValue)) {
return JSON.parseObject(cacheValue, Product.class);
}
// 3. 缓存未命中,查数据库
Product product = productMapper.selectById(productId);
// 4. 把数据库结果写入缓存,设置过期时间为30分钟
if (product != null) {
stringRedisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
导师看完后,眉头一皱,问了我三个灵魂拷问:
“小明啊,如果有人故意用一个数据库里根本不存在的商品ID来请求,比如 productId = -1,你的这段代码会怎么做?”
“如果某个热门商品突然过期了,一瞬间一万个请求同时打进来,你的数据库扛得住吗?”
“如果今晚零点是双十一活动开始,你缓存里的一大堆商品信息都在零点过期了,数据库会变成什么样?”
我当时整个人都懵了,心里只有一个想法:完了,我写的这是啥玩意儿啊!
带着这三个问题,我开始了为期两周的 Redis 缓存"补习班"。今天这篇文章,就是我这两周学习的总结,希望能帮到和我一样正在学习 Redis 缓存机制的朋友们。
一、青铜选手:我以为缓存就是简单的读写
在我导师的灵魂拷问之前,我对 Redis 缓存的理解就是三个字:读和写。读取的时候先看看缓存里有没有,有就直接返回,没有就读数据库然后写进去。这种简单的模式确实能解决大部分问题,但这只是冰山一角。
1.1 最基本的缓存模式
让我先给大家看一下最基础也是最经典的缓存使用模式,这个模式叫做 Cache-Aside Pattern(旁路缓存模式)。这是业界使用最广泛的一种缓存策略,它的工作流程可以用一张图来概括:
用户请求 → 查缓存(Redis)
↓ 有数据 ↓ 没数据
直接返回 查询数据库(MySQL)
↓
写入缓存
↓
返回数据
这个模式的核心思想很简单:应用层首先尝试从缓存读取数据,如果缓存命中(Hit),就直接返回缓存中的数据;如果缓存未命中(Miss),就去数据库查询,然后把查询结果写入缓存,以便下次查询时能够命中。
1.2 我的"青铜版"代码
在我学习初期,我写的代码就是这样的:
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ProductMapper productMapper;
private static final String PRODUCT_CACHE_PREFIX = "product:";
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
// 第一步:尝试从缓存获取
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
// 缓存命中,直接返回
log.info("缓存命中,商品ID: {}", id);
return JSON.parseObject(cachedData, Product.class);
}
// 第二步:缓存未命中,查询数据库
log.info("缓存未命中,查询数据库,商品ID: {}", id);
Product product = productMapper.selectById(id);
// 第三步:将数据库结果写入缓存
if (product != null) {
// 设置过期时间为30分钟
stringRedisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(product),
30,
TimeUnit.MINUTES
);
}
return product;
}
}
当时我还沾沾自喜,觉得自己写得挺规范的,又是日志又是异常处理的,应该没问题的。结果导师告诉我,这段代码存在三个严重的问题:
问题一:缓存穿透
如果有大量请求查询一个数据库根本不存在的商品ID,每次请求都会穿过缓存直接打到数据库上,导致数据库压力过大。
问题二:缓存击穿
如果某个热门商品的缓存过期了,在过期的那个瞬间,大量并发请求会同时发现缓存失效,然后全部涌向数据库,造成数据库压力骤增。
问题三:缓存雪崩
如果大量缓存在同一时间过期(比如批量设置缓存时使用了相同的过期时间),那么在这一刻,大量请求会同时穿透到数据库,数据库很可能直接挂掉。
听完这三个问题,我的心态是崩溃的,心想:怎么一个简简单单的缓存功能,会有这么多坑啊!但是没办法,谁让我是实习生呢,学呗!
二、白银选手:搞定缓存穿透
带着导师的问题,我开始了第一轮学习。首先解决的是缓存穿透问题。
2.1 什么是缓存穿透?
缓存穿透,听起来挺吓人的,其实用白话解释就是:有人专门查询数据库里没有的数据。
这种情况可能有两种原因:
原因一:恶意攻击
有些人可能故意用一个不存在的 ID 来请求你的接口,比如 productId = -1,或者一个很大的数字比如 99999999。如果你的系统没有做好防护,每一次请求都会穿过缓存直接打到数据库上。如果攻击者短时间内发起大量这样的请求,数据库的 CPU 就会飙升,严重的话整个系统都会挂掉。
原因二:业务逻辑问题
有时候可能是前端传错了参数,或者后端代码有 bug,导致查询了一个实际不存在的对象。这种情况虽然不是恶意攻击,但同样会导致大量无效的数据库查询。
我导师给我举了一个形象的比喻:这就像你去图书馆找一本书,管理员告诉你这本书不存在(数据库未命中),然后你就走了。但如果有十万个人同时来找这本不存在的书,管理员就算不累死,也会被烦死。缓存穿透就是这个道理,每一次请求都要"打扰"数据库这位"图书管理员"。
2.2 解决方案一:缓存空值
最简单直接的解决方案是:当数据库查询不到数据时,也在缓存中存一个空值或者一个特殊的标记。
这样做的好处是:下次再有人查询这个不存在的 ID 时,缓存直接返回空值,不需要再查数据库了。
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
// 第一步:尝试从缓存获取
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
// 这里要判断是否是空值标记
if ("NULL".equals(cachedData)) {
log.warn("缓存空值,商品ID: {} 不存在于数据库", id);
return null;
}
log.info("缓存命中,商品ID: {}", id);
return JSON.parseObject(cachedData, Product.class);
}
// 第二步:缓存未命中,查询数据库
Product product = productMapper.selectById(id);
// 第三步:将结果写入缓存
if (product != null) {
stringRedisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(product),
30,
TimeUnit.MINUTES
);
log.info("写入商品缓存,商品ID: {}", id);
} else {
// 数据库也没有查到,缓存一个空值标记
// 设置一个较短的过期时间,比如5分钟
stringRedisTemplate.opsForValue().set(
cacheKey,
"NULL",
5,
TimeUnit.MINUTES
);
log.warn("数据库未找到商品,缓存空值,商品ID: {}", id);
}
return product;
}
这个方案的优点是实现简单,不需要引入额外的组件。缺点是需要额外处理空值的逻辑,而且在空值过期前,如果数据库中新增了这个商品,缓存和数据库就会不一致。
2.3 解决方案二:布隆过滤器
除了缓存空值,还有一种更优雅的解决方案:布隆过滤器(Bloom Filter)。
布隆过滤器是一种空间效率极高的概率型数据结构,它可以用来判断一个元素是否可能存在于集合中。注意,这里说的是"可能存在",而不是"确定存在"。布隆过滤器可能会把不存在的元素判断为存在(假阳性),但不会把存在的元素判断为不存在(假阴性)。
简单来说,布隆过滤器就像是一个"黑名单":如果布隆过滤器判断这个 ID 不存在,那它就一定不存在;如果判断存在,那它可能存在也可能不存在,需要再查一次数据库确认。
在 Java 中,我们可以使用 Google Guava 提供的 BloomFilter 或者 Redisson 提供的分布式布隆过滤器。
// 使用 Guava 的布隆过滤器
@Component
public class BloomFilterConfig {
@Autowired
private ProductMapper productMapper;
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器,预期数据量 10000,误判率 0.01
bloomFilter = BloomFilter.create(Funnels.longFunnel(), 10000, 0.01);
// 将数据库中所有商品 ID 放入布隆过滤器
List<Long> allProductIds = productMapper.selectAllIds();
allProductIds.forEach(bloomFilter::put);
}
public boolean mightContain(Long productId) {
return bloomFilter.mightContain(productId);
}
}
// 在查询方法中使用布隆过滤器
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private BloomFilterConfig bloomFilterConfig;
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
return JSON.parseObject(cachedData, Product.class);
}
// 使用布隆过滤器判断
if (!bloomFilterConfig.mightContain(id)) {
log.warn("布隆过滤器判断商品ID {} 不存在,直接返回null", id);
// 缓存一个空值,避免穿透到数据库
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
return null;
}
// 布隆过滤器判断可能存在,查询数据库
Product product = productMapper.selectById(id);
if (product != null) {
stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
// 数据库确实不存在,缓存空值
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
}
}
布隆过滤器的优点是空间效率极高,几 MB 的内存就能存储上亿个 ID。缺点是需要额外维护布隆过滤器的数据同步问题(比如新商品入库时需要把 ID 加入过滤器)。
2.4 我的选择
作为一个实习生,我最终选择了缓存空值的方案。原因很简单:布隆过滤器虽然高大上,但对于我们这种小系统来说,引入额外的数据结构会增加复杂度。而且缓存空值的方案已经能很好地解决缓存穿透问题了。
当然,如果你的系统面临大规模的恶意攻击,或者数据量非常大,那布隆过滤器肯定是更好的选择。这就叫做"看菜吃饭,量体裁衣"——适合自己的才是最好的。
三、黄金选手:搞定缓存击穿
搞定缓存穿透之后,我信心满满地去找导师汇报。结果导师说:“不错,缓存穿透的问题你理解了。但你再想想,如果某个热门商品的缓存过期了那一瞬间,会发生什么?”
我愣住了。热门商品过期?那不就是缓存失效吗?失效了再查一次数据库不就好了?
导师叹了口气,开始给我解释缓存击穿的概念。
3.1 什么是缓存击穿?
缓存击穿(也叫"热点key失效")是指:一个非常热门的缓存key,在某个时刻突然过期了,导致大量并发请求同时发现缓存失效,然后全部涌向数据库。
用一个形象的比喻:如果把数据库比作一个正在休息的病人,缓存就是给病人吃的药。正常情况下,每隔几个小时吃一次药,病人就能保持健康。但如果某一次吃药的时候,药突然失效了,所有人就会立刻冲进病房给病人喂药——这一瞬间的冲击,病人能受得了吗?
这就是缓存击穿的可怕之处。热门商品的访问量可能是普通商品的几百倍甚至几千倍,一旦缓存过期,这瞬间的并发量足以让数据库崩溃。
3.2 解决方案:互斥锁
解决缓存击穿的核心思路是:让只有一个请求能去查数据库,其他请求等待这个请求把缓存写好之后,直接从缓存取。
这就要用到分布式锁的概念了。在分布式系统中,我们需要一种机制来保证在同一时刻,只有一个线程(或服务实例)能够执行某段代码。
在 Redis 中,最常用的分布式锁实现方式是使用 SETNX 命令(SET if Not eXists)。SETNX 的作用是:如果 key 不存在,就设置这个 key 的值并返回 1;如果 key 已经存在,就不做任何操作返回 0。
让我来看看我的改进代码:
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
return JSON.parseObject(cachedData, Product.class);
}
// 尝试获取锁
String lockKey = "lock:product:" + id;
Boolean lockAcquired = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "lock", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lockAcquired)) {
try {
// 获取锁成功,查询数据库
Product product = productMapper.selectById(id);
if (product != null) {
stringRedisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
} finally {
// 释放锁
stringRedisTemplate.delete(lockKey);
}
} else {
// 获取锁失败,等待一段时间后重试
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductById(id);
}
}
这段代码的核心逻辑是:
- 当缓存未命中时,首先尝试获取分布式锁
- 如果获取锁成功,说明我是第一个到达的请求,去查数据库并写入缓存
- 如果获取锁失败,说明已经有其他请求在查数据库了,我等待一段时间后重试
- 无论查询成功与否,最后都要释放锁
关键点说明:
-
为什么锁要设置过期时间?
如果一个服务实例获取锁后突然崩溃了,没有释放锁,其他服务就会永远拿不到锁,死锁了。所以锁必须设置一个过期时间,即使服务崩溃,锁也会自动释放。 -
为什么 finally 里要释放锁?
为了保证锁一定会被释放,避免死锁。 -
为什么获取锁失败要 sleep 后重试?
因为其他请求正在查数据库,我们只需要等它把缓存写好,我们就能从缓存读到数据了。 -
锁的过期时间设置多长合适?
一般设置为预计查询数据库所需时间的 2-3 倍。如果查询数据库需要 1 秒,锁就设置 3 秒。
3.3 更优雅的方案:Redisson
上面的代码虽然能工作,但实现起来比较繁琐,而且还有不少细节需要处理(比如锁续期、锁的可重入性等)。在生产环境中,我更推荐使用 Redisson 这个成熟的分布式锁解决方案。
Redisson 是 Redis 官方推荐的 Java 客户端,它在 Redis 基础上提供了很多分布式功能的封装,其中就包括分布式锁。
使用 Redisson 的方式是这样的:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private RedissonClient redissonClient;
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
return JSON.parseObject(cachedData, Product.class);
}
// 获取分布式锁
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待10秒,锁持有时间30秒
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (acquired) {
try {
// 双重检查,防止在等待锁期间缓存已经被写入
cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
return JSON.parseObject(cachedData, Product.class);
}
// 查数据库
Product product = productMapper.selectById(id);
if (product != null) {
stringRedisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
} finally {
lock.unlock();
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(100);
return getProductById(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取商品信息失败", e);
}
}
}
使用 Redisson 的好处是:
- 自动续期:如果业务执行时间超过了锁的持有时间,Redisson 会自动延长锁的持有时间,避免锁被提前释放
- 可重入:同一个线程可以多次获取同一把锁
- 公平锁:可以按照请求顺序获取锁,避免锁饥饿
- 读写锁:支持读锁和写锁的分离,提高并发性能
导师告诉我:“不要重复造轮子,在分布式锁这种复杂的问题上,使用成熟的开源方案比自己的实现要可靠得多。” 这一点我深以为然。
四、铂金选手:搞定缓存雪崩
搞定了缓存击穿之后,我以为已经天下太平了。结果导师又问了我一个问题:
“小明啊,如果今晚零点是双十一活动开始,你缓存里的一大堆商品信息都在零点过期了,你觉得会发生什么?”
我想了想,脸色大变——这不就是缓存雪崩吗!
4.1 什么是缓存雪崩?
缓存雪崩是指:大量缓存 key 在同一个时间点过期,导致大量请求同时穿透到数据库。
和缓存击穿不同,雪崩不是针对某一个热门 key,而是针对大量 key。如果缓存的过期时间都是 30 分钟,那么 30 分钟后,这些 key 会同时失效。想象一下,如果你的系统有 10 万个商品缓存都在同一时间过期,在那一瞬间,10 万个请求会同时冲向数据库——这就是灾难性的缓存雪崩。
缓存雪崩的原因通常有:
- 缓存集中过期:在设置缓存时使用了相同的过期时间
- Redis 集群故障:如果 Redis 集群中的某个节点挂了,请求会打到数据库上
- 系统重启:服务重启导致缓存全部失效
4.2 解决方案一:随机过期时间
最简单直接的方案是:在设置缓存过期时间时,加上一个随机值。
@Override
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_PREFIX + id;
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
return JSON.parseObject(cachedData, Product.class);
}
Product product = productMapper.selectById(id);
if (product != null) {
// 基础过期时间30分钟 + 随机偏移量(1-5分钟)
long baseExpireTime = 30 * 60; // 30分钟
long randomOffset = ThreadLocalRandom.current().nextLong(1, 5 * 60); // 1-5分钟
long finalExpireTime = baseExpireTime + randomOffset;
stringRedisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(product),
finalExpireTime,
TimeUnit.SECONDS
);
} else {
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
}
这样设置之后,每个缓存的过期时间都不完全一样,大大降低了同时过期的概率。
4.3 解决方案二:Redis 高可用
如果 Redis 本身是高可用的(比如 Redis Sentinel 或 Redis Cluster),那么单点故障的概率会大大降低。即使某个 Redis 节点挂了,其他节点也能继续提供服务,不会导致所有请求都打到数据库上。
# Redis Sentinel 配置示例
redis:
host: localhost
password: yourpassword
sentinel:
master: mymaster
nodes:
- 127.0.0.1:26379
- 127.0.0.1:26380
- 127.0.0.1:26381
4.4 解决方案三:服务降级
如果缓存真的出了问题(比如 Redis 完全不可用了),我们还可以通过服务降级来保护数据库。常见的做法是:
- 使用本地缓存:在服务本地维护一个小的缓存,缓存 Redis 不可用时的热点数据
- 熔断机制:使用 Hystrix 或 Sentinel 等组件,当检测到数据库压力过大时,直接返回兜底数据
- 限流:通过限流组件(如 Sentinel、RateLimiter)限制并发请求数量
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ProductMapper productMapper;
// 本地缓存,使用 Caffeine
@Autowired
private Cache<Long, Product> localCache;
@Override
public Product getProductById(Long id) {
try {
// 1. 先查本地缓存
Product localProduct = localCache.get(id);
if (localProduct != null) {
return localProduct;
}
// 2. 查 Redis 缓存
String cacheKey = PRODUCT_CACHE_PREFIX + id;
String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
if ("NULL".equals(cachedData)) {
return null;
}
Product product = JSON.parseObject(cachedData, Product.class);
// 写入本地缓存
localCache.put(id, product);
return product;
}
// 3. 查数据库
Product product = productMapper.selectById(id);
if (product != null) {
stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
localCache.put(id, product);
} else {
stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
} catch (Exception e) {
// Redis 异常时,从本地缓存或数据库获取
log.error("Redis服务异常,使用降级方案,商品ID: {}", id, e);
Product localProduct = localCache.get(id);
if (localProduct != null) {
return localProduct;
}
Product product = productMapper.selectById(id);
if (product != null) {
localCache.put(id, product);
}
return product;
}
}
}
4.5 多级缓存架构
对于高并发系统,我们还可以采用多级缓存的架构:
用户请求 → Nginx缓存 → 本地缓存(Caffeine/Guava) → Redis缓存 → 数据库
越靠近用户的缓存速度越快,但容量越小。通过多级缓存的组合,我们可以在保证响应速度的同时,减小后端数据库的压力。
五、总结:三种缓存问题的对比
经过两周的学习,我终于把 Redis 缓存的三个"老大难"问题给搞清楚了。让我用一个表格来总结一下:
| 问题类型 | 产生原因 | 解决方案 | 难度 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据,请求直接打到数据库 | 1. 缓存空值 2. 布隆过滤器 | ⭐⭐ |
| 缓存击穿 | 热门key过期,大量并发同时请求数据库 | 1. 互斥锁 2. Redisson分布式锁 | ⭐⭐⭐ |
| 缓存雪崩 | 大量key同时过期,请求穿透到数据库 | 1. 随机过期时间 2. Redis高可用 3. 服务降级 | ⭐⭐⭐⭐ |
核心代码模板
最后,给大家分享一个相对完整的缓存工具类模板,这个模板综合考虑了三种缓存问题,可以作为项目中的参考:
@Component
public class CacheService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private BloomFilterConfig bloomFilterConfig;
/**
* 带缓存的查询方法
* 综合处理缓存穿透、击穿、雪崩问题
*/
public <T> T getWithCache(String cacheKey, Class<T> clazz,
Supplier<T> databaseQuery, long baseExpireSeconds) {
// 1. 尝试从缓存获取
String cachedValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
if ("NULL".equals(cachedValue)) {
return null;
}
return JSON.parseObject(cachedValue, clazz);
}
// 2. 尝试获取分布式锁
String lockKey = "lock:" + cacheKey;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
// 双重检查
cachedValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
if ("NULL".equals(cachedValue)) {
return null;
}
return JSON.parseObject(cachedValue, clazz);
}
// 3. 查询数据库
T result = databaseQuery.get();
// 4. 写入缓存(带随机过期时间防止雪崩)
if (result != null) {
long randomExpire = ThreadLocalRandom.current()
.nextLong(60, 300); // 1-5分钟随机偏移
stringRedisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(result),
baseExpireSeconds + randomExpire,
TimeUnit.SECONDS
);
} else {
// 缓存空值防止穿透
stringRedisTemplate.opsForValue().set(
cacheKey,
"NULL",
300,
TimeUnit.SECONDS
);
}
return result;
} finally {
lock.unlock();
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(100);
return getWithCache(cacheKey, clazz, databaseQuery, baseExpireSeconds);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取缓存失败", e);
}
}
}
六、后记:从青铜到铂金的感悟
写完这篇文章,我突然想起了两周前那段被导师"吊打"的代码。那个时候的我,天真地以为缓存就是简单的读写,完全没有意识到背后有这么多"坑"。
经过这两周的学习,我深刻体会到了几个道理:
-
理论要和实践结合:光看书是不够的,必须自己动手写代码、踩坑,才能真正理解问题的本质。
-
不要重复造轮子:像分布式锁这种复杂的问题,应该优先使用成熟的开源方案,而不是自己造一个可能有 bug 的轮子。
-
考虑问题要全面:写代码的时候要想想各种边界情况,比如恶意请求、服务异常、数据不存在等。
-
持续学习的重要性:技术日新月异,今天学的东西可能明天就过时了。只有保持学习的热情,才能在这个行业立足。
最后,感谢我的导师(虽然他看不到这篇文章)对我这个实习生的耐心指导。也希望这篇文章能帮助到和我一样正在学习 Redis 的朋友们。
如果这篇文章对你有帮助的话,记得点赞、收藏、关注哦!你们的支持是我继续创作的最大动力!
更多推荐




所有评论(0)