阶段二 · 进阶数据结构与应用场景

典型应用场景

缓存策略、分布式锁、限流、消息队列 —— 用 Redis 解决真实系统问题的四把钥匙

Redis 学习笔记 · 第 05 篇 · 大纲 · 前置:第 04 篇

总览:数据结构 → 场景映射

前四篇积累了"有哪些武器"(String、List、Hash、Set、ZSet、Bitmap、HLL、GEO、Stream),这一篇聚焦"怎么用它们打赢一场仗"。对于每个场景,关注三个问题:

  1. 为什么需要——不用 Redis 会怎样?
  2. 怎么实现——用哪些数据结构、哪些命令?
  3. 有什么坑——边界条件、故障场景怎么处理?
场景核心数据结构一句话描述
缓存String、Hash用内存速度换数据库压力,加速读取
分布式锁String(SET NX PX)+ Lua多实例间互斥访问共享资源
限流String、ZSet + Lua控制请求速率,保护下游系统
消息队列List → Pub/Sub → Stream异步解耦,削峰填谷

1. 缓存策略(Cache Strategy)

缓存是 Redis 最广泛的应用。核心问题不是"怎么存",而是"缓存和数据库之间怎么保持一致性"——读写顺序、失效策略、异常兜底,每一步选错了都可能导致数据错乱。

1.1 Cache Aside(旁路缓存)—— 最常用的模式

Cache Aside 的核心思想:缓存是"附属品",数据库是唯一真相源。应用层自己管理缓存逻辑,Redis 和 DB 之间没有自动同步机制。

读流程

1. 先查缓存 → 命中直接返回
2. 缓存 miss → 查数据库
3. 将数据库结果写入缓存 + 设过期时间
4. 返回结果
def get_user(user_id):
    # 1. 查缓存
    data = redis.get(f"user:{user_id}")
    if data:
        return data

    # 2. 缓存 miss,查 DB
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    if not user:
        return None

    # 3. 回填缓存 + 设置过期时间
    redis.setex(f"user:{user_id}", 3600, user)

    return user

写流程 —— 为什么是"先DB后删缓存"?

更新数据时,有两种顺序可选:

顺序问题
❌ 先删缓存,再更新 DB删缓存后、DB 更新前,另一个请求读到旧数据并回填缓存 → 缓存里永远是旧数据
✅ 先更新 DB,再删缓存更新 DB 后、删缓存前,另一个请求读到旧缓存(短暂不一致)。一旦缓存被删,下次读就会拿到新数据。
先删缓存再写 DB,就像先把黑板擦了你才开始翻书找答案——这期间别人看到的是一块空黑板,随手写上错误答案。先写 DB 再删缓存,就像先把正确答案记在笔记本上,然后才擦黑板——别人最多看一眼旧笔记,等你擦完下次就会看到新的。

为什么是"删"而不是"更新"缓存?

更新缓存看似直接,但存在两个问题:① 如果缓存是计算结果(如聚合数据),更新代价高;② 并发写操作可能导致缓存与 DB 不一致。删除缓存让下次读操作触发一次重新计算和回填,反而更安全——这就是 Lazy 加载思想。

延迟双删(极端一致性场景)

在"先更新 DB 再删缓存"的基础上,再加一次延迟删除来兜底:

1. 删缓存
2. 更新 DB
3. 等一小段时间(如 500ms)
4. 再次删缓存   ← 兜底:防止第 1-2 步之间其他请求回填了旧数据

这个方案降低了不一致的时间窗口但无法彻底消除,且增加了写操作的延迟。绝大多数业务场景先更新 DB 再删缓存就足够了。

1.2 Read/Write Through(代理模式)

应用不直接操作 DB 和缓存,而是通过一个缓存抽象层来读写。缓存层负责同步逻辑,应用侧代码简化。

模式
Read Through缓存 miss 时,缓存层查 DB 并回填,应用无感知
Write Through应用写缓存,缓存层同步写 DB,两次写都成功才返回

适用场景

