目录
[1. 布隆过滤器:高效的"守门员"](#1. 布隆过滤器:高效的"守门员")
[2. 缓存空对象:以空间换时间](#2. 缓存空对象:以空间换时间)
[3. 接口层校验:第一道防线](#3. 接口层校验:第一道防线)
[1. 互斥锁:分布式环境下的"红绿灯"](#1. 互斥锁:分布式环境下的"红绿灯")
[2. 逻辑过期:永不失效的缓存策略](#2. 逻辑过期:永不失效的缓存策略)
[3. 永不过期 + 后台刷新:最安全的策略](#3. 永不过期 + 后台刷新:最安全的策略)
[1. 随机过期时间:打破同步失效](#1. 随机过期时间:打破同步失效)
[2. 多级缓存架构:构建缓存金字塔](#2. 多级缓存架构:构建缓存金字塔)
[3. 服务熔断与降级:系统的"保险丝"](#3. 服务熔断与降级:系统的"保险丝")
[1. 监控与告警体系](#1. 监控与告警体系)
[2. 缓存键设计规范](#2. 缓存键设计规范)
[3. 完整的缓存方案示例](#3. 完整的缓存方案示例)
引言
在当今高并发的互联网应用中,缓存已经成为提升系统性能的标配组件。Redis作为最受欢迎的内存数据库之一,以其高性能、丰富的数据结构支持,成为了缓存方案的首选。然而,错误的缓存使用方式不仅无法提升性能,反而可能导致系统崩溃。
今天,我们将深入探讨Redis使用中常见的三大问题:缓存穿透、缓存击穿和缓存雪崩。这些问题如同缓存系统的"隐形杀手",在流量高峰时可能瞬间击垮整个系统。理解它们的原理和解决方案,是每个后端工程师的必修课。
一、缓存穿透:查询不存在的"幽灵数据"
什么是缓存穿透?
想象一下这样的场景:一个恶意用户不断请求系统中不存在的用户ID,比如user:-1或user:999999。这些请求会先查询Redis缓存,由于缓存中没有这些数据,请求会直接打到数据库。数据库也查询不到结果,因此不会回写缓存。每次请求都像穿过缓存直接访问数据库一样,这就是"缓存穿透"。
真实案例:电商平台的商品搜索
# 问题代码示例
def get_product(product_id):
# 先查缓存
product = redis.get(f"product:{product_id}")
if product:
return product
# 缓存没有,查数据库
product = db.query("SELECT * FROM products WHERE id = ?", product_id)
if product:
# 写入缓存,设置1小时过期
redis.setex(f"product:{product_id}", 3600, product)
return product
当攻击者使用脚本批量请求不存在的商品ID时,数据库每秒可能面临数万次的无效查询,最终导致数据库连接池耗尽,正常业务无法响应。
解决方案:构建多级防御
1. 布隆过滤器:高效的"守门员"
布隆过滤器是一种概率型数据结构,可以快速判断一个元素是否在集合中。虽然有一定误判率,但绝不会漏判已存在的元素。
// 使用Guava的布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预期元素数量
0.01 // 误判率
);
// 初始化时加载所有有效ID
for (String id : getAllValidIds()) {
bloomFilter.put("product:" + id);
}
// 查询时先检查布隆过滤器
public Product getProduct(String id) {
String key = "product:" + id;
// 布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null; // 肯定不存在,直接返回
}
// 后续缓存查询逻辑...
}
2. 缓存空对象:以空间换时间
对于查询不到的数据,我们也可以缓存一个特殊的空值,并设置较短的过期时间。
def get_product_with_null_cache(product_id):
cache_key = f"product:{product_id}"
# 先查缓存
result = redis.get(cache_key)
if result:
# 如果是空标记,直接返回None
if result == "__NULL__":
return None
return json.loads(result)
# 查询数据库
product = db.query_product(product_id)
if product:
# 正常缓存
redis.setex(cache_key, 3600, json.dumps(product))
else:
# 缓存空值,设置较短过期时间
redis.setex(cache_key, 300, "__NULL__") # 5分钟
return product
3. 接口层校验:第一道防线
在请求进入业务逻辑前进行基础校验,可以过滤掉大部分无效请求。
public Product getProduct(@PathVariable String id) {
// 校验ID格式:必须为正整数
if (!id.matches("^[1-9]\\d*$")) {
throw new IllegalArgumentException("商品ID格式错误");
}
// 校验ID范围
long productId = Long.parseLong(id);
if (productId > MAX_PRODUCT_ID) {
throw new IllegalArgumentException("商品ID超出范围");
}
// 后续业务逻辑...
}
二、缓存击穿:热点数据的"瞬间崩溃"
什么是缓存击穿?
缓存击穿就像是缓存系统的"阿喀琉斯之踵"------一个致命的弱点。当某个热点key过期的瞬间,大量并发请求同时发现缓存失效,这些请求会如潮水般涌向数据库,造成数据库瞬时压力过大。
真实案例:双十一秒杀活动
假设某电商平台在双十一推出了一款限量秒杀商品,这个商品的缓存设置为10秒过期。在缓存过期的瞬间,数万用户同时点击"立即购买",导致数据库瞬间接收数万条相同的查询请求。
解决方案:平滑过渡热点数据
1. 互斥锁:分布式环境下的"红绿灯"
使用分布式锁确保只有一个线程去查询数据库,其他线程等待。
public class ProductService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 1. 先查缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 获取分布式锁
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
// 尝试获取锁,最多等待100ms
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 3. 双重检查:再次查询缓存
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 4. 查询数据库
product = productDao.findById(productId);
if (product != null) {
// 5. 写入缓存,设置随机过期时间避免雪崩
int expireTime = 3600 + new Random().nextInt(600);
redisTemplate.opsForValue().set(
cacheKey, product, expireTime, TimeUnit.SECONDS
);
} else {
// 缓存空值防止穿透
redisTemplate.opsForValue().set(
cacheKey, new NullValue(), 300, TimeUnit.SECONDS
);
}
return product;
} finally {
lock.unlock();
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getProduct(productId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取商品信息失败", e);
}
}
}
2. 逻辑过期:永不失效的缓存策略
我们可以在缓存值中存储逻辑过期时间,而不是依赖Redis的TTL。
{
"data": {
"id": 12345,
"name": "iPhone 15 Pro",
"price": 8999
},
"expireAt": 1698393600 // 逻辑过期时间戳
}
实现逻辑:
class LogicalExpirationCache:
def get_product(self, product_id):
cache_key = f"product:{product_id}"
cache_data = redis.get(cache_key)
if cache_data:
cache_obj = json.loads(cache_data)
# 检查是否逻辑过期
if time.time() < cache_obj["expireAt"]:
return cache_obj["data"]
# 已过期,尝试获取更新锁
if self.acquire_update_lock(cache_key):
# 获取到锁,异步更新缓存
self.async_update_cache(product_id)
# 返回当前数据(可能是过期的)
return cache_obj["data"] if cache_data else self.query_from_db(product_id)
def async_update_cache(self, product_id):
# 异步线程更新缓存
Thread(target=self._update_cache, args=(product_id,)).start()
def _update_cache(self, product_id):
try:
# 查询最新数据
new_data = db.query_product(product_id)
# 更新缓存,设置新的逻辑过期时间
cache_obj = {
"data": new_data,
"expireAt": time.time() + 3600 # 1小时后过期
}
redis.set(f"product:{product_id}", json.dumps(cache_obj))
finally:
self.release_update_lock(f"product:{product_id}")
3. 永不过期 + 后台刷新:最安全的策略
对于极其热点的数据,可以采用永不过期策略,配合后台定时刷新。
@Service
public class HotProductService {
@PostConstruct
public void init() {
// 启动定时任务,每30秒刷新热点商品
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::refreshHotProducts, 0, 30, TimeUnit.SECONDS);
}
private void refreshHotProducts() {
List<Long> hotProductIds = getHotProductIds();
for (Long productId : hotProductIds) {
Product product = productDao.findById(productId);
if (product != null) {
// 永不过期,但每次刷新时更新值
redisTemplate.opsForValue().set(
"product:" + productId,
product
);
}
}
}
}
三、缓存雪崩:系统的"多米诺骨牌效应"
什么是缓存雪崩?
缓存雪崩是缓存系统中最危险的场景。当大量缓存key在同一时间点过期,或者Redis集群宕机,导致所有请求直接涌向数据库,就像雪崩一样瞬间压垮系统。
真实案例:整点抢券活动
某平台每天中午12点发放优惠券,所有优惠券信息的缓存都设置在凌晨4点过期(当时没有活动)。当缓存同时失效后,早上第一个用户访问时触发缓存重建,如果重建速度跟不上请求速度,就会引发连锁反应。
解决方案:分散风险,构建弹性系统
1. 随机过期时间:打破同步失效
public class CacheService {
// 基础过期时间 + 随机偏移量
private int getRandomExpireTime(int baseExpire) {
Random random = new Random();
int offset = random.nextInt(600); // 0-10分钟的随机偏移
return baseExpire + offset;
}
public void setProductCache(Long productId, Product product) {
String key = "product:" + productId;
int expireTime = getRandomExpireTime(3600); // 3600~4200秒
redisTemplate.opsForValue().set(
key, product, expireTime, TimeUnit.SECONDS
);
}
}
2. 多级缓存架构:构建缓存金字塔
用户请求 → CDN缓存 → Nginx缓存 → 应用本地缓存 → Redis集群 → 数据库
实现本地缓存 + Redis的多级缓存:
@Component
public class MultiLevelCacheService {
// 本地缓存(Caffeine)
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
return product;
}
// 2. 查Redis
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
// 回填本地缓存
localCache.put(key, product);
return product;
}
// 3. 查数据库(加锁保护)
product = queryWithLock(productId);
if (product != null) {
// 写入多级缓存
localCache.put(key, product);
redisTemplate.opsForValue().set(
key, product,
getRandomExpireTime(3600), TimeUnit.SECONDS
);
}
return product;
}
}
3. 服务熔断与降级:系统的"保险丝"
使用熔断器(如Hystrix、Resilience4j)在缓存异常时保护数据库:
@Service
public class ProductServiceWithCircuitBreaker {
// 定义熔断器
private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("productService");
@CircuitBreaker(name = "productService", fallbackMethod = "fallbackGetProduct")
public Product getProduct(Long productId) {
// 正常的业务逻辑
return doGetProduct(productId);
}
// 降级方法
private Product fallbackGetProduct(Long productId, Throwable t) {
log.warn("熔断降级,返回默认商品信息,productId: {}", productId, t);
// 返回默认值或兜底数据
return Product.defaultProduct();
}
}
四、综合对比与选择策略
三大问题对比表
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 问题本质 | 查询不存在的数据 | 热点key突然失效 | 大量key同时失效 |
| 影响范围 | 特定不存在key | 单个热点key | 大量key甚至整个缓存 |
| 数据库压力 | 持续中等压力 | 瞬时极大压力 | 持续极大压力 |
| 引发原因 | 恶意攻击或业务bug | 热点数据过期 | 缓存同时过期或Redis宕机 |
| 解决方案 | 1. 布隆过滤器 2. 缓存空值 3. 参数校验 | 1. 互斥锁 2. 逻辑过期 3. 永不过期 | 1. 随机过期时间 2. 多级缓存 3. 熔断降级 |
选择策略指南
根据不同的业务场景,我们可以这样选择解决方案:
-
读多写少的热点数据
-
推荐:永不过期 + 后台刷新
-
备选:逻辑过期 + 异步更新
-
-
常规业务数据
-
推荐:互斥锁 + 随机过期时间
-
备选:多级缓存架构
-
-
防攻击场景
-
必选:布隆过滤器 + 参数校验
-
补充:缓存空值(短时间)
-
-
高可用要求场景
-
必选:多级缓存 + 熔断降级
-
补充:Redis集群 + 哨兵模式
-
五、最佳实践:构建健壮的缓存系统
1. 监控与告警体系
# 关键监控指标
监控项:
- 缓存命中率: < 90% 告警
- Redis内存使用率: > 80% 告警
- 数据库QPS: 突增50% 告警
- 慢查询数量: > 10/分钟 告警
2. 缓存键设计规范
// 良好的键设计示例
public class CacheKeyGenerator {
// 业务:对象类型:业务ID:其他维度
public static String productKey(Long productId) {
return String.format("product:detail:%d", productId);
}
public static String userProductsKey(Long userId, int page) {
return String.format("user:products:%d:page:%d", userId, page);
}
}
3. 完整的缓存方案示例
@Component
public class RobustCacheService {
// 布隆过滤器(防穿透)
private final BloomFilter<String> bloomFilter;
// 本地缓存(一级缓存)
private final Cache<String, Object> localCache;
// Redis模板(二级缓存)
private final RedisTemplate<String, Object> redisTemplate;
// 分布式锁
private final DistributedLockService lockService;
public Object getData(String key, Supplier<Object> loader, int expireSeconds) {
// 1. 布隆过滤器校验
if (!bloomFilter.mightContain(key)) {
return null;
}
// 2. 查本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
if (value instanceof NullValue) {
return null;
}
return value;
}
// 3. 查Redis
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// 4. 加锁查数据库
if (lockService.tryLock(key)) {
try {
// 双重检查
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// 查询数据库
value = loader.get();
if (value != null) {
// 随机过期时间(防雪崩)
int randomExpire = expireSeconds + new Random().nextInt(300);
redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
localCache.put(key, value);
} else {
// 缓存空值(防穿透)
redisTemplate.opsForValue().set(key, new NullValue(), 300, TimeUnit.SECONDS);
localCache.put(key, new NullValue());
}
} finally {
lockService.unlock(key);
}
} else {
// 获取锁失败,短暂等待
try {
Thread.sleep(100);
return getData(key, loader, expireSeconds);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("缓存查询中断", e);
}
}
return value instanceof NullValue ? null : value;
}
}
结语
缓存系统的优化是一个持续的过程,没有一劳永逸的银弹。穿透、击穿、雪崩这三个问题提醒我们,在享受缓存带来的性能提升时,必须时刻警惕潜在的风险。
在实际项目中,我们需要:
-
理解业务特点:不同的业务场景适用不同的缓存策略
-
建立监控体系:没有监控的缓存就像没有仪表盘的汽车
-
定期演练:通过压力测试验证缓存方案的健壮性
-
保持学习:缓存技术不断发展,新的解决方案不断涌现
记住,好的缓存设计不是避免问题,而是让问题发生时系统依然能够优雅地运行。希望这篇文章能帮助你在设计缓存系统时避开这些"坑",构建出更加稳定、高效的应用系统。
延伸阅读: