Redis缓存穿透与解决方案

Redis缓存穿透与解决方案

Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。

一、什么是缓存穿透

#

1.1 穿透场景

正常查询:
客户端 → 查询缓存(命中)→ 返回数据

缓存未命中:
客户端 → 查询缓存(未命中)→ 查询数据库(命中)→ 写入缓存 → 返回数据

缓存穿透:
客户端 → 查询缓存(未命中,数据不存在)→ 查询数据库(未命中)→ 返回空

└── 每次请求都查数据库!

#

1.2 产生原因

  1. 恶意攻击:构造大量不存在的key进行查询
  2. 业务误用:查询已被删除的数据
  3. 数据未初始化:新数据还未写入缓存和数据库
  4. 参数错误:传入非法参数查询

#

1.3 危害

  • 数据库压力剧增
  • 可能导致数据库宕机
  • 缓存失去保护作用
  • 影响正常用户访问

二、解决方案

#

2.1 方案一:缓存空值

原理:查询数据库未命中后,将空值也缓存起来

@Service
public class UserService {

@Autowired
private StringRedisTemplate redis;
@Autowired
private UserMapper userMapper;

private static final String USER_KEY = "user:";
private static final long CACHE_NULL_TTL = 60; // 空值缓存60秒
private static final String NULL_VALUE = "null";

public User getUser(Long id) {
String key = USER_KEY + id;
String json = redis.opsForValue().get(key);

// 缓存命中(包括空值)
if (json != null) {
if (NULL_VALUE.equals(json)) {
return null; // 之前查询过,数据库不存在
}
return JSON.parseObject(json, User.class);
}

// 缓存未命中,查数据库
User user = userMapper.findById(id);

if (user == null) {
// 数据库不存在,缓存空值(短过期时间)
redis.opsForValue().set(key, NULL_VALUE, CACHE_NULL_TTL, TimeUnit.SECONDS);
} else {
// 数据库存在,缓存数据
redis.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}

return user;
}
}

优点

  • 实现简单
  • 效果显著

缺点

  • 缓存大量空值占用空间
  • 如果攻击者构造大量不同的key,缓存空间可能被撑满
  • 数据真实存在后,需要等待空值过期才能正确获取

#

2.2 方案二:布隆过滤器

原理:在查询缓存之前,先用布隆过滤器判断key是否可能存在

@Configuration
public class BloomFilterConfig {

@Bean
public RBloomFilter<String> userBloomFilter(RedissonClient redisson) {
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("userBloomFilter");

// 初始化:预期插入100万个元素,误判率0.01
bloomFilter.tryInit(1000000L, 0.01);

return bloomFilter;
}
}

@Service
public class UserService {

@Autowired
private RBloomFilter<String> userBloomFilter;
@Autowired
private StringRedisTemplate redis;
@Autowired
private UserMapper userMapper;

// 数据初始化时添加到布隆过滤器
@PostConstruct
public void initBloomFilter() {
List<Long> userIds = userMapper.findAllIds();
for (Long id : userIds) {
userBloomFilter.add("user:" + id);
}
}

public User getUser(Long id) {
String key = "user:" + id;

// 1. 布隆过滤器判断
if (!userBloomFilter.contains(key)) {
return null; // 一定不存在,直接返回
}

// 2. 查询缓存
String json = redis.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, User.class);
}

// 3. 查询数据库
User user = userMapper.findById(id);
if (user != null) {
redis.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}

return user;
}

// 新增用户时添加到布隆过滤器
public void addUser(User user) {
userMapper.insert(user);
userBloomFilter.add("user:" + user.getId());
redis.opsForValue().set("user:" + user.getId(),
JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}
}

优点

  • 不占用大量缓存空间
  • 能防御大量随机key攻击

缺点

  • 有一定误判率(可能将不存在的判断为存在)
  • 不支持删除元素(或需要特殊实现如Counting Bloom Filter)
  • 需要初始化时加载所有数据

#

2.3 方案三:参数校验

@RestController
public class UserController {

@Autowired
private UserService userService;

@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
// 参数校验
if (id == null || id <= 0 || id > 999999999L) {
throw new IllegalArgumentException("Invalid user id");
}

return userService.getUser(id);
}
}

优点

  • 最简单直接
  • 能拦截明显的非法请求

缺点

  • 只能拦截部分场景
  • 不能防御合法范围内的攻击

#

2.4 方案四:限流