数据读取频繁、写入后需要立即可见、对一致性要求较高的场景。代价是写入延迟变高(同步写 DB),且 Redis 本身不提供此能力——需要应用层封装或使用第三方缓存框架。

1.3 Write Behind(异步写回)

应用只写缓存(Redis),缓存层异步批量刷入数据库。写入延迟极低,但有数据丢失风险。

Write Behind 就像先记在便利贴上(Redis),每隔一段时间把便利贴上的内容整理到笔记本(DB)里。好处是写字飞快,坏处是如果便利贴丢了,那段记录就没了。
对比维度Cache AsideWrite Behind
写延迟写 DB(高)写 Redis(低)
数据安全高(DB 是同步的)低(Redis 挂了丢数据)
实现复杂度高(需要异步刷盘 + 去重 + 合并)
典型场景通用 Web 应用高并发写入(如秒杀扣库存)

1.4 缓存三大问题(概要)

这三个问题在实际生产中极其常见,这里先建立概念框架,第 12 篇会深入展开具体解决方案。

问题现象根因解决方向
缓存穿透查一个不存在的数据,缓存和 DB 都没有 → 每次请求都穿到 DB恶意攻击或业务 bug 查询不存在的 key① 布隆过滤器拦截 ② 缓存空值(短 TTL)
缓存击穿某个热点 key 过期瞬间,大量请求同时打到 DB热点 key 过期 + 高并发读① 互斥锁(只让一个请求回源)② 逻辑过期(异步刷新)
缓存雪崩大量 key 同时过期或 Redis 宕机,DB 瞬间被压垮大批缓存集中在同一时段失效① TTL 加随机偏移 ② 多级缓存 ③ 熔断降级

2. 分布式锁(Distributed Lock)

单机环境下,synchronizedReentrantLock 能保证线程安全。但在多实例部署时,每个实例有自己的 JVM/进程,单机锁失效——需要一把所有实例都能看到的分布式锁

2.1 SET NX PX:最基础的方案

核心思想:利用 Redis 单线程模型的原子性,谁先 SET 成功谁就拿到锁。

# 获取锁:NX = 不存在才设置,PX = 毫秒级过期
SET lock:order:1001 unique_token NX PX 30000
→ OK          ← 拿到锁
→ (nil)       ← 别人已经持有

为什么是 SET ... NX PX 而不是 SETNX + EXPIRE 分两步?

原子性是关键

如果 SETNX 之后、EXPIRE 之前进程崩溃了,这把锁永远无法释放(死锁)。SET key value NX PX 30000 把"加锁"和"设过期"合并为一个原子操作,这是 Redis 2.6.12 引入这个语法的主要原因。

2.2 安全释放与锁续期

为什么不能直接 DEL?

假设 A 拿到锁,业务执行超时,锁自动过期释放。B 拿到锁开始执行。此时 A 恢复,执行 DEL 把 B 的锁删了——A 释放了别人的锁

A: SET lock:order NX PX 30000 → OK   ← A 拿锁
A: 业务执行中...超过了 30s...
   锁自动过期!                         ← A 的锁没了
B: SET lock:order NX PX 30000 → OK   ← B 拿到锁
A: DEL lock:order                     ← A 把 B 的锁删了!❌

解法:释放时校验 value。SET 的时候放一个唯一 token,释放时用 Lua 脚本比对 token,一致才删:

-- 安全释放锁:先比对 token,一致才删
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

这个 Lua 脚本也是原子的——GET + DEL 之间不会被其他命令插入。

锁续期(Watch Dog)

如果业务执行时间不确定,设置一个固定过期时间可能不够。常见方案是启动一个后台续期线程

拿到锁(过期 30s)
  ↓
启动后台线程:每 10s 检查一次
  ├─ 锁还存在 + 业务还在跑 → EXPIRE 续期到 30s
  └─ 业务结束 → 停止续期 + 释放锁

Redisson 等 Java 客户端内置了此机制(叫 "watch dog"),无需手动实现。

2.3 Redlock:多节点分布式锁算法

