总览: 数据结构 → 场景映射
前四篇积累了"有哪些武器"(String, List, Hash, Set, ZSet, Bitmap, HLL, GEO, Stream), 这一篇聚焦"怎么用它们打赢一场仗". 对于每个场景, 关注三个问题:
- 为什么需要 -- 不用 Redis 会怎样?
- 怎么实现 -- 用哪些数据结构, 哪些命令?
- 有什么坑 -- 边界条件, 故障场景怎么处理?
| 场景 | 核心数据结构 | 一句话描述 |
|---|---|---|
| 缓存 | 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 之间没有自动同步机制.
读流程
|
|
写流程 -- 为什么是"先DB后删缓存"?
更新数据时, 有两种顺序可选:
| 顺序 | 问题 |
|---|---|
| ❌ 先删缓存, 再更新 DB | 删缓存后, DB 更新前, 另一个请求读到旧数据并回填缓存 → 缓存里永远是旧数据 |
| ✅ 先更新 DB, 再删缓存 | 更新 DB 后, 删缓存前, 另一个请求读到旧缓存(短暂不一致). 一旦缓存被删, 下次读就会拿到新数据. |
先删缓存再写 DB = 先把黑板擦了才开始翻书找答案 -- 这期间别人看到的是一块空黑板, 随手写上错误答案.
先写 DB 再删缓存 = 先把正确答案记在笔记本上, 然后才擦黑板 -- 别人最多看一眼旧笔记, 等你擦完下次就会看到新的.
为什么是"删"而不是"更新"缓存?
更新缓存看似直接, 但存在两个问题:
- 如果缓存是计算结果(如聚合数据), 更新代价高
- 并发写操作可能导致缓存与 DB 不一致
删除缓存让下次读操作触发一次重新计算和回填, 反而更安全--这就是 Lazy 加载思想.
延迟双删(极端一致性场景)
在"先更新 DB 再删缓存"的基础上, 再加一次延迟删除来兜底:
|
这个方案降低了不一致的时间窗口但无法彻底消除, 且增加了写操作的延迟. 绝大多数业务场景先更新 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)
单机环境下, synchronized 或 ReentrantLock 能保证线程安全. 但在多实例部署时, 每个实例有自己的 JVM/进程, 单机锁失效--需要一把所有实例都能看到的分布式锁.
2.1 SET NX PX: 最基础的方案
核心思想: 利用 Redis 单线程模型的原子性, 谁先 SET 成功谁就拿到锁.
|
为什么是 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 释放了别人的锁.
|
解法: 释放时校验 value. SET 的时候放一个唯一 token, 释放时用 Lua 脚本比对 token, 一致才删:
|
这个 Lua 脚本也是原子的--GET + DEL 之间不会被其他命令插入.
锁续期(Watch Dog)
如果业务执行时间不确定, 设置一个固定过期时间可能不够. 常见方案是启动一个后台续期线程:
|
Redisson 等 Java 客户端内置了此机制(叫 "watch dog"), 无需手动实现.
2.3 Redlock: 多节点分布式锁算法
单实例 Redis 做主从时存在风险: A 在主节点拿到锁, 主节点挂了但锁数据还没同步到从节点. 从节点提升为新主, B 又在新主上拿到同一把锁--两个实例同时认为自己持有锁.
Redlock 算法(Redis 作者 antirez 提出)通过在 N 个独立 Redis 节点上分别加锁来解决:
|
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 分钟)为界, 窗口内计数.
|
也可以一步完成(利用 SET NX 初始化 + INCR):
|
假设限制每分钟 10 次. 用户在 12:00:59 发了 10 个请求, 12:01:00 又发了 10 个请求. 两个请求在时间上只差 1 秒, 但因为跨了窗口边界, 全部放行了--实际 2 秒内通过了 20 个请求. 这是固定窗口最大的缺陷.
3.2 滑动窗口(Sliding Window)-- ZSet 方案
不再按整点分界, 而是以当前时间为终点, 回看过去 N 秒的请求数.
|
固定窗口 = 在日历上画格子, 到了新的一格计数器清零.
滑动窗口 = 用一个滚动的 N 秒时间框罩住时间轴, 每来一个请求就朝框里看一眼--框外的自动滑出去, 框内的超过阈值就拦截.
| 对比 | 固定窗口 | 滑动窗口(ZSet) |
|---|---|---|
| 精度 | 有边界突变 | 平滑精确 |
| 内存 | 极低(一个计数器) | 与请求量成正比(每次请求记一个 member) |
| 性能 | O(1) | O(log N + M)(ZREMRANGEBYSCORE + ZCARD + ZADD) |
| 适用 | 粗粒度限流, 精度要求低 | 需要精确控制速率的场景 |
3.3 令牌桶(Token Bucket)
令牌桶与滑动窗口的思路不同: 不是统计"过去发了多少", 而是控制**"当前有没有令牌可用"**. 令牌以固定速率生成存入桶中, 请求来时必须拿到一枚令牌才能放行.
令牌桶 = 食堂排队打饭 -- 师傅每分钟做 10 份菜(令牌生成速率), 窗口前排 10 个盘子(桶容量). 菜做好了放盘子上, 有人来了拿一盘走. 如果连续来 100 个人, 前 10 个能吃到, 后面的就得等师傅再做. 突发流量被桶容量兜住了, 平稳输出速率由生成速率控制.
|
令牌桶的优势: 允许一定的突发流量(桶里积攒的令牌可一次性消耗), 但长期速率被严格控制. 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 的特殊之处 -- 它是唯一支持"一发多收"广播的模式, 但也最不可靠:
|
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 自带) | 中 | 高 |
| 典型定位 | 轻中量可靠队列 | 企业级消息路由 | 大数据流式处理 |
决策口诀
- 已经用了 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 篇 |
动手练习
- 分布式锁: 用
SET lock:test <uuid> NX PX 30000拿锁, 用 Lua 脚本安全释放. 在 redis-cli 中验证正常加锁-释放, 拿不到锁时的行为, 以及 token 不匹配时 DEL 被拒绝. - 滑动窗口限流: 用 ZSet 实现"每 10 秒最多 5 次请求"的限流器. 模拟跨窗口边界的请求序列, 对比固定窗口看看边界突变是否消失.
- 令牌桶: 用 Hash + Lua 实现令牌桶, 每 2 秒生成 1 个令牌, 桶容量 10. 观察连续请求 15 次时的通过/拒绝分布.
- Pub/Sub: 开两个 redis-cli, 一个 SUBSCRIBE, 另一个 PUBLISH. 然后关掉订阅者再发消息, 重新上线--验证消息丢失现象.
- 思考题: 缓存 Aside 模式下, 如果"先更新 DB, 再删除缓存"的第二步(删缓存)失败了, 会有什么后果? 怎样用消息队列来兜底?
- 思考题: 分布式锁中, 如果不设置过期时间会怎样? 如果过期时间到了但业务还没执行完又该怎么办? 这两个约束为什么是矛盾的, Watch Dog 如何折中?