缓存策略、分布式锁、限流、消息队列 —— 用 Redis 解决真实系统问题的四把钥匙
前四篇积累了"有哪些武器"(String、List、Hash、Set、ZSet、Bitmap、HLL、GEO、Stream),这一篇聚焦"怎么用它们打赢一场仗"。对于每个场景,关注三个问题:
| 场景 | 核心数据结构 | 一句话描述 |
|---|---|---|
| 缓存 | String、Hash | 用内存速度换数据库压力,加速读取 |
| 分布式锁 | String(SET NX PX)+ Lua | 多实例间互斥访问共享资源 |
| 限流 | String、ZSet + Lua | 控制请求速率,保护下游系统 |
| 消息队列 | List → Pub/Sub → Stream | 异步解耦,削峰填谷 |
缓存是 Redis 最广泛的应用。核心问题不是"怎么存",而是"缓存和数据库之间怎么保持一致性"——读写顺序、失效策略、异常兜底,每一步选错了都可能导致数据错乱。
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 不一致。删除缓存让下次读操作触发一次重新计算和回填,反而更安全——这就是 Lazy 加载思想。
在"先更新 DB 再删缓存"的基础上,再加一次延迟删除来兜底:
1. 删缓存
2. 更新 DB
3. 等一小段时间(如 500ms)
4. 再次删缓存 ← 兜底:防止第 1-2 步之间其他请求回填了旧数据
这个方案降低了不一致的时间窗口但无法彻底消除,且增加了写操作的延迟。绝大多数业务场景先更新 DB 再删缓存就足够了。
应用不直接操作 DB 和缓存,而是通过一个缓存抽象层来读写。缓存层负责同步逻辑,应用侧代码简化。
| 模式 | 读 | 写 |
|---|---|---|
| Read Through | 缓存 miss 时,缓存层查 DB 并回填,应用无感知 | — |
| Write Through | — | 应用写缓存,缓存层同步写 DB,两次写都成功才返回 |
适用场景
数据读取频繁、写入后需要立即可见、对一致性要求较高的场景。代价是写入延迟变高(同步写 DB),且 Redis 本身不提供此能力——需要应用层封装或使用第三方缓存框架。
应用只写缓存(Redis),缓存层异步批量刷入数据库。写入延迟极低,但有数据丢失风险。
| 对比维度 | Cache Aside | Write Behind |
|---|---|---|
| 写延迟 | 写 DB(高) | 写 Redis(低) |
| 数据安全 | 高(DB 是同步的) | 低(Redis 挂了丢数据) |
| 实现复杂度 | 低 | 高(需要异步刷盘 + 去重 + 合并) |
| 典型场景 | 通用 Web 应用 | 高并发写入(如秒杀扣库存) |
这三个问题在实际生产中极其常见,这里先建立概念框架,第 12 篇会深入展开具体解决方案。
| 问题 | 现象 | 根因 | 解决方向 |
|---|---|---|---|
| 缓存穿透 | 查一个不存在的数据,缓存和 DB 都没有 → 每次请求都穿到 DB | 恶意攻击或业务 bug 查询不存在的 key | ① 布隆过滤器拦截 ② 缓存空值(短 TTL) |
| 缓存击穿 | 某个热点 key 过期瞬间,大量请求同时打到 DB | 热点 key 过期 + 高并发读 | ① 互斥锁(只让一个请求回源)② 逻辑过期(异步刷新) |
| 缓存雪崩 | 大量 key 同时过期或 Redis 宕机,DB 瞬间被压垮 | 大批缓存集中在同一时段失效 | ① TTL 加随机偏移 ② 多级缓存 ③ 熔断降级 |
单机环境下,synchronized 或 ReentrantLock 能保证线程安全。但在多实例部署时,每个实例有自己的 JVM/进程,单机锁失效——需要一把所有实例都能看到的分布式锁。
核心思想:利用 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 引入这个语法的主要原因。
假设 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 之间不会被其他命令插入。
如果业务执行时间不确定,设置一个固定过期时间可能不够。常见方案是启动一个后台续期线程:
拿到锁(过期 30s)
↓
启动后台线程:每 10s 检查一次
├─ 锁还存在 + 业务还在跑 → EXPIRE 续期到 30s
└─ 业务结束 → 停止续期 + 释放锁
Redisson 等 Java 客户端内置了此机制(叫 "watch dog"),无需手动实现。
单实例 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 锁 + 合理过期就够用。
| 陷阱 | 后果 | 对策 |
|---|---|---|
| 忘记设过期时间 | 死锁,其他实例永远拿不到 | 永远带上 PX/EX |
| 直接 DEL 释放 | 释放了别人的锁 | Lua 脚本 + token 校验 |
| 过期时间太短 | 锁过期业务还在跑,数据错乱 | 保守估算 + watch dog 续期 |
| 不可重入 | 同一线程重复获取被自己阻塞 | 客户端实现可重入(本地计数 + 锁 value 存线程标识) |
| 主从切换丢锁 | 两个实例同时拿到锁 | Redlock / 接受小概率冲突 / 换 ZK |
限流的本质是控制单位时间内的请求次数。保护下游服务不被突发流量打垮,防止单个用户滥用资源。Redis 天然适合做限流计数器——内存操作够快,过期机制自动清理。
最简单的方案:以自然时间窗口(如 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 个请求。这是固定窗口最大的缺陷。
不再按整点分界,而是以当前时间为终点,回看过去 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
| 对比 | 固定窗口 | 滑动窗口(ZSet) |
|---|---|---|
| 精度 | 有边界突变 | 平滑精确 |
| 内存 | 极低(一个计数器) | 与请求量成正比(每次请求记一个 member) |
| 性能 | O(1) | O(log N + M)(ZREMRANGEBYSCORE + ZCARD + ZADD) |
| 适用 | 粗粒度限流,精度要求低 | 需要精确控制速率的场景 |
令牌桶与滑动窗口的思路不同:不是统计"过去发了多少",而是控制"当前有没有令牌可用"。令牌以固定速率生成存入桶中,请求来时必须拿到一枚令牌才能放行。
-- 令牌桶限流(原子 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)和云服务商的限流大多基于令牌桶或其变体。
| 维度 | 固定窗口 | 滑动窗口 | 令牌桶 |
|---|---|---|---|
| 核心思想 | 窗口内计数 | 过去 N 秒内的请求数 | 桶中是否有令牌 |
| Redis 结构 | String(计数器) | ZSet | Hash(2 字段) |
| 精度 | 低(边界突变) | 高 | 中(取决于时间精度) |
| 支持突发 | 否 | 否 | ✅(桶容量决定突发上限) |
| 内存占用 | 极低 | 与请求量正比 | 极低 |
| 实现复杂度 | 低 | 中 | 中 |
| 典型场景 | 简单频控 | 精确 QPS 控制 | 允许突发 + 平稳输出的场景 |
第 04 篇已经详细讲过 Stream 的命令和消费者组机制。这里从方案演进和选型决策两个维度来梳理 Redis 做消息队列的全貌。
| 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 服务内跨节点广播、配置热更新通知、实时聊天消息分发。
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 被拒绝。