第七章 常见应用场景实战
前面几章你已经学会了 Redis 的数据类型和基本命令。从本章开始,我们聚焦真实项目里怎么用 Redis:缓存怎么防坑、锁怎么用、Session 怎么共享、接口怎么限流。这些也是面试里常被问到的「实战题」。
7.1 缓存穿透
问题描述
缓存穿透指:业务要查的数据在数据库里也不存在,缓存里自然也没有。于是每次请求都会「穿透」缓存,直接打到数据库。
客户端 Redis 缓存 数据库
| | |
|-- 查 id=99999 商品 ------>| miss (无此 key) |
| |--------------------->| 查询
| | | 无结果
|<-------------------------|<---------------------|
|
(恶意或异常请求可反复用「不存在的 id」轰炸数据库)典型危害:数据库压力骤增,甚至被拖垮;若有人故意构造大量不存在的 key,相当于一种简单的攻击面。
解决方案
| 方案 | 思路 | 适用场景 |
|---|---|---|
| 缓存空值 | 查库无结果时,仍往 Redis 写一条短 TTL 的占位(如空 JSON、NULL) | 实现简单,适合大多数业务 |
| 布隆过滤器 | 请求先到布隆过滤器判断「可能存在吗」,不可能则直接返回,不打库 | 数据量大、可接受一定误判(误判为存在时仍会查库) |
二者可组合:布隆过滤器挡掉大部分非法 key,少量漏网之鱼再用空值缓存兜底。
示例 / 伪代码
方案 A:缓存空值
function getProduct(id):
cacheKey = "product:" + id
cached = redis.GET(cacheKey)
if cached == "__NULL__": // 约定:表示「已确认不存在」
return null
if cached != null:
return deserialize(cached)
row = db.query("SELECT * FROM product WHERE id = ?", id)
if row == null:
// 空值也要缓存,但 TTL 要短,避免长期占内存、也避免以后真有数据却读不到
redis.SETEX(cacheKey, 60, "__NULL__")
return null
redis.SETEX(cacheKey, 3600, serialize(row))
return row方案 B:布隆过滤器(概念)
// 初始化:把所有合法 id(或全表主键)加入布隆过滤器
bloom.add(all_valid_ids)
function getProduct(id):
if not bloom.mightContain(id):
return null // 高概率不存在,直接返回,不打库不打缓存
// 仍可能误判为存在,后面走正常缓存 + 数据库逻辑
...Redis 4.0+ 可通过 RedisBloom 模块使用布隆过滤器;没有模块时可在应用内用 Guava、Redisson 等实现。
7.2 缓存击穿
问题描述
缓存击穿针对的是单个热点 key:该 key 存在过且很热门,但在某一时刻过期了。过期瞬间,大量并发请求同时发现缓存未命中,一齐涌向数据库去查同一条数据。
时间线:
... 热点 key 有效 ...
|
v
[key 过期] <-- 临界点
|
+----+----+----+----+
| | | | | 大量线程同时 miss
v v v v v
数据库(同一查询被放大 N 倍)与「穿透」的区别:击穿是「曾经有、过期了」;穿透是「本来就没有,永远查不到」。
解决方案
| 方案 | 思路 | 注意点 |
|---|---|---|
| 互斥锁(单飞) | 只有一个线程去回源,其它线程短暂等待或重试读缓存 | 需设置锁超时,避免死锁 |
| 逻辑过期 | Redis 里不依赖 TTL 删数据,而是在值里带「逻辑过期时间」,过期后异步刷新,读时仍可返回旧值 | 可能短暂读到旧数据,适合可容忍最终一致的热点 |
永不过期 + 后台更新也可视为逻辑过期的一种变体。
示例 / 伪代码
互斥锁(推荐理解用「SET NX EX」)
function getHotProduct(id):
cacheKey = "product:hot:" + id
lockKey = "lock:product:hot:" + id
val = redis.GET(cacheKey)
if val != null:
return deserialize(val)
// 尝试抢锁,只有抢到的人去查库
if redis.SET(lockKey, requestId, "NX", "EX", 10):
try:
row = db.query(...)
redis.SETEX(cacheKey, 3600, serialize(row))
return row
finally:
// 释放锁须校验 value,见 7.4,这里用 Lua 更安全
releaseLock(lockKey, requestId)
else:
// 未抢到锁:稍等再读缓存,或有限次自旋
sleep(50ms)
return getHotProduct(id) // 或循环读缓存若干次逻辑过期(示意)
// 值结构: { "data": {...}, "expireAt": 1710000000 }
function getHotProductLogical(id):
raw = redis.GET(cacheKey)
obj = json.parse(raw)
now = currentTimeSeconds()
if obj.expireAt > now:
return obj.data
// 已逻辑过期:先返回旧数据(可选),同时只让一个请求去异步刷新
if tryAcquireRefreshLock():
async refreshFromDb(id)
return obj.data // 用户仍拿到旧值,数据库不被瞬时打爆7.3 缓存雪崩
问题描述
缓存雪崩指大面积缓存同时失效或 Redis 本身不可用,导致请求几乎全部落到数据库,数据库压力陡增甚至宕机。
常见诱因:
- 大量 key 使用相同或相近的 TTL,同一时刻集体过期。
- Redis 宕机、网络分区,整层缓存不可用。
- 与「击穿」关系:雪崩往往是很多 key 或整实例的问题;击穿是单个热点 key 的问题。
正常:大部分请求命中 Redis
|
v
[ 集体过期 / Redis 挂 ]
|
v
几乎所有请求 --> 数据库
|
v
数据库过载 / 宕机 --> 业务雪崩解决方案
| 方案 | 作用 |
|---|---|
| 随机过期时间 | TTL = 基础值 + random(0, 300) 秒,避免同一秒过期 |
| 集群 / 主从 / 哨兵 | 提高 Redis 可用性,单点故障影响面缩小 |
| 限流、降级、熔断 | 保护数据库:排队、返回默认值、关闭非核心功能 |
| 多级缓存 | 本地缓存 + Redis,减轻对 Redis 与 DB 的依赖(架构更重) |
示例 / 伪代码
随机 TTL
baseTtl = 3600 // 1 小时
jitter = randomInt(0, 300) // 0~5 分钟随机
redis.SETEX(cacheKey, baseTtl + jitter, value)限流(与 7.6 呼应):在网关或应用入口对「回源」或「全站读」做限流,雪崩时至少保住数据库不被瞬间打死。
7.4 分布式锁
问题描述
在多台应用服务器上,要避免同一时刻多个进程执行互斥任务(例如只让一个实例去刷新缓存、扣库存),需要分布式锁。Redis 因单线程执行命令、性能高,常被用来实现锁。
解决方案:SET key value NX EX
核心命令语义:
- NX:仅当 key 不存在时才设置成功(互斥)。
- EX:设置过期时间(秒),防止进程崩溃导致锁永远不释放。
SET lock:order:123 <唯一值> NX EX 30- 返回
OK:拿到锁。 - 返回
(nil):未拿到锁。
注意事项
| 要点 | 说明 |
|---|---|
| 唯一 value | 每个持有者用 UUID 等唯一串;释放时只有 value 匹配才删除,防止误删别人的锁 |
| 过期时间 | 要大于「临界区」典型执行时间,并考虑时钟与 GC 停顿;过短可能锁提前失效 |
| 释放锁原子性 | 不能用「GET 比对再 DEL」两条命令(中间可能被别的客户端插队),应使用 Lua 脚本 一次执行「比较 value + DEL」 |
Lua 释放锁(逻辑示意)
-- KEYS[1] = lock key, ARGV[1] = 加锁时的唯一 value
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
endRedlock(简要提及)
在单机 Redis 上,主从异步复制时,极端情况下可能出现锁在 master 上建立后 master 宕机、slave 尚未同步而丢锁的问题。Redlock 是 Redis 官方文档中描述的一种在多独立 Redis 节点上投票多数通过才算加锁的算法,用于提高在多节点场景下的安全性;实现复杂,生产环境常用 Redisson 等库封装,并需理解其适用边界与争议(网络延迟、时钟等)。入门阶段掌握 SET NX EX + 唯一值 + Lua 释放 即可应对大部分面试与简单场景。
7.5 Session 共享
问题描述
用户登录后,Session 默认存在单台应用服务器内存中。若使用负载均衡把请求打到多台机器,会出现:
- 第一次请求在 A 机登录,Session 在 A;
- 下一次请求到 B 机,B 没有 Session → 被认为未登录。
用户
|
v
[ 负载均衡 ]
/ | \
v v v
应用A 应用B 应用C
Session 只在 A 上 --> 需要「共享」Session 存储解决方案
把 Session 外置到所有应用都能访问的存储:Redis 是常见选择(低延迟、支持过期)。
大致流程:
- 用户登录成功,生成
sessionId(如 Cookie 中的JSESSIONID)。 - 将 Session 内容序列化后写入 Redis,key 形如
spring:session:sessions:<id>(具体前缀因框架而异)。 - 后续请求带上 Cookie,任意一台应用根据 id 从 Redis 读取 Session,与哪台机器无关。
Spring Session + Redis(简要说明)
在 Spring Boot 项目中:
- 引入 Spring Session Data Redis 与 Redis 依赖。
- 配置 Redis 连接。
- 使用
@EnableRedisHttpSession或等效自动配置。
效果:HttpSession 的读写由框架代理到 Redis,开发者仍可用 session.setAttribute / getAttribute,多台实例自动共享登录状态。
// 伪配置思路(具体依赖版本以官方文档为准)
// 1. 依赖: spring-session-data-redis, spring-boot-starter-data-redis
// 2. application.yml: spring.redis.host / port
// 3. @EnableRedisHttpSession // 或在 Spring Boot 3 中使用对应的 RedisSession 配置类小结:Session 共享的本质是 「会话状态中心化」;Redis 负责存、负责过期(与业务 Session 超时一致)。
7.6 接口限流(滑动窗口)
问题描述
需要限制每个用户(或每个 IP)在一段时间窗口内的请求次数,例如「每分钟最多 60 次」。滑动窗口相对固定窗口更平滑:任意连续 1 分钟内的请求数都不超过阈值。
解决方案:Sorted Set(ZSET)
思路:
- Key:例如
rate:user:10086。 - Member:每次请求可用唯一 id(如 UUID)或「时间戳+随机串」,避免 member 冲突。
- Score:请求时间戳(毫秒或秒,全程统一即可)。
每次请求:
- ZREMRANGEBYSCORE 删掉窗口以外的记录(
score < now - window)。 - ZCARD 看当前窗口内数量;若 ≥ 限额则拒绝。
- ZADD 加入本次请求,EXPIRE 设置 key 过期(略大于窗口即可,防止冷 key 常驻)。
redis-cli 命令示例
以下用秒级时间戳,窗口 60 秒,限额 5 次(演示用小数字)。
# 变量示意(实际在脚本/程序里拼接)
# now = 当前 Unix 秒
# window = 60
# limit = 5
# key = rate:demo:1
# 1) 清除 60 秒之前的记录
ZREMRANGEBYSCORE rate:demo:1 0 <now-60>
# 2) 查看当前窗口内数量
ZCARD rate:demo:1
# 3) 若 ZCARD < 5,则允许并记录本次请求(member 需唯一)
ZADD rate:demo:1 <now> <唯一member例如 uuid>
EXPIRE rate:demo:1 120伪代码
function allowRequest(userId):
key = "rate:user:" + userId
now = nowSeconds()
window = 60
limit = 100
redis.ZREMRANGEBYSCORE(key, 0, now - window)
count = redis.ZCARD(key)
if count >= limit:
return false
redis.ZADD(key, now, now + ":" + randomUuid())
redis.EXPIRE(key, window * 2)
return true说明:高并发下可对同一用户用 Lua 脚本把「删旧、计数、ZADD、EXPIRE」打包成原子操作,避免竞态。生产环境也可结合 令牌桶 / 漏桶(Redis Cell 模块等)做更精细的限流。
本章小结
| 主题 | 核心问题 | 主要手段 | 与相邻概念区分 |
|---|---|---|---|
| 缓存穿透 | 查不存在的数据,反复打库 | 空值缓存、布隆过滤器 | 数据库里也没有 |
| 缓存击穿 | 热点 key 过期瞬间并发打库 | 互斥锁、逻辑过期 | 单个热点、曾存在 |
| 缓存雪崩 | 大量 key 同时失效或 Redis 不可用 | 随机 TTL、高可用、限流降级 | 面上的问题 |
| 分布式锁 | 多实例互斥 | SET NX EX、唯一值、Lua 释锁 | Redlock 多节点进阶 |
| Session 共享 | 多机登录态一致 | Redis 存 Session;Spring Session | 状态外置 |
| 滑动窗口限流 | 平滑限制调用频率 | ZSET + 时间戳 score | 可 Lua 原子化 |
记忆口诀:穿透是「没有还查」,击穿是「热点过期」,雪崩是「一起挂」。
下一章预告
第八章:事务与 Lua 脚本
你将学习 Redis 中 MULTI / EXEC / WATCH 的事务模型(与关系型数据库事务的差异)、乐观锁思路,以及如何用 Lua 脚本在服务端一次执行多条逻辑,保证原子性——第七章里释放分布式锁、限流脚本都会与这些内容紧密衔接。我们下一章见。