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。
解法:
- 缓存空值:DB 查不到也缓存一个空(短过期时间,如 60s)。
- 布隆过滤器:请求前用布隆过滤器判断 key 是否可能存在,不存在直接拒绝。
- 参数校验:非法参数(负数 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。
解法:
- 互斥锁:只让一个线程去查 DB 重建缓存,其余等待。
- 热点 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 崩溃 → 级联故障。
解法:
- 过期时间加随机值:
expire = 300 + random(0, 300),打散过期时刻。 - 多级缓存:本地缓存(Caffeine)+ Redis。
- 高可用:Redis 主从/哨兵/集群,避免单点。
- 限流降级: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 才是数据源,别把缓存当唯一存储。
📚 官方文档与参考资料
✏️ 练习与自测
- Redis 五大数据结构各适合什么场景?ZSet 为什么用跳表而不是红黑树?
- 缓存穿透、击穿、雪崩各是什么?分别给出至少两种解法。
- 为什么缓存更新推荐「删除」而不是「更新」?延迟双删解决什么问题?
- 用 Java + Redis 写一个防缓存击穿的「互斥锁重建缓存」方法。
本节小结
Redis 的核心是内存 + 丰富数据结构:String/Hash/List/Set/ZSet 各有底层编码(SDS、listpack、跳表)。缓存读写遵循 Cache-Aside(先更新 DB 再删缓存);三大问题——穿透(缓存空值/布隆过滤器)、击穿(互斥锁/逻辑过期)、雪崩(随机过期/多级缓存/高可用)。记住:缓存是加速,DB 才是真理。