单实例 Redis 做主从时存在风险:A 在主节点拿到锁,主节点挂了但锁数据还没同步到从节点。从节点提升为新主,B 又在新主上拿到同一把锁——两个实例同时认为自己持有锁

Redlock 算法(Redis 作者 antirez 提出)通过在 N 个独立 Redis 节点上分别加锁来解决:

1. 客户端获取当前时间戳 t1
2. 依次向 N 个 Redis 节点请求加锁(SET NX PX,超时时间远小于锁过期时间)
3. 如果 ≥ N/2+1 个节点加锁成功(且总耗时 < 锁有效期)
   → 锁获取成功,有效时间 = 锁过期时间 - 总耗时
4. 否则向所有节点释放锁,重试

Redlock 的争议

分布式系统领域对 Redlock 的安全性存在争议(Martin Kleppmann 指出时钟跳跃等极端场景下 Redlock 可能失效)。实际工程中:① 如果对一致性要求极高,考虑 ZooKeeper 或 etcd 的 CP 锁;② 如果只是"大概率互斥"(如防止重复提交),单实例 Redis 锁 + 合理过期就够用。

2.4 常见陷阱

陷阱后果对策
忘记设过期时间死锁,其他实例永远拿不到永远带上 PX/EX
直接 DEL 释放释放了别人的锁Lua 脚本 + token 校验
过期时间太短锁过期业务还在跑,数据错乱保守估算 + watch dog 续期
不可重入同一线程重复获取被自己阻塞客户端实现可重入(本地计数 + 锁 value 存线程标识)
主从切换丢锁两个实例同时拿到锁Redlock / 接受小概率冲突 / 换 ZK

3. 限流(Rate Limiting)

限流的本质是控制单位时间内的请求次数。保护下游服务不被突发流量打垮,防止单个用户滥用资源。Redis 天然适合做限流计数器——内存操作够快,过期机制自动清理。

3.1 固定窗口(Fixed Window)

最简单的方案:以自然时间窗口(如 1 分钟)为界,窗口内计数。

# key = rate:{user_id}:{window_timestamp}
# 每个时间窗口内最多 10 次请求

INCR rate:user_1001:1717200000     # window = 当前分钟的时间戳
EXPIRE rate:user_1001:1717200000 120   # 过期 = 窗口的 2 倍保证清理

if value > 10:
    reject request

也可以一步完成(利用 SET NX 初始化 + INCR):

-- Lua 原子化:初始化 + 递增 + 判断
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call("INCR", key)
if current == 1 then
    redis.call("EXPIRE", key, window)
end
if current > limit then
    return 0   -- 限流
end
return 1       -- 放行

固定窗口的"边界突变"问题

假设限制每分钟 10 次。用户在 12:00:59 发了 10 个请求,12:01:00 又发了 10 个请求。两个请求在时间上只差 1 秒,但因为跨了窗口边界,全部放行了——实际 2 秒内通过了 20 个请求。这是固定窗口最大的缺陷。

3.2 滑动窗口(Sliding Window)—— ZSet 方案

不再按整点分界,而是以当前时间为终点,回看过去 N 秒的请求数

-- ZSet 滑动窗口限流(原子 Lua)
-- KEYS[1]: rate zset key
-- ARGV[1]: limit (窗口内最大请求数)
-- ARGV[2]: window (窗口时长,秒)
-- ARGV[3]: now (当前毫秒时间戳,保证唯一)
-- ARGV[4]: member (now + 随机数,作为 member 保证不重复)

redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, ARGV[3] - ARGV[2] * 1000)
local count = redis.call("ZCARD", KEYS[1])
if count >= tonumber(ARGV[1]) then
    return 0   -- 限流
