🚀 缓存策略
缓存是性能优化的核武器——用空间换时间,用稍旧的数据换取极致的响应速度。 但缓存也是一把双刃剑:用好了吞吐量提升10倍,用不好数据不一致导致严重故障。 本指南涵盖多级缓存架构、Redis最佳实践、一致性方案与经典问题应对。
1. 多级缓存架构
单一缓存层难以兼顾速度、容量、一致性的三角矛盾。业界普遍采用多级缓存架构, 让不同缓存层各司其职。
🏗️ 四级缓存金字塔
┌─────────────────────────────────────────┐
│ L1: 本地缓存 (Caffeine / Guava Cache) │ 纳秒级 | < 1GB
│ JVM堆内,最快,容量有限 │
├─────────────────────────────────────────┤
│ L2: 分布式缓存 (Redis / Memcached) │ 毫秒级 | TB级
│ 跨实例共享,容量可按需扩展 │
├─────────────────────────────────────────┤
│ L3: 数据库缓存 (MySQL Buffer Pool) │ 毫秒级 | 取决于内存
│ InnoDB缓冲池,热数据自动缓存 │
├─────────────────────────────────────────┤
│ L4: 本地磁盘 / CDN │ 毫秒-秒 | PB级
│ 静态资源、不变数据 │
└─────────────────────────────────────────┘
缓存命中流程: L1 → L2 → L3 → 源数据
每层Miss时回源加载,并回填上层缓存
📊 各级缓存技术选型
| 层级 | 推荐技术 | 容量 | 延迟 | 适用数据 | 一致性 |
|---|---|---|---|---|---|
| L1 本地 | Caffeine + Spring Cache | 百MB级 | 纳秒-微秒 | 热点配置、字典、Token | 弱(需主动失效) |
| L2 分布式 | Redis Cluster / Sentinel | 百GB-TB | 1~5ms | 用户Session、商品详情、接口缓存 | 中等(可用MQ通知) |
| L3 数据库 | MySQL InnoDB Buffer Pool | 取决于内存配置 | 0.1~1ms | 热表数据 | 强(由DB保证) |
| L4 CDN/磁盘 | CDN + Nginx本地文件 | PB级 | 10~100ms | 图片、静态资源、日志 | 极弱 |
2. Redis 最佳实践
2.1 数据结构选型
Redis 不只是 key-value 存储——选择正确的数据结构可以大幅减少内存和网络开销。
🗂️ 数据结构选择指南
| 场景 | 错误选择 | 推荐选择 | 原因 |
|---|---|---|---|
| 用户积分排行 | String + ZRANGE(每次都序列化) | Sorted Set | 原生排序,O(logN) |
| 对象缓存 | 多个 String Key(user:1:name, user:1:age) | Hash | 内存节省30%+,支持部分字段更新 |
| 消息队列 | String + RPOP(轮询) | List / Stream | 阻塞读取 (BLPOP),Stream 支持消费组 |
| 去重/UV统计 | Set(全量存储) | HyperLogLog | 12KB 可统计 2^64 个UV,误差0.81% |
| 布隆过滤 | Set(内存爆炸) | Bitmap / RedisBloom | 极低内存判断元素是否存在 |
| 地理位置 | String + 手动计算 | Geo | 原生经纬度距离计算 |
| 计数器/限流 | String + INCR(单Key) | String + Lua脚本 | 原子操作,支持滑动窗口限流 |
2.2 Redis 性能调优参数
⚙️ Redis 关键配置优化
| 参数 | 默认值 | 推荐值 | 说明 |
|---|---|---|---|
maxmemory | 无限制 | 物理内存的60-70% | 务必设置,避免OOM被系统Kill |
maxmemory-policy | noeviction | allkeys-lru / volatile-lru | 逐出策略,不要用noeviction(会拒绝写入) |
save | 周期性RDB | 关闭(纯缓存场景) | 缓存可关闭持久化,用AOF+RDB混合或用主从保证数据安全 |
tcp-backlog | 511 | 2048 | 高并发下避免连接丢失 |
timeout | 0 | 300 | 空闲连接超时关闭,避免僵尸连接 |
hash-max-ziplist-entries | 512 | 512 | 小Hash使用ziplist压缩编码,节省内存 |
slowlog-log-slower-than | 10000(微秒) | 10000 | 记录慢操作日志,单位微秒 |
activerehashing | yes | yes | 渐进式rehash,避免大面积阻塞 |
2.3 Redis 集群模式选型
🔗 集群方案对比
| 方案 | 容量 | 可用性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 主从(单机) | 受单机内存限制 | 手动切换 | 低 | 开发/测试环境 |
| Sentinel 哨兵 | 受单机内存限制 | 自动故障转移 | 中 | 中小规模,读多写少 |
| Redis Cluster | 水平扩展至1000节点 | 自动故障转移 | 高 | 大规模、高吞吐、大数据量 |
| Proxy 代理层 | 按分片规则扩展 | 依赖代理层HA | 高 | 需要透明分片、多语言客户端 |
3. 缓存一致性方案
缓存与数据库的一致性问题是分布式系统的经典难题。CAP理论告诉我们:一致性和可用性不可兼得, 需要根据业务场景选择合适的方案。
3.1 主流一致性方案
📐 六种缓存一致性方案对比
| 方案 | 原理 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside (旁路缓存) |
读:先查缓存,Miss则查DB并回填 写:先更新DB,再删除缓存 |
最终一致 | ⭐⭐ | 最通用方案,90%场景适用 |
| Read/Write Through | 缓存层代理所有读写,对应用透明 | 强一致 | ⭐⭐⭐ | 需要缓存中间件(如RedisGear) |
| Write Behind (异步回写) |
先写缓存,异步批量写DB | 最终一致 | ⭐⭐⭐⭐ | 写密集、可容忍数据丢失 |
| 双删策略 | 写前删缓存 → 更新DB → 延迟再删缓存 | 最终一致 | ⭐⭐ | 高并发写场景、防止脏读 |
| 订阅Binlog | Canal/Debezium 订阅 MySQL Binlog,异步更新/删除缓存 | 最终一致 | ⭐⭐⭐⭐ | 多系统缓存同步、跨机房 |
| 分布式锁 | 写操作获取锁:更新DB → 更新缓存 → 释放锁 | 强一致 | ⭐⭐⭐ | 一致性要求极高(如余额、库存) |
💡 为什么是「删缓存」而不是「更新缓存」?
删除缓存是惰性加载(Lazy Loading)策略:只有下次读取时才从DB加载最新值。 相比更新缓存,删除操作避免了并发写时的覆盖问题,也避免了缓存「不读的数据」占用空间。
3.2 Cache-Aside 核心流程
📋 标准 Cache-Aside 实现
// 读取:缓存优先
public Order getOrder(Long id) {
String key = "order:" + id;
Order order = redis.get(key);
if (order != null) return order;
// 缓存未命中,查DB
order = db.findById(id);
if (order != null) {
redis.setex(key, 3600, order); // 回填缓存,设置过期时间
}
return order;
}
// 写入:先更新DB,再删除缓存
@Transactional
public void updateOrder(Order order) {
db.update(order); // 1. 先更新数据库
redis.del("order:" + order.id); // 2. 再删除缓存
}
// 删除操作双删策略(高并发优化)
@Transactional
public void deleteOrder(Long id) {
redis.del("order:" + id); // 1. 先删缓存
db.delete(id); // 2. 更新DB
Thread.sleep(500); // 3. 等待其他并发读完成
redis.del("order:" + id); // 4. 二次删除,防止并发写导致的脏缓存
}
4. 缓存穿透 / 击穿 / 雪崩
这三个问题是缓存架构中最高频的故障模式,名字相似但成因和解决方案完全不同。 面试和故障复盘必考——必须搞清楚。
⚠️ 三大问题对比总览
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 定义 | 查询不存在的数据,缓存和DB都没有 | 热点Key过期瞬间,大量请求打到DB | 大量Key同时过期,或Redis宕机 |
| 后果 | 无效查询穿透到DB,可能被恶意攻击 | DB瞬时压力激增,可能拖垮DB | DB被打垮,引发全链路雪崩 |
| 典型场景 | 查询不存在的商品ID、恶意攻击 | 秒杀商品缓存过期、热点新闻 | 批量导入缓存统一TTL、Redis集群故障 |
| 解决方案 | 布隆过滤器、缓存空对象、参数校验 | 互斥锁、永不过期+异步更新、逻辑过期 | 随机化TTL、多级缓存、熔断降级 |
4.1 缓存穿透应对
🛡️ 穿透防护三层防线
| 防线 | 方案 | 原理 | 优缺点 |
|---|---|---|---|
| L1: 参数校验 | 业务层过滤非法参数 | ID范围、格式校验,直接拒绝非法请求 | 简单有效,但无法防御合法ID的恶意遍历 |
| L2: 布隆过滤器 | RedisBloom / Guava BloomFilter | 先判断Key是否可能存在,不存在则直接返回 | 内存效率高(1亿Key约100MB),但有误判率 |
| L3: 缓存空值 | 对不存在的Key也缓存(value=null, TTL短) | 将「不存在」作为一个结果缓存,避免重复查DB | 简单,但消耗缓存空间,需控制TTL |
💻 布隆过滤器实现示例
// 使用 RedisBloom 模块
// BF.RESERVE product_filter 0.01 10000000 // 误判率1%,预计1000万元素
public Product getProduct(Long id) {
// 1. 布隆过滤器检查
if (!redis.bfExists("product_filter", String.valueOf(id))) {
return null; // 一定不存在,直接返回
}
// 2. 查缓存
Product product = redis.get("product:" + id);
if (product != null) return product;
// 3. 查DB
product = db.findById(id);
if (product != null) {
redis.setex("product:" + id, 3600, product);
} else {
// 4. 缓存空值(防止布隆过滤器误判导致穿透)
redis.setex("product:" + id, 60, "NULL");
}
return product;
}
4.2 缓存击穿应对
热点Key过期瞬间,多个并发请求同时查到缓存Miss,然后并发去查DB——这就是击穿。 核心思路:只让一个请求去加载数据,其余等待。
🔐 互斥锁方案(Redis SETNX)
public Product getHotProduct(Long id) {
String key = "hot:product:" + id;
Product product = redis.get(key);
if (product != null) return product;
// 加互斥锁,只让一个线程去加载
String lockKey = "lock:product:" + id;
try {
if (redis.setnx(lockKey, "1", 10)) { // 获取锁,10秒过期
// 双重检查
product = redis.get(key);
if (product != null) return product;
product = db.findById(id);
redis.setex(key, 3600, product);
} else {
// 未获取锁,短暂等待后重试
Thread.sleep(100);
return getHotProduct(id); // 递归重试
}
} finally {
redis.del(lockKey);
}
return product;
}
4.3 缓存雪崩应对
雪崩通常由两类原因引发:大量Key同一时刻过期或Redis服务不可用。 应对需要从「预防」和「降级」两个层面入手。
🛡️ 雪崩防护四层体系
| 层级 | 策略 | 实现方式 |
|---|---|---|
| 1. 过期时间随机化 | 将缓存过期时间打散,避免集中过期 | TTL = base + random(0, 600) 秒 |
| 2. 多级缓存 | 本地缓存兜底,Redis挂了走本地 | Caffeine + Redis 双层架构 |
| 3. 熔断降级 | Redis不可用时直接返回降级数据 | Sentinel / Hystrix 熔断 → 返回默认值/静态页面 |
| 4. Redis高可用 | Redis自身高可用,避免单点故障 | Redis Sentinel / Cluster + 持久化 |
💻 随机TTL + 多级缓存实现
// Caffeine 本地缓存配置
Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats()
.build();
public User getWithMultiLevelCache(Long userId) {
String key = "user:" + userId;
// L1: 本地缓存
User user = localCache.getIfPresent(key);
if (user != null) return user;
try {
// L2: Redis分布式缓存
user = redis.get(key);
if (user != null) {
localCache.put(key, user); // 回填本地缓存
return user;
}
} catch (Exception e) {
// Redis故障,尝试降级
log.warn("Redis unavailable, using fallback", e);
return getFallbackUser(userId);
}
// L3: 数据库
user = db.findById(userId);
if (user != null) {
// 设置随机过期时间,避免雪崩
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex(key, ttl, user);
localCache.put(key, user);
}
return user;
}
🚨 缓存设计反模式
- 所有数据都缓存:缓存空间宝贵,只缓存热点数据和频繁读取的数据。遵循 80/20 原则。
- 缓存无过期时间:会导致内存无限增长,Redis OOM。每个Key必须有TTL。
- 大Key(Big Key):单个Key超过10KB(String)或元素超过5000(集合),拆分为多个小Key。
- 热Key(Hot Key):单个Key的QPS超过单分片承载能力(通常>5000),需要本地缓存或Key拆分。
- 缓存与DB操作不在同一事务:先更新DB成功但删缓存失败,导致长期不一致。