Redis缓存击穿与热点Key

Redis缓存击穿与热点Key

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

一、缓存击穿

#

1.1 什么是缓存击穿

场景:某个热点key过期瞬间

时间点T0:
缓存:key = value (即将过期)
数据库:key = value

时间点T1(key过期):
请求1 → 缓存未命中 → 查数据库
请求2 → 缓存未命中 → 查数据库
请求3 → 缓存未命中 → 查数据库
...(大量并发请求同时打到数据库)

数据库压力瞬间剧增!

#

1.2 与缓存穿透的区别

问题 原因 数据是否存在
缓存穿透 查询不存在的数据 数据库也不存在
缓存击穿 热点key过期 数据库存在

二、缓存击穿解决方案

#

2.1 方案一:互斥锁(分布式锁)

@Service
public class HotKeyService {

@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate redis;
@Autowired
private ProductMapper productMapper;

public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.opsForValue().get(key);

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

// 获取分布式锁,只有一个线程去查数据库
RLock lock = redisson.getLock("lock:product:" + id);
try {
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 获取锁失败,等待后重试
Thread.sleep(100);
return getProduct(id); // 递归重试
}

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

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

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

优点

  • 保证只有一个线程查数据库
  • 其他线程等待缓存重建

缺点

  • 获取锁失败的线程需要等待或重试
  • 有性能损耗

#

2.2 方案二:逻辑过期

原理:不设置Redis的TTL,而是在value中存储逻辑过期时间

@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 实际数据
}

@Service
public class HotKeyService {

@Autowired
private StringRedisTemplate redis;
@Autowired
private ProductMapper productMapper;
@Autowired
private ExecutorService cacheRebuildExecutor;

public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.opsForValue().get(key);

if (json == null) {
return null; // 数据从未加载过
}

RedisData redisData = JSON.parseObject(json, RedisData.class);
Product product = JSON.parseObject(JSON.toJSONString(redisData.getData()), Product.class);

// 检查逻辑过期时间
if (LocalDateTime.now().isAfter(redisData.getExpireTime())) {
// 已过期,需要重建缓存
// 获取锁(这里用简单的互斥锁)
Boolean locked = redis.opsForValue()
.setIfAbsent("lock:product:" + id, "1", 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
// 异步重建缓存
cacheRebuildExecutor.execute(() -> {
try {
Product fresh = productMapper.findById(id);
if (fresh != null) {
RedisData newData = new RedisData();
newData.setExpireTime(LocalDateTime.now().plusSeconds(3600));
newData.setData(fresh);
redis.opsForValue().set(key, JSON.toJSONString(newData));
}
} finally {
redis.delete("lock:product:" + id);
}
});
}
// 返回过期数据(保证可用性)
}

return product;
}

// 数据预热时设置逻辑过期
public void preheatProduct(Long id) {
Product product = productMapper.findById(id);
if (product != null) {
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(3600));
redisData.setData(product);
redis.opsForValue().set("product:" + id, JSON.toJSONString(redisData));
}
}
}

优点

  • 不会阻塞请求(返回旧数据)
  • 不需要等待缓存重建

缺点

  • 会返回过期数据(最终一致性)
  • 实现复杂度高
  • 需要额外的线程池处理重建

#

2.3 方案三:热点Key永不过期

@Service
public class HotKeyService {

@Autowired
private StringRedisTemplate redis;
@Autowired
private ProductMapper productMapper;

// 热点key集合(通过监控或配置识别)
private Set<String> hotKeys = ConcurrentHashMap.newKeySet();

public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.opsForValue().get(key);

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

Product product = productMapper.findById(id);
if (product != null) {
long ttl = hotKeys.contains(key) ? -1 : 3600; // 热点key永不过期
redis.opsForValue().set(key, JSON.toJSONString(product));
if (ttl > 0) {
redis.expire(key, ttl, TimeUnit.SECONDS);
}
}

return product;
}

// 定时更新热点key
@Scheduled(fixedRate = 60000)
public void refreshHotKeys() {
// 从监控系统获取热点key列表
// 或根据访问频率统计
}

// 后台定时刷新热点key
@Scheduled(fixedRate = 300000)
public void refreshHotKeyData() {
for (String key : hotKeys) {
Long id = extractIdFromKey(key);
Product product = productMapper.findById(id);
if (product != null) {
redis.opsForValue().set(key, JSON.toJSONString(product));
}
}
}
}

#

2.4 方案四:二级缓存

@Service
public class HotKeyService {

// 本地缓存(Caffeine)
private Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.SECONDS)
.build();

@Autowired
private StringRedisTemplate redis;
@Autowired
private ProductMapper productMapper;