end
redis.call("ZADD", KEYS[1], ARGV[3], ARGV[4])
redis.call("EXPIRE", KEYS[1], ARGV[2] + 1)
return 1
固定窗口像是在日历上画格子,到了新的一格计数器清零。滑动窗口像是用一个滚动的 N 秒时间框罩住时间轴,每来一个请求就朝框里看一眼——框外的自动滑出去,框内的超过阈值就拦截。
对比固定窗口滑动窗口(ZSet)
精度有边界突变平滑精确
内存极低(一个计数器)与请求量成正比(每次请求记一个 member)
性能O(1)O(log N + M)(ZREMRANGEBYSCORE + ZCARD + ZADD)
适用粗粒度限流,精度要求低需要精确控制速率的场景

3.3 令牌桶(Token Bucket)

令牌桶与滑动窗口的思路不同:不是统计"过去发了多少",而是控制"当前有没有令牌可用"。令牌以固定速率生成存入桶中,请求来时必须拿到一枚令牌才能放行。

令牌桶就像食堂排队打饭——师傅每分钟做 10 份菜(令牌生成速率),窗口前排 10 个盘子(桶容量)。菜做好了放盘子上,有人来了拿一盘走。如果连续来 100 个人,前 10 个能吃到,后面的就得等师傅再做。突发流量被桶容量兜住了,平稳输出速率由生成速率控制。
-- 令牌桶限流(原子 Lua)
-- KEYS[1]: 令牌桶 key
-- ARGV[1]: rate(令牌生成速率,个/秒)
-- ARGV[2]: capacity(桶最大容量)
-- ARGV[3]: now(当前时间戳,秒)
-- ARGV[4]: requested(本次请求需要的令牌数,默认 1)

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 上次填充时间 & 当前令牌数(用 Hash 存储两个字段)
local last_time = tonumber(redis.call("HGET", KEYS[1], "last_time")) or now
local tokens = tonumber(redis.call("HGET", KEYS[1], "tokens")) or capacity

-- 计算自上次填充以来新生成的令牌
local elapsed = math.max(0, now - last_time)
local new_tokens = elapsed * rate
tokens = math.min(capacity, tokens + new_tokens)
local last_time = now

-- 判断是否够用
if tokens >= requested then
    tokens = tokens - requested
    redis.call("HSET", KEYS[1], "tokens", tokens, "last_time", last_time)
    redis.call("EXPIRE", KEYS[1], math.ceil(capacity / rate) * 2 + 10)
    return 1   -- 放行
else
    redis.call("HSET", KEYS[1], "tokens", tokens, "last_time", last_time)
    return 0   -- 限流
end

令牌桶的优势:允许一定的突发流量(桶里积攒的令牌可一次性消耗),但长期速率被严格控制。API 网关(如 Nginx 的 ngx_http_limit_req_module)和云服务商的限流大多基于令牌桶或其变体。

3.4 三种方案对比

维度固定窗口滑动窗口令牌桶
核心思想窗口内计数过去 N 秒内的请求数桶中是否有令牌
Redis 结构String(计数器)ZSetHash(2 字段)
精度低(边界突变)中(取决于时间精度)
支持突发✅(桶容量决定突发上限)
内存占用极低与请求量正比极低
实现复杂度
典型场景简单频控精确 QPS 控制允许突发 + 平稳输出的场景

4. 消息队列(Redis as Message Queue)

第 04 篇已经详细讲过 Stream 的命令和消费者组机制。这里从方案演进选型决策两个维度来梳理 Redis 做消息队列的全貌。

4.1 三种方案的演进

List(BLPOP)Pub/SubStream
消息模型队列(点对点)广播(一对多)消费者组 + 广播
消息确认❌ 消费即删除❌ 发出去就忘了✅ XACK
消息持久化消费即删❌ 无持久化✅ 消息保留
消费者离线消息堆积在 List消息直接丢失消息保留,重连后可续读
适用场景临时任务队列实时通知/广播可靠消息队列

Pub/Sub 的特殊之处——它是唯一支持"一发多收"广播的模式,但也最不可靠:

# 终端1: 订阅频道
SUBSCRIBE order:notify
Reading messages... (press Ctrl-C to quit)

# 终端2: 发布消息
PUBLISH order:notify "New order: 1001"
→ (integer) 1   ← 有 1 个订阅者收到