@Component
public class CachePenetrationLimiter {

@Autowired
private StringRedisTemplate redis;

// 基于Sliding Window限流
public boolean isAllowed(String key, int maxRequests, int windowSeconds) {
String limitKey = "limit:" + key;
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000;

// 移除窗口外的记录
redis.opsForZSet().removeRangeByScore(limitKey, 0, windowStart);

// 获取当前请求数
Long count = redis.opsForZSet().zCard(limitKey);
if (count != null && count >= maxRequests) {
return false;
}

// 记录本次请求
redis.opsForZSet().add(limitKey, String.valueOf(now), now);
redis.expire(limitKey, windowSeconds + 1, TimeUnit.SECONDS);

return true;
}
}

@Service
public class UserService {

@Autowired
private CachePenetrationLimiter limiter;

public User getUser(Long id) {
// 对同一key限流
if (!limiter.isAllowed("user:" + id, 100, 60)) {
throw new RateLimitException("请求过于频繁");
}

// ... 查询逻辑
}
}

#

2.5 方案五:互斥锁(防并发查询)

@Service
public class UserService {

@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate redis;
@Autowired
private UserMapper userMapper;

public User getUser(Long id) {
String key = "user:" + id;
String json = redis.opsForValue().get(key);

if (json != null) {
return JSON.parseObject(json, User.class);
}

// 获取分布式锁,防止并发查数据库
RLock lock = redisson.getLock("lock:user:" + id);
try {
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 获取锁失败,等待后重试
Thread.sleep(100);
return getUser(id);
}

// 双重检查
json = redis.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, User.class);
}

// 查询数据库
User user = userMapper.findById(id);
if (user == null) {
redis.opsForValue().set(key, "null", 60, TimeUnit.SECONDS);
} else {
redis.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}

return user;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

三、方案对比

方案 优点 缺点 适用场景
缓存空值 简单有效 占用空间 数据量可控
布隆过滤器 节省空间 有误判率 大数据量,初始化已知
参数校验 最简单 能力有限 参数范围可控
限流 保护系统 影响正常请求 高并发防御
互斥锁 防并发穿透 复杂度增加 热点key

四、最佳实践

#

4.1 组合方案

@Service
public class UserService {

@Autowired
private RBloomFilter<String> bloomFilter;
@Autowired
private StringRedisTemplate redis;
@Autowired
private UserMapper userMapper;
@Autowired
private RedissonClient redisson;

public User getUser(Long id) {
// 1. 参数校验
if (id == null || id <= 0) {
return null;
}

String key = "user:" + id;

// 2. 布隆过滤器判断
if (!bloomFilter.contains(key)) {
return null;
}

// 3. 查询缓存
String json = redis.opsForValue().get(key);
if (json != null) {
if ("null".equals(json)) {
return null;
}
return JSON.parseObject(json, User.class);
}

// 4. 分布式锁防并发
RLock lock = redisson.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 双重检查
json = redis.opsForValue().get(key);
if (json != null) {
return "null".equals(json) ? null : JSON.parseObject(json, User.class);
}

// 5. 查询数据库
User user = userMapper.findById(id);

// 6. 缓存结果(空值也缓存)
if (user == null) {
redis.opsForValue().set(key, "null", 60, TimeUnit.SECONDS);
} else {
redis.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}

return user;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

return null;
}
}

#

4.2 空值缓存的过期策略

// 根据业务调整空值过期时间
// - 高频查询但不存在的:短过期时间(10-60秒)
// - 低频查询的:较长过期时间(5-30分钟)
// - 数据可能很快写入的:极短过期时间(1-5秒)

@Service
public class UserService {

private static final long CACHE_NULL_TTL_SHORT = 10; // 10秒
private static final long CACHE_NULL_TTL_NORMAL = 60; // 60秒
private static final long CACHE_NULL_TTL_LONG = 300; // 5分钟

public User getUser(Long id) {
// ...
if (user == null) {
// 根据业务判断使用哪个过期时间
redis.opsForValue().set(key, "null", CACHE_NULL_TTL_NORMAL, TimeUnit.SECONDS);
}
// ...
}
}

五、总结

缓存穿透的防御策略:

  1. 参数校验:第一道防线,拦截明显非法请求
  2. 布隆过滤器:高效判断key是否存在,节省空间
  3. 缓存空值:将不存在的查询结果也缓存
  4. 限流:防止单个key或IP的过度请求
  5. 互斥锁:防止并发查询同一不存在key

核心原则:

  • 在请求到达数据库之前尽可能拦截
  • 使用组合方案增强防御能力
  • 根据业务特点选择合适的策略
  • 持续监控缓存命中率和数据库负载

核心要点

  1. String:简单的键值对,适合缓存、计数器

  2. Hash:存储对象属性,适合用户信息、配置

  3. List:有序列表,适合消息队列、最新列表

  4. Set:无序去重,适合共同好友、抽奖

  5. ZSet:有序集合,适合排行榜、积分系统

总结

选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。


   转载规则


《Redis缓存穿透与解决方案》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录