3. 分布式缓存基础:Redis核心数据结构与使用场景、缓存穿透/击穿/雪崩概念、缓存过期与淘汰策略
3.1 Redis核心数据结构与使用场景
Redis 作为分布式缓存的事实标准,其成功很大程度上归功于丰富且高效的数据结构。理解每种结构的底层实现与适用场景,是设计缓存策略的基础。
| 数据结构 | 底层编码 | 典型使用场景 | 时间复杂度(关键操作) |
|---|---|---|---|
| String | int / embstr / raw | 会话Token、计数器、分布式锁、对象JSON序列化缓存 | GET/SET: O(1) |
| Hash | ziplist / hashtable | 对象字段级缓存(如用户信息)、购物车 | HGET/HSET: O(1) |
| List | quicklist | 消息队列、最新消息列表、时间线 | LPUSH/RPOP: O(1) |
| Set | intset / hashtable | 标签系统、好友关系、抽奖去重 | SADD/SISMEMBER: O(1) |
| Sorted Set | ziplist / skiplist | 排行榜、延时队列、滑动窗口限流 | ZADD: O(log N) |
选型建议: 对于需要频繁修改对象中某个字段的场景(如更新用户最后登录时间),优先使用 Hash 而非整个 String 序列化,可显著减少网络开销与序列化成本。
3.2 缓存穿透、击穿与雪崩
这三个概念是分布式缓存设计中最常遇到的“三座大山”,其本质都是缓存失效导致请求直达后端存储,但触发原因与应对策略截然不同。
生产警示: 缓存雪崩往往不是单一原因造成。Redis 实例宕机与大量 Key 同时过期叠加,会形成“雪崩+穿透”的复合故障。建议同时部署 Redis Sentinel/Cluster 高可用方案,并在客户端侧配置 本地缓存(如 Caffeine) 作为最后一道防线。
3.3 缓存过期与淘汰策略
Redis 的内存是有限的,当内存写满时,必须通过淘汰策略腾出空间。同时,过期 Key 的清理机制也直接影响缓存命中率与性能。
3.3.1 过期策略:被动 + 定期
- 被动过期(惰性删除): 当客户端访问一个 Key 时,Redis 检查其是否过期,若过期则删除并返回 nil。优点是对 CPU 友好,缺点是过期 Key 可能长时间占用内存。
- 定期过期(主动删除): Redis 每隔 100ms 随机抽取一批设置了过期时间的 Key,检查并删除已过期的 Key。通过
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP参数控制每次抽查数量。
最佳实践: 不要完全依赖 Redis 的过期清理。对于大量几乎同时过期的 Key(如每日凌晨失效的会话),建议在业务层为过期时间增加 随机偏移量(如基础 TTL + 随机 0~600 秒),避免集中过期导致 CPU 飙升与缓存雪崩。
3.3.2 内存淘汰策略(maxmemory-policy)
当 Redis 内存达到 maxmemory 上限时,将根据配置的策略选择要淘汰的 Key。不同策略对缓存命中率与系统性能影响差异巨大。
| 策略名称 | 淘汰范围 | 算法 | 适用场景 |
|---|---|---|---|
| noeviction | 不淘汰 | — | 写入失败返回错误,仅用于禁止淘汰的强一致性场景 |
| allkeys-lru | 所有 Key | LRU(最近最少使用) | 通用缓存,追求高命中率 |
| volatile-lru | 设置了 TTL 的 Key | LRU | 缓存与持久数据混合使用 |
| allkeys-random | 所有 Key | 随机 | 数据访问频率均匀,无热点 |
| volatile-random | 设置了 TTL 的 Key | 随机 | 同上,但仅淘汰带过期时间的 Key |
| volatile-ttl | 设置了 TTL 的 Key | TTL 最短优先 | 优先淘汰即将过期的数据 |
| allkeys-lfu(4.0+) | 所有 Key | LFU(最不经常使用) | 访问频率差异明显的场景 |
| volatile-lfu(4.0+) | 设置了 TTL 的 Key | LFU | 带过期时间的频率敏感场景 |
生产推荐: 绝大多数业务缓存场景,allkeys-lru 是最安全且高效的选择。它不关心 Key 是否设置过期时间,统一按访问热度淘汰,实现简单且命中率稳定。如果业务有明显的“冷热数据”分层(如热数据 1 小时、冷数据 1 天),可考虑 volatile-lru 配合精确的 TTL 设置。
3.3.3 淘汰策略选型决策流程
3.4 综合案例:缓存异常防御代码片段
以下代码展示了在业务层如何同时防御缓存穿透与击穿,使用 空值缓存 与 分布式互斥锁 组合策略。
public String getWithProtection(String key) {
// 1. 查缓存
String value = redis.get(key);
if (value != null) {
// 命中缓存,直接返回(包括空值标记)
return value;
}
// 2. 缓存未命中,尝试获取分布式锁(防御击穿)
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (!locked) {
// 获取锁失败,说明其他线程正在加载,短暂等待后重试
Thread.sleep(50);
return redis.get(key); // 二次检查
}
try {
// 3. 双重检查:获取锁后再次查询缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 4. 查询数据库
value = db.query(key);
if (value != null) {
// 数据存在,写入缓存,TTL 随机化防雪崩
int ttl = 3600 + new Random().nextInt(600);
redis.setex(key, ttl, value);
} else {
// 数据不存在,写入空值缓存(防御穿透),TTL 较短
redis.setex(key, 120, "NULL_VALUE");
}
return value;
} finally {
// 5. 释放锁
redis.del(lockKey);
}
}
注意: 上述代码中的
setnx 加锁方式仅用于演示核心逻辑。生产环境建议使用 Redisson 等成熟库的看门狗机制,避免锁超时导致并发问题。同时,空值缓存的 TTL 不宜过长,建议 1~5 分钟,并配合异步消息定期清理无效空值。