# 如果订阅者离线后再上线 → 离线期间的消息永远丢失

Pub/Sub 的正确用法:不依赖消息持久化的场景,如 WebSocket 服务内跨节点广播、配置热更新通知、实时聊天消息分发。

4.2 选型边界:什么时候该用 Kafka/RabbitMQ?

Stream 让 Redis 具备了"像 MQ 一样可靠"的能力,但它仍然受限于 Redis 的定位——内存数据库,不是专用消息中间件

评估维度Redis StreamRabbitMQKafka
吞吐量万级 QPS万级 QPS百万级 QPS
消息积压受内存限制可大量积压到磁盘海量磁盘存储
消费模型消费者组Direct/Topic/Fanout/Headers Exchange消费者组(Partition 级别)
顺序保证单 Stream 全局有序单 Queue 有序Partition 内有序
消息回溯✅ XRANGE❌(消费即删)✅ Offset 回退
运维复杂度低(Redis 自带)
典型定位轻中量可靠队列企业级消息路由大数据流式处理

决策口诀

① 已经用了 Redis → 消息量万级以下、对可靠性和积压要求不高 → Stream 够用,省掉一套 MQ 运维;② 需要复杂的路由规则(direct/topic/headers)→ RabbitMQ;③ 海量数据流式处理、多消费者回放历史数据 → Kafka。Redis Stream 是 "已经用 Redis 的团队的免费 MQ",但不是"专业的 MQ"。当消息开始成为系统的核心资产时,该上 MQ 就上。

快速回顾

场景核心模式/命令记忆要点
缓存 Cache Aside 先查缓存 → miss 查 DB → 回填缓存;先写 DB → 删缓存
Read/Write Through 缓存层代理读写,应用无感知
缓存三大问题 穿透(布隆/空值)→ 击穿(互斥锁/逻辑过期)→ 雪崩(随机 TTL/多级缓存)
分布式锁 SET key token NX PX 30000 原子加锁 + 设过期,token 区分持有者
Lua 释放脚本 GET 比 token → 一致才 DEL,防止误删别人的锁
Redlock N/2+1 节点加锁,防主从切换丢锁;有争议,按需选用
限流 固定窗口(String) INCR + EXPIRE,简单但有窗口边界突变
滑动窗口(ZSet) ZREMRANGEBYSCORE 清理 + ZCARD 计数,精确但内存与请求量正比
令牌桶(Hash + Lua) 按速率填充令牌,允许突发,长期速率恒定
消息队列 List + BLPOP 简单队列,消费即删,不可靠
Pub/Sub 广播模式,无持久化,离线丢消息
Stream + 消费者组 可靠消息队列:XACK / XPENDING / XCLAIM,细节见 04 篇

动手练习

  1. 分布式锁:用 SET lock:test <uuid> NX PX 30000 拿锁,用 Lua 脚本安全释放。在 redis-cli 中验证:① 正常加锁-释放 ② 拿不到锁时的行为 ③ token 不匹配时 DEL 被拒绝。
  2. 滑动窗口限流:用 ZSet 实现"每 10 秒最多 5 次请求"的限流器。模拟跨窗口边界的请求序列,对比固定窗口看看边界突变是否消失。
  3. 令牌桶:用 Hash + Lua 实现令牌桶,每 2 秒生成 1 个令牌,桶容量 10。观察连续请求 15 次时的通过/拒绝分布。
  4. Pub/Sub:开两个 redis-cli,一个 SUBSCRIBE,另一个 PUBLISH。然后关掉订阅者再发消息,重新上线——验证消息丢失现象。
  5. (思考题)缓存 Aside 模式下,如果"先更新 DB,再删除缓存"的第二步(删缓存)失败了,会有什么后果?怎样用消息队列来兜底?
  6. (思考题)分布式锁中,如果不设置过期时间会怎样?如果过期时间到了但业务还没执行完又该怎么办?这两个约束为什么是矛盾的,Watch Dog 如何折中?