2. 缓存基础:本地缓存与分布式缓存原理、缓存穿透/击穿/雪崩
缓存是后端系统应对高并发、提升响应速度的核心手段。本章从缓存分类出发,深入剖析本地缓存与分布式缓存的实现原理,并重点讲解三种经典缓存故障——穿透、击穿、雪崩的成因与解决方案。理解这些基础,是后续构建缓存一致性闭环与可观测性整合的前提。
2.1 缓存分类与核心原理
根据数据存储位置与共享范围,缓存可分为本地缓存和分布式缓存两大类。两者在性能、一致性、容量和适用场景上存在显著差异。
| 对比维度 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 存储位置 | 应用进程内(JVM 堆 / 堆外内存) | 独立缓存服务(Redis / Memcached 集群) |
| 访问延迟 | 纳秒级(无网络开销) | 微秒~毫秒级(网络 RTT) |
| 容量上限 | 受限于单机内存(通常 GB 级) | 可水平扩展至 TB 级 |
| 数据一致性 | 各节点独立,易出现不一致 | 中心化存储,一致性相对可控 |
| 典型实现 | Caffeine, Guava Cache, Ehcache | Redis, Memcached, Hazelcast |
| 适用场景 | 配置信息、热点数据、单机计算缓存 | 全局共享数据、会话状态、跨服务缓存 |
架构决策要点: 本地缓存适合延迟敏感、数据变更频率低的场景;分布式缓存适合跨进程共享、需要全局一致性的场景。生产环境中常采用多级缓存架构——本地缓存兜底热点,分布式缓存承载全量。
2.2 本地缓存实现原理
本地缓存本质是一个内存中的键值存储,核心组件包括:存储引擎、淘汰策略、过期机制、并发控制。
- 存储引擎: 通常使用 ConcurrentHashMap 或类似结构,支持高并发读写。
- 淘汰策略: 当缓存容量达到上限时,按策略移除旧数据。常见策略包括:
- LRU(最近最少使用): 淘汰最久未被访问的条目。
- LFU(最不经常使用): 淘汰访问频率最低的条目。
- FIFO(先进先出): 按写入顺序淘汰。
- TTL(过期时间): 基于写入时间或最后访问时间自动失效。
- 并发控制: 通过分段锁、读写锁或 CAS 操作保证线程安全。
// Caffeine 本地缓存典型配置示例
Cache<String, UserProfile> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目数
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期
.recordStats() // 开启统计,用于可观测性
.build();
// 缓存穿透保护:结合 LoadingCache 自动加载
LoadingCache<String, UserProfile> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> userService.loadFromDB(key));
2.3 分布式缓存:Redis 与 Memcached
分布式缓存将数据分散存储在集群节点上,对外提供统一的读写接口。Redis 和 Memcached 是当前最主流的两种方案。
2.3.1 Redis 核心特性
- 丰富的数据结构: String、Hash、List、Set、Sorted Set、Bitmap、HyperLogLog、Stream。
- 持久化: RDB(快照)和 AOF(追加日志),支持数据恢复。
- 高可用: 主从复制 + Sentinel 自动故障转移;Redis Cluster 提供分片与自动重分片。
- 原子操作: 支持 Lua 脚本、事务、分布式锁(SETNX + Redlock)。
- 过期策略: 惰性删除 + 定期扫描,支持精确 TTL。
2.3.2 Memcached 核心特性
- 纯内存缓存: 不支持持久化,重启后数据全部丢失。
- 简单 KV 模型: 仅支持 String 类型,value 最大 1MB。
- 多线程架构: 利用多核 CPU,读写性能极高。
- LRU 淘汰: 内存满时自动淘汰最近最少使用的条目。
- 客户端分片: 一致性哈希由客户端实现,服务端无集群管理。
选型建议: 需要数据结构灵活性、持久化、高可用时选择 Redis;追求极致读写性能、数据可丢失、仅需简单 KV 时选择 Memcached。当前业界 Redis 占据绝对主导地位。
2.4 缓存穿透、击穿与雪崩
这三种故障是缓存系统最常见的稳定性杀手,理解其成因与应对方案是架构师的必修课。
核心区别速记: 穿透是“查不到”导致绕过缓存直击 DB;击穿是“热点 key 失效”导致并发打穿;雪崩是“大量 key 同时失效”导致 DB 被压垮。
2.4.1 缓存穿透
定义: 查询一个根本不存在的数据,缓存和数据库都查不到,每次请求都直接打到数据库。
常见原因:
- 恶意攻击:遍历不存在的 ID。
- 业务漏洞:查询条件错误导致 key 不存在。
解决方案:
- 缓存空值: 将 null 结果也缓存,设置较短 TTL(如 30~60 秒)。
- 布隆过滤器: 在缓存前加一层 Bloom Filter,快速判断 key 是否可能存在。
- 参数校验: 在入口层拦截非法参数。
2.4.2 缓存击穿
定义: 一个热点 key 在缓存失效的瞬间,大量并发请求同时穿透到数据库,导致 DB 负载飙升。
解决方案:
- 互斥锁: 只允许一个线程去加载数据,其他线程等待或快速失败。
- 逻辑过期: 缓存永不过期,但 value 中携带过期时间,异步刷新。
- 热点数据预热: 系统启动时主动加载热点 key,避免冷启动击穿。
// 互斥锁防止缓存击穿(Redis + Redisson 示例)
public String getWithMutex(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = "lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
// 双重检查:锁获取后再次查询缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 从数据库加载
value = db.query(key);
redis.setex(key, 300, value);
return value;
} else {
// 等待后重试或返回降级数据
Thread.sleep(50);
return getWithMutex(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return fallback(key);
} finally {
lock.unlock();
}
}
2.4.3 缓存雪崩
定义: 大量缓存 key 在同一时间集中过期,或缓存节点宕机,导致所有请求直接打到数据库,引发连锁崩溃。
解决方案:
- 过期时间随机化: 在基础 TTL 上增加随机偏移(如 ±10%~30%),避免集体失效。
- 多级缓存: 本地缓存 + 分布式缓存组合,本地缓存作为最后一道防线。
- 缓存高可用: Redis Sentinel/Cluster 自动故障转移,避免单点。
- 限流降级: 对 DB 访问做限流,超出阈值时返回降级数据或错误提示。
- 提前预热: 大促前主动加载热点数据,分散过期时间。
2.5 三种故障对比与应对矩阵
| 故障类型 | 根本原因 | 影响范围 | 核心应对策略 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 单个 key 持续打 DB | 缓存空值、布隆过滤器、参数校验 |
| 缓存击穿 | 热点 key 失效瞬间并发 | 单个热点 key 瞬时打 DB | 互斥锁、逻辑过期、热点预热 |
| 缓存雪崩 | 大量 key 同时过期 / 节点宕机 | 全局流量打 DB,可能雪崩 | TTL 随机化、多级缓存、高可用、限流降级 |