高并发读多写少的业务中,如果每次请求都直接访问Redis,一旦出现网络抖动、热点key集中访问或者Redis内存压力过大,大量请求就会穿透到数据库,导致连接数飙升、响应延迟从毫秒级恶化到秒级。更合理的做法是在应用进程内增加一层本地缓存作为一级缓存,把Redis作为二级分布式缓存,数据库作为最终数据源。这种组合下,一级缓存负责极速命中,二级缓存负责跨实例共享,两者协同可以把绝大多数读请求拦截在数据库之前。

一级缓存与二级缓存的职责划分
一级缓存通常指JVM进程内的本地缓存,常见实现有Caffeine、Guava Cache、Ehcache。它的最大优势是速度快,数据直接放在堆内存中,读取延迟可以控制在微秒级,而且不涉及网络序列化开销。但本地缓存也有明显短板:容量受限于单个JVM堆内存,不能无限扩展;每个应用实例各自维护一份缓存,集群部署时容易出现数据不一致,也就是不同实例持有同一key的不同版本。
二级缓存一般使用Redis这样的分布式缓存。Redis独立部署,所有应用实例共享同一份缓存数据,容量可以通过集群横向扩展。虽然Redis的访问延迟比本地缓存高,通常在毫秒级以下,但比数据库查询快一个数量级。把Redis放在二级缓存位置,既能缓解数据库压力,又能解决本地缓存无法跨实例共享的问题。一级缓存与二级缓存形成了互补:一级缓存拦截最频繁的请求,二级缓存保证数据最终一致性和可用性。
读取链路的典型流程是:先查一级缓存,命中直接返回;未命中则查Redis二级缓存,命中后回填一级缓存并返回;仍未命中则回源数据库,拿到数据后依次写入Redis和一级缓存。下面用Java结合Caffeine和RedisTemplate给出一个查询示例。
public class CacheService {
private final LoadingCache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build(this::loadFromRedis);
private final RedisTemplate<String, Object> redis;
public Object get(String key) {
return localCache.get(key, k -> {
Object val = redis.opsForValue().get(k);
if (val != null) {
return val;
}
val = loadFromDb(k);
if (val != null) {
redis.opsForValue().set(k, val, 5, TimeUnit.MINUTES);
}
return val;
});
}
private Object loadFromRedis(String key) {
return redis.opsForValue().get(key);
}
private Object loadFromDb(String key) {
// 模拟数据库查询
return null;
}
}
这种写入顺序保证了后续请求能够优先命中一级缓存,减少对Redis的访问。但这里的本地缓存过期时间需要谨慎设置,太短会导致频繁回源Redis,太长则可能长期持有脏数据。另外,当本地缓存未命中时,Caffeine的LoadingCache会自动调用loadFromRedis加载数据,即便Redis没有数据,也不会阻塞并发请求,因为Caffeine内部做了key级别的并发控制。
多级缓存的数据一致性保障
引入一级缓存后,最大的麻烦来自数据更新时的一致性问题。假设数据库中的数据被修改,Redis中的旧值需要删除或更新,与此同时各个应用实例上的本地缓存也可能持有旧数据。如果只删除Redis缓存,而本地缓存还保留旧值,用户仍然会读到过期数据。因此更新策略必须同时考虑一级缓存和二级缓存的失效顺序。
常见的Cache Aside模式是:先更新数据库,然后删除二级缓存,再通过广播通知所有实例删除一级缓存。删除缓存而不是更新缓存,可以避免并发写导致的值覆盖问题。但在删除二级缓存和本地缓存之间存在时间窗口,如果窗口内另一个请求恰好回源数据库读到了新值并回填缓存,那么旧缓存可能再次被写入。为了解决这个问题,可以采用延迟双删策略:更新数据库后先删除缓存,延迟几百毫秒后再删除一次,尽量覆盖并发读的窗口。
更可靠的做法是使用消息队列或者Redis的发布订阅功能,在数据更新后发布缓存失效事件,所有实例订阅该事件并清除本地缓存。下面给出基于Redis发布订阅的示例,演示如何让集群中所有节点清理一级缓存。
// 发布缓存失效事件
redis.convertAndSend("cache:invalidate", "user:1001");
// 订阅方监听并清理本地缓存
@Component
public class RedisCacheInvalidateSubscriber {
@Autowired
private CacheManager localCacheManager;
@PostConstruct
public void init() {
redisContainer.addMessageListener((message, pattern) -> {
String key = new String(message.getBody());
localCacheManager.evict(key);
}, new ChannelTopic("cache:invalidate"));
}
}
上述方案属于最终一致性,在并发量极高且对一致性要求极其严格的场景下仍有不足。如果业务允许短暂不一致,这种设计已经足够。如果要求强一致,则需要引入分布式锁或者将读请求在更新期间强制回源数据库,但会牺牲吞吐量。除此之外,还可以通过监听数据库binlog的方式触发缓存更新,进一步解耦业务代码中的缓存操作。
无论采用哪种失效策略,都要避免直接更新缓存值。假设两个并发请求同时更新数据库和缓存,由于执行顺序不可控,可能出现数据库较新但缓存较旧的情况。删除缓存让后续请求重新加载,能够天然避免这种竞态。
缓存穿透、击穿、雪崩的防御实践
多级缓存架构同样要面对经典的三大缓存问题。缓存穿透指查询一个根本不存在的数据,该请求会绕过一级缓存和Redis直接打到数据库。缓存击穿指某个热点key在过期的瞬间,大量并发请求同时回源数据库。缓存雪崩则指大量key在同一时间过期,或者Redis本身宕机,导致请求集中落到数据库。三个问题都会造成数据库瞬时压力过大,必须在架构层面提前设计防御。
对于穿透,常见方案是缓存空值和使用布隆过滤器。缓存空值的方式简单直接,当数据库查询结果为空时,在Redis中写入一个带有短过期时间的空对象,让后续请求直接返回空,避免重复查询数据库。布隆过滤器可以放在Redis之前,先判断key是否可能存在,不存在的key直接拦截。下面展示Guava布隆过滤器的初始化与判断逻辑。
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000,
0.01);
bloomFilter.put("user:1001");
boolean mightContain = bloomFilter.mightContain("user:1001");
if (!mightContain) {
// 直接返回空,不查数据库
}
对于击穿,核心思路是让只有一个请求能够回源数据库,其他请求等待或快速失败。可以在Redis前面加分布式锁,使用Redisson实现互斥加载。下面示例中,通过Redisson的锁保证同一时刻只有一个线程执行数据库查询并回填缓存。
public Object getWithLock(String key) {
Object val = localCache.getIfPresent(key);
if (val != null) return val;
RLock lock = redisson.getLock("lock:" + key);
try {
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
val = localCache.getIfPresent(key);
if (val != null) return val;
val = redis.opsForValue().get(key);
if (val != null) return val;
val = loadFromDb(key);
if (val != null) {
redis.opsForValue().set(key, val, randomExpire(), TimeUnit.MINUTES);
localCache.put(key, val);
}
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return val;
}
private long randomExpire() {
return 30 + ThreadLocalRandom.current().nextInt(60);
}
对于雪崩,一方面要避免大量key同时过期,给每个key的过期时间加上随机值;另一方面要做好降级,当Redis不可用时,一级缓存仍然可以支撑一部分读请求,避免数据库直接暴露。同时,Redis本身可以部署主从和哨兵或者集群模式,提高可用性。
多级缓存落地选型与监控
本地缓存框架的选择直接影响到一级缓存的性能和稳定性。Caffeine是目前综合表现最好的选择,它使用Window TinyLFU淘汰算法,在命中率和内存占用之间取得了很好的平衡,而且支持异步加载和自动刷新。Guava Cache虽然使用广泛,但已经多年没有重大更新,性能逐渐被Caffeine拉开。Ehcache功能强大,支持持久化和分布式,但配置相对复杂,适合有特殊要求的场景。大多数新项目建议直接使用Caffeine作为一级缓存。
Redis作为二级缓存时,序列化方式对性能和内存占用影响很大。JDK原生序列化虽然使用简单,但产生的字节数较多,而且要求类实现Serializable接口。JSON序列化可读性好,但序列化后的体积也不小。Protobuf或者Kryo等二进制方案效率更高,适合对性能敏感的系统。此外,还要合理规划Redis内存容量,根据预估的数据量和每条数据的平均大小,预留一定的冗余,避免内存不足触发淘汰策略导致命中率下降。
架构上线后必须监控几个关键指标:一级缓存命中率、二级缓存命中率、数据库查询次数、缓存加载耗时。命中率过低需要检查key的过期时间设置是否合理,或者是否存在大量无法命中的非法请求。缓存加载耗时异常可能意味着数据库查询变慢,需要优化SQL或者增加索引。同时,为Redis配置告警阈值,当内存使用率超过80%或者连接数异常升高时及时介入处理。
多级缓存并非越级越多越好,每增加一层缓存都会引入新的复杂度和一致性风险。只有在单层缓存无法满足读性能要求时,才考虑叠加一级本地缓存。如果业务读多写少、数据量适中,直接使用Redis作为唯一缓存往往更加简单可靠。设计时应当优先评估数据库承受能力,再决定是否需要本地缓存来进一步降低Redis压力。