Skip to content

第七章 常见应用场景实战

前面几章你已经学会了 Redis 的数据类型和基本命令。从本章开始,我们聚焦真实项目里怎么用 Redis:缓存怎么防坑、锁怎么用、Session 怎么共享、接口怎么限流。这些也是面试里常被问到的「实战题」。


7.1 缓存穿透

问题描述

缓存穿透指:业务要查的数据在数据库里也不存在,缓存里自然也没有。于是每次请求都会「穿透」缓存,直接打到数据库。

客户端                    Redis 缓存              数据库
   |                          |                      |
   |-- 查 id=99999 商品 ------>|  miss (无此 key)     |
   |                          |--------------------->|  查询
   |                          |                      |  无结果
   |<-------------------------|<---------------------|
   |
   (恶意或异常请求可反复用「不存在的 id」轰炸数据库)

典型危害:数据库压力骤增,甚至被拖垮;若有人故意构造大量不存在的 key,相当于一种简单的攻击面。

解决方案

方案思路适用场景
缓存空值查库无结果时,仍往 Redis 写一条短 TTL 的占位(如空 JSON、NULL实现简单,适合大多数业务
布隆过滤器请求先到布隆过滤器判断「可能存在吗」,不可能则直接返回,不打库数据量大、可接受一定误判(误判为存在时仍会查库)

二者可组合:布隆过滤器挡掉大部分非法 key,少量漏网之鱼再用空值缓存兜底。

示例 / 伪代码

方案 A:缓存空值

text
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:布隆过滤器(概念)

text
// 初始化:把所有合法 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」)

text
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)   // 或循环读缓存若干次

逻辑过期(示意)

text
// 值结构: { "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 本身不可用,导致请求几乎全部落到数据库,数据库压力陡增甚至宕机。

常见诱因:

  1. 大量 key 使用相同或相近的 TTL,同一时刻集体过期。
  2. Redis 宕机、网络分区,整层缓存不可用。
  3. 与「击穿」关系:雪崩往往是很多 key整实例的问题;击穿是单个热点 key 的问题。
        正常:大部分请求命中 Redis
              |
              v
        [ 集体过期 / Redis 挂 ]
              |
              v
        几乎所有请求 --> 数据库
              |
              v
        数据库过载 / 宕机  -->  业务雪崩

解决方案

方案作用
随机过期时间TTL = 基础值 + random(0, 300) 秒,避免同一秒过期
集群 / 主从 / 哨兵提高 Redis 可用性,单点故障影响面缩小
限流、降级、熔断保护数据库:排队、返回默认值、关闭非核心功能
多级缓存本地缓存 + Redis,减轻对 Redis 与 DB 的依赖(架构更重)

示例 / 伪代码

随机 TTL

text
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:设置过期时间(秒),防止进程崩溃导致锁永远不释放。
text
SET lock:order:123 <唯一值> NX EX 30
  • 返回 OK:拿到锁。
  • 返回 (nil):未拿到锁。

注意事项

要点说明
唯一 value每个持有者用 UUID 等唯一串;释放时只有 value 匹配才删除,防止误删别人的锁
过期时间要大于「临界区」典型执行时间,并考虑时钟与 GC 停顿;过短可能锁提前失效
释放锁原子性不能用「GET 比对再 DEL」两条命令(中间可能被别的客户端插队),应使用 Lua 脚本 一次执行「比较 value + DEL」

Lua 释放锁(逻辑示意)

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
end

Redlock(简要提及)

单机 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 是常见选择(低延迟、支持过期)。

大致流程

  1. 用户登录成功,生成 sessionId(如 Cookie 中的 JSESSIONID)。
  2. 将 Session 内容序列化后写入 Redis,key 形如 spring:session:sessions:<id>(具体前缀因框架而异)。
  3. 后续请求带上 Cookie,任意一台应用根据 id 从 Redis 读取 Session,与哪台机器无关

Spring Session + Redis(简要说明)

在 Spring Boot 项目中:

  • 引入 Spring Session Data RedisRedis 依赖。
  • 配置 Redis 连接。
  • 使用 @EnableRedisHttpSession 或等效自动配置。

效果:HttpSession 的读写由框架代理到 Redis,开发者仍可用 session.setAttribute / getAttribute,多台实例自动共享登录状态。

text
// 伪配置思路(具体依赖版本以官方文档为准)
// 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:请求时间戳(毫秒或秒,全程统一即可)。

每次请求:

  1. ZREMRANGEBYSCORE 删掉窗口以外的记录(score < now - window)。
  2. ZCARD 看当前窗口内数量;若 ≥ 限额则拒绝。
  3. ZADD 加入本次请求,EXPIRE 设置 key 过期(略大于窗口即可,防止冷 key 常驻)。

redis-cli 命令示例

以下用秒级时间戳,窗口 60 秒,限额 5 次(演示用小数字)。

text
# 变量示意(实际在脚本/程序里拼接)
# 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

伪代码

text
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 脚本在服务端一次执行多条逻辑,保证原子性——第七章里释放分布式锁、限流脚本都会与这些内容紧密衔接。我们下一章见。

坚持是一种品格