public Product getProduct(Long id) {
String key = "product:" + id;

// 1. 查本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
return product;
}

// 2. 查Redis
String json = redis.opsForValue().get(key);
if (json != null) {
product = JSON.parseObject(json, Product.class);
localCache.put(key, product); // 放入本地缓存
return product;
}

// 3. 查数据库
product = productMapper.findById(id);
if (product != null) {
redis.opsForValue().set(key, JSON.toJSONString(product), 3600, TimeUnit.SECONDS);
localCache.put(key, product);
}

return product;
}
}

三、热点Key识别

#

3.1 Redis监控命令

# 查看热key(Redis 4.0+)
redis-cli --hotkeys

# 查看bigkey
redis-cli --bigkeys

# 监控实时命令
redis-cli MONITOR

# 查看慢查询
redis-cli SLOWLOG GET 10

#

3.2 应用层监控

@Component
public class HotKeyMonitor {

private ConcurrentHashMap<String, AtomicLong> keyAccessCount = new ConcurrentHashMap<>();

public void recordAccess(String key) {
keyAccessCount.computeIfAbsent(key, k -> new AtomicLong(0)).incrementAndGet();
}

@Scheduled(fixedRate = 60000)
public void reportHotKeys() {
List<Map.Entry<String, AtomicLong>> sorted = keyAccessCount.entrySet().stream()
.sorted((e1, e2) -> Long.compare(e2.getValue().get(), e1.getValue().get()))
.limit(100)
.collect(Collectors.toList());

// 上报热点key
for (Map.Entry<String, AtomicLong> entry : sorted) {
System.out.println("HotKey: " + entry.getKey() + ", Count: " + entry.getValue().get());
}

// 清空计数
keyAccessCount.clear();
}
}

#

3.3 使用Redis监控工具

# redis-faina(分析MONITOR输出)
redis-cli MONITOR | head -n 10000 | redis-faina

# 输出热点key统计

四、热点Key解决方案

#

4.1 热点Key分散

// 将热点key分散到多个key上
@Service
public class HotKeyService {

@Autowired
private StringRedisTemplate redis;

private static final int HOT_KEY_SHARD_COUNT = 10;

public Product getProduct(Long id) {
// 对热点key使用多个副本
String baseKey = "product:" + id;

// 先查主key
String json = redis.opsForValue().get(baseKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}

// 如果是热点key,查副本
if (isHotKey(baseKey)) {
int shard = ThreadLocalRandom.current().nextInt(HOT_KEY_SHARD_COUNT);
String shardKey = baseKey + ":" + shard;
json = redis.opsForValue().get(shardKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
}

// ... 查数据库并写入
return null;
}

// 写入时写多个副本
public void setProduct(Product product) {
String baseKey = "product:" + product.getId();
String json = JSON.toJSONString(product);

redis.opsForValue().set(baseKey, json, 3600, TimeUnit.SECONDS);

// 如果是热点key,写多个副本
if (isHotKey(baseKey)) {
for (int i = 0; i < HOT_KEY_SHARD_COUNT; i++) {
redis.opsForValue().set(baseKey + ":" + i, json, 3600, TimeUnit.SECONDS);
}
}
}
}

#

4.2 本地缓存+Redis

@Component
public class HotKeyLocalCache {

private LoadingCache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.SECONDS)
.refreshAfterWrite(3, TimeUnit.SECONDS)
.build(key -> loadFromRedis(key));

@Autowired
private StringRedisTemplate redis;

private Object loadFromRedis(String key) {
String json = redis.opsForValue().get(key);
return json != null ? JSON.parse(json) : null;
}

public Object get(String key) {
return localCache.get(key);
}
}

五、方案对比

方案 优点 缺点 适用场景
互斥锁 简单有效 有等待时间 一般场景
逻辑过期 不阻塞 返回旧数据 高可用优先
永不过期 简单 需要刷新机制 配置型热点key
二级缓存 性能最好 数据一致性 极高并发

六、总结

缓存击穿防御策略:

  1. 互斥锁:保证只有一个线程重建缓存
  2. 逻辑过期:不阻塞请求,异步重建
  3. 热点key永不过期:后台定时刷新
  4. 二级缓存:本地缓存+Redis

热点Key处理策略:

  1. 监控识别:及时发现热点key
  2. key分散:多个副本分担压力
  3. 本地缓存:减少Redis访问
  4. 读写分离:多个Slave分担读压力

核心原则:

  • 预防为主:合理设置过期时间,避免同时过期
  • 分级防御:本地缓存 → Redis → 数据库
  • 监控告警:及时发现热点和击穿
  • 降级保护:异常情况保护数据库

核心要点

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

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

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

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

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

总结

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


   转载规则


《Redis缓存击穿与热点Key》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录