Skip to content

Redis 基础实战 ​

Java 中级 · 存储与缓存

Redis 是内存数据库,常用作缓存、分布式锁、计数器、排行榜。但「会用 set/get」远远不够——缓存穿透/击穿/雪崩是面试必问,也是线上事故高发区。本篇从数据结构底层讲起,重点讲清缓存三大问题的成因与解法。

一、五大数据结构及其底层编码 ​

结构典型用途底层编码
String缓存、计数器、分布式锁SDS(简单动态字符串)
Hash存对象字段listpack / hashtable
List消息队列、栈quicklist(双向链表+listpack)
Set去重、交并集intset / hashtable
ZSet排行榜、延时队列listpack / skiplist(跳表)
bash
# String
SET user:1:name "Tom" EX 3600      # 带过期
INCR article:1:views               # 原子自增(计数器)
SETNX lock:order:1 "uuid"          # 分布式锁雏形

# Hash(存对象,比多个 String 省内存)
HSET user:1 name Tom age 18
HGETALL user:1

# List(LPUSH+BRPOP 做简易队列)
LPUSH queue:order 1001
BRPOP queue:order 0                # 阻塞弹出

# Set
SADD article:1:likes 100
SINTER article:1:likes article:2:likes   # 共同点赞的人

# ZSet(排行榜)
ZADD rank 100 tom 95 jerry
ZREVRANGE rank 0 9 WITHSCORES      # 取前 10

为什么用 SDS 而不是 C 字符串

C 字符串以 \0 结尾、获取长度要 O(n)、容易缓冲区溢出。Redis 的 SDS 记录了 len 和 free,O(1) 取长度、二进制安全、预分配减少内存重分配。

二、过期策略与内存淘汰 ​

2.1 过期删除 ​

Redis 采用惰性删除 + 定期删除结合:

策略说明
惰性删除访问 key 时才检查是否过期,过期则删
定期删除每秒随机抽查部分设置了过期的 key,删除其中的过期项

为什么不遍历删除所有过期 key

全量遍历会阻塞单线程的 Redis。所以采用「抽查 + 惰性」的折中,保证不卡顿的同时逐步清理。

2.2 内存淘汰策略(maxmemory-policy) ​

策略说明
noeviction不淘汰,写满报错(默认)
allkeys-lru所有 key 中淘汰最近最少使用(缓存场景推荐)
allkeys-lfu淘汰访问频率最低
volatile-lru只在设了过期时间的 key 中淘汰
allkeys-random随机淘汰
volatile-ttl优先淘汰即将过期的
conf
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

三、缓存读写模式 ​

3.1 缓存更新策略 ​

策略说明评价
先更新 DB,再删缓存最常用推荐(Cache-Aside)
先删缓存,再更新 DB有并发脏读风险不推荐
先更新 DB,再更新缓存并发写会脏不推荐
延迟双删更新 DB 前后各删一次解决部分并发问题

为什么是「删缓存」而不是「更新缓存」

  • 更新缓存可能并发写覆盖(旧值后写覆盖新值)。
  • 若缓存值需要复杂计算,频繁更新浪费。
  • 删除更简单:下次读时按需重建(懒加载)。

四、缓存三大问题(重点) ​

4.1 缓存穿透 ​

问题:查询不存在的数据,缓存不命中 → 每次都打到数据库。恶意攻击可用大量不存在的 key 打垮 DB。

解法:

  1. 缓存空值:DB 查不到也缓存一个空(短过期时间,如 60s)。
  2. 布隆过滤器:请求前用布隆过滤器判断 key 是否可能存在,不存在直接拒绝。
  3. 参数校验:非法参数(负数 ID、超长字符串)直接拦截。
java
// 缓存空值
String val = redis.get(key);
if (val != null) return val.isEmpty() ? null : val;   // 空串代表不存在
User user = db.query(id);
redis.set(key, user == null ? "" : toJson(user), 60, TimeUnit.SECONDS);
return user;

4.2 缓存击穿 ​

问题:某个热点 key 过期瞬间,大量并发请求同时打到 DB。

解法:

  1. 互斥锁:只让一个线程去查 DB 重建缓存,其余等待。
  2. 热点 key 永不过期:逻辑过期,后台异步更新。
java
// 互斥锁方案
String val = redis.get(key);
if (val == null) {
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 10)) {          // 抢到锁
        try {
            val = loadFromDb(key);
            redis.set(key, val, 300);
        } finally {
            redis.del(lockKey);
        }
    } else {
        Thread.sleep(50);                          // 没抢到,稍后重试
        return getWithMutex(key);                  // 递归/循环
    }
}
return val;

4.3 缓存雪崩 ​

问题:大量 key 同时过期(或 Redis 宕机),请求全部打到 DB,DB 崩溃 → 级联故障。

解法:

  1. 过期时间加随机值:expire = 300 + random(0, 300),打散过期时刻。
  2. 多级缓存:本地缓存(Caffeine)+ Redis。
  3. 高可用:Redis 主从/哨兵/集群,避免单点。
  4. 限流降级:DB 前加限流,或返回降级数据。
java
// 过期时间加随机
long expire = 300 + ThreadLocalRandom.current().nextInt(300);   // 300~600s
redis.set(key, val, expire, TimeUnit.SECONDS);

三大问题对比 ​

问题触发条件核心解法
穿透查不存在的数据缓存空值 / 布隆过滤器
击穿单个热点 key 过期互斥锁 / 逻辑过期
雪崩大量 key 同时过期 / Redis 挂随机过期 / 多级缓存 / 高可用

五、缓存一致性 ​

数据库与缓存的强一致很难,通常追求最终一致:

  • 先更新 DB,再删除缓存(Cache-Aside),并给缓存设过期时间兜底。
  • 强一致要求高时:binlog 订阅(Canal)+ 消息队列异步删缓存。
  • 极端场景接受短暂不一致。

六、最佳实践 ​

  • 缓存必须设过期时间(除非是持久数据),避免内存耗尽。
  • 缓存空值防穿透,过期加随机防雪崩,互斥锁防击穿。
  • 大 key 拆分:一个 key 的 value 别超过几 KB,避免阻塞单线程。
  • 热 key 本地缓存:极端热点用 Caffeine 挡在应用层。
  • 监控命中率、内存、慢查询,maxmemory-policy 设为 allkeys-lru。
  • 缓存只是加速,DB 才是数据源,别把缓存当唯一存储。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. Redis 五大数据结构各适合什么场景?ZSet 为什么用跳表而不是红黑树?
  2. 缓存穿透、击穿、雪崩各是什么?分别给出至少两种解法。
  3. 为什么缓存更新推荐「删除」而不是「更新」?延迟双删解决什么问题?
  4. 用 Java + Redis 写一个防缓存击穿的「互斥锁重建缓存」方法。

本节小结

Redis 的核心是内存 + 丰富数据结构:String/Hash/List/Set/ZSet 各有底层编码(SDS、listpack、跳表)。缓存读写遵循 Cache-Aside(先更新 DB 再删缓存);三大问题——穿透(缓存空值/布隆过滤器)、击穿(互斥锁/逻辑过期)、雪崩(随机过期/多级缓存/高可用)。记住:缓存是加速,DB 才是真理。