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

典型应用场景:缓存 / 分布式锁 / 限流 / 消息队列

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

前四篇积累了"有哪些武器"(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 再删缓存 = 先把正确答案记在笔记本上, 然后才擦黑板 -- 别人最多看一眼旧笔记, 等你擦完下次就会看到新的.

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

更新缓存看似直接, 但存在两个问题:

  1. 如果缓存是计算结果(如聚合数据), 更新代价高
  2. 并发写操作可能导致缓存与 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 Aside Write 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 可能失效).

实际工程中:

  1. 如果对一致性要求极高, 考虑 ZooKeeper 或 etcd 的 CP 锁
  2. 如果只是"大概率互斥"(如防止重复提交), 单实例 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])

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(计数器) ZSet Hash(2 字段)
精度 低(边界突变) 中(取决于时间精度)
支持突发 ✅(桶容量决定突发上限)
内存占用 极低 与请求量正比 极低
实现复杂度
典型场景 简单频控 精确 QPS 控制 允许突发 + 平稳输出的场景

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

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

4.1 三种方案的演进

List(BLPOP) Pub/Sub Stream
消息模型 队列(点对点) 广播(一对多) 消费者组 + 广播
消息确认 ❌ 消费即删除 ❌ 发出去就忘了 ✅ 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 Stream RabbitMQ Kafka
吞吐量 万级 QPS 万级 QPS 百万级 QPS
消息积压 受内存限制 可大量积压到磁盘 海量磁盘存储
消费模型 消费者组 Direct/Topic/Fanout/Headers Exchange 消费者组(Partition 级别)
顺序保证 单 Stream 全局有序 单 Queue 有序 Partition 内有序
消息回溯 ✅ XRANGE ❌(消费即删) ✅ Offset 回退
运维复杂度 低(Redis 自带)
典型定位 轻中量可靠队列 企业级消息路由 大数据流式处理

决策口诀

  1. 已经用了 Redis → 消息量万级以下, 对可靠性和积压要求不高 → Stream 够用, 省掉一套 MQ 运维
  2. 需要复杂的路由规则(direct/topic/headers) → RabbitMQ
  3. 海量数据流式处理, 多消费者回放历史数据 → 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 如何折中?