Redis 缓存设计与优化实践
Redis 缓存设计与优化实践:从代码到架构的演进之路
在高并发场景下,缓存是提升系统性能的核心手段,但随之而来的缓存穿透、双写不一致、热点数据冲击等问题也不容忽视。本文结合实际业务代码,从基础缓存实现到多级缓存架构,循序渐进地讲解缓存设计的优化思路与最佳实践。
一、基础缓存设计:解决 "查多写少" 的性能瓶颈
1.1 缓存读写策略:先查缓存再查库
在商品查询场景中,我们采用 "缓存优先" 策略:
- 查询商品时,先从缓存获取数据
- 缓存未命中时,查询数据库并回写缓存
- 缓存命中时直接返回,减少数据库访问
public Product get(Long productId) throws InterruptedException {
Product product = null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
// 先查缓存
product = getProductFromCache(productCacheKey);
if (product != null) {
return product;
}
// 缓存未命中,查库并回写缓存(后续优化部分)
// ...
}
1.2 缓存时效性:随机过期时间避免缓存雪崩
为避免大量缓存同时过期导致的 "缓存雪崩",我们在设置缓存时添加随机过期时间:
// 商品缓存基础超时24小时,随机增加0-5小时偏移量
private Integer genProductCacheTimeout() {
return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
}
// 空对象缓存超时60-90秒,避免长期占用内存
private Integer genEmptyCacheTimeout() {
return 60 + new Random().nextInt(30);
}
原理:通过时间偏移打散缓存过期点,防止同一时间大量请求穿透到数据库。
1.3 读延期机制:延长热点数据缓存生命周期
在缓存命中时,我们会延长缓存的过期时间(读延期):
private Product getProductFromCache(String productCacheKey) {
// ...从缓存获取数据逻辑...
if (!StringUtils.isEmpty(productStr)) {
// 缓存命中时延长过期时间
redisUtil.expire(productCacheKey, genProductCacheTimeout(), TimeUnit.SECONDS);
}
return product;
}
优势:
- 热点数据会被频繁访问,通过读延期自动延长生命周期
expire命令比重新set值更高效(减少网络传输和内存操作)- 实现 "冷热分离":热点数据自动长期驻留,冷数据自然淘汰
二、缓存问题解决方案:从穿透到一致性
2.1 缓存穿透:空对象 + 布隆过滤器
缓存穿透指查询不存在的数据,导致请求直接穿透到数据库。解决方案:
- 缓存空对象:对不存在的商品 ID,缓存空对象并设置短期过期
// 查库后发现商品不存在
if (product == null) {
// 缓存空对象标记,避免重复穿透
redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
}
- 布隆过滤器:
- 启动时将所有商品 ID 加载到布隆过滤器
- 查询前先通过过滤器判断 ID 是否存在,不存在直接返回
- 适合数据量固定、新增少的场景(如商品 ID)
2.2 热点缓存重建:双重检查锁 + 分布式锁
热点商品缓存过期时,可能引发大量并发请求同时查库重建缓存,导致数据库压力骤增。解决方案:
// 双重检查锁(DCL)+ 分布式锁
RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
hotCacheLock.lock(); // 获取分布式锁
try {
// 二次检查缓存,避免锁等待期间已重建缓存
product = getProductFromCache(productCacheKey);
if (product != null) {
return product;
}
// 真正的查库并重建缓存操作
// ...
} finally {
hotCacheLock.unlock();
}
关键点:
- 双重检查:获取锁前后各检查一次缓存,减少无效的锁竞争
- 分布式锁:保证集群环境下只有一个线程执行缓存重建
- 锁超时:实际应用中可以使用
tryLock(timeout)避免死锁(慎重考虑)
锁超时方案,需要结合具体业务,例如评估业务代码执行最多1s,则tryLock设置1秒自动锁失效。若是1秒业务没执行完,则可能大量请求打到数据库
2.3 双写不一致:读写锁优化读多写少场景
更新商品数据时,需要保证数据库与缓存的数据一致性。采用读写锁优化:
// 更新商品时使用写锁
RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
RLock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
// 1. 更新数据库
productResult = productDao.update(product);
// 2. 更新缓存
redisUtil.set(..., JSON.toJSONString(productResult), ...);
} finally {
writeLock.unlock();
}
// 查询商品时使用读锁
RLock rLock = readWriteLock.readLock();
rLock.lock();
try {
// 查库操作
} finally {
rLock.unlock();
}
读写锁优势:
- 读锁共享:多个读请求可同时获取锁,不阻塞
- 写锁排他:写操作时阻塞所有读写请求,保证原子性
- 适合读多写少场景(如商品详情查询远多于更新)
三、多级缓存架构:从 Redis 到本地缓存
当并发量进一步提升,单靠 Redis 可能成为瓶颈,此时引入多级缓存:
3.1 JVM 本地缓存:抗住瞬时高并发
在代码中添加本地缓存(ConcurrentHashMap)作为一级缓存:
// 本地缓存容器,线程安全
public static Map<String, Product> productMap = new ConcurrentHashMap<>();
// 从缓存获取数据时,优先查本地缓存
private Product getProductFromCache(String productCacheKey) {
// 1. 先查本地缓存
Product product = productMap.get(productCacheKey);
if (product != null) {
return product;
}
// 2. 再查Redis缓存
// ...
}
优势:
- 本地内存访问,性能比 Redis 高 1-2 个数量级
- 抗住瞬时流量峰值(如秒杀开始时的突发请求)
- 减轻 Redis 压力
3.2 本地缓存一致性:MQ 同步与取舍
分布式系统中,多节点的本地缓存需要保持一致:
// 更新商品时,同步更新本地缓存
productMap.put(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), product);
// 集群环境下,通过MQ通知其他节点更新本地缓存
// 伪代码:
mqTemplate.send("product-update-topic", productResult.getId());
注意事项:
- 本地缓存容量有限,只适合存储热点数据
- MQ 同步存在延迟,需容忍短期不一致
- 非核心业务可接受最终一致性,避免过度设计
3.3 进一步优化:专业本地缓存方案
当业务复杂度提升,可考虑成熟的本地缓存框架:
- Caffeine:支持 LRU/LFU 等淘汰策略,自动清理过期数据
- JetCache:整合本地缓存与分布式缓存,支持 TTL 和自动刷新
- 单独服务维护热点数据:通过监听 MQ 更新,解耦业务代码
四、总结:缓存设计的权衡之道
没有完美的缓存方案,只有适合业务的方案。关键权衡点:
- 性能与一致性:
- 强一致性:读写锁 + 分布式锁(性能损耗高)
- 最终一致性:过期时间 + MQ 同步(性能好,适合大多数场景)
- 复杂度与收益:
- 简单场景:Redis 单级缓存 + 随机过期即可
- 高并发场景:多级缓存 + 读写锁 + 布隆过滤器
- 监控与调优:
- 关注 Redis 慢查询日志,优化 bigkey 和 O (N) 命令
- 监控缓存命中率,低于 80% 需排查原因
- 定期演练缓存失效场景,验证降级策略
通过本文的代码解析与架构演进分析,相信你已掌握缓存设计的核心思路。实际开发中,需结合业务 QPS、数据一致性要求和团队技术栈,选择最合适的方案并持续优化。

浙公网安备 33010602011771号