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; 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; redis.opsForValue().set(key, JSON.toJSONString(product)); if (ttl > 0) { redis.expire(key, ttl, TimeUnit.SECONDS); } } return product; } @Scheduled(fixedRate = 60000) public void refreshHotKeys() { } @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 { 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; Product product = localCache.getIfPresent(key); if (product != null) { return product; } String json = redis.opsForValue().get(key); if (json != null) { product = JSON.parseObject(json, Product.class); localCache.put(key, product); return product; } 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监控命令
redis-cli --hotkeys
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()); for (Map.Entry<String, AtomicLong> entry : sorted) { System.out.println("HotKey: " + entry.getKey() + ", Count: " + entry.getValue().get()); } keyAccessCount.clear(); } }
|
#
3.3 使用Redis监控工具
redis-cli MONITOR | head -n 10000 | redis-faina
|
四、热点Key解决方案
#
4.1 热点Key分散
@Service public class HotKeyService { @Autowired private StringRedisTemplate redis; private static final int HOT_KEY_SHARD_COUNT = 10; public Product getProduct(Long id) { String baseKey = "product:" + id; String json = redis.opsForValue().get(baseKey); if (json != null) { return JSON.parseObject(json, Product.class); } 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); 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 |
| 二级缓存 |
性能最好 |
数据一致性 |
极高并发 |
六、总结
缓存击穿防御策略:
- 互斥锁:保证只有一个线程重建缓存
- 逻辑过期:不阻塞请求,异步重建
- 热点key永不过期:后台定时刷新
- 二级缓存:本地缓存+Redis
热点Key处理策略:
- 监控识别:及时发现热点key
- key分散:多个副本分担压力
- 本地缓存:减少Redis访问
- 读写分离:多个Slave分担读压力
核心原则:
- 预防为主:合理设置过期时间,避免同时过期
- 分级防御:本地缓存 → Redis → 数据库
- 监控告警:及时发现热点和击穿
- 降级保护:异常情况保护数据库
核心要点
String:简单的键值对,适合缓存、计数器
Hash:存储对象属性,适合用户信息、配置
List:有序列表,适合消息队列、最新列表
Set:无序去重,适合共同好友、抽奖
ZSet:有序集合,适合排行榜、积分系统
总结
选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。