Redis 实战:模式与陷阱
Redis 不只是缓存,但很多人只会 set/get。本文讲清 Redis 的核心数据结构各适用的场景、生产级模式(缓存策略/分布式锁/限流/排行榜)、持久化与高可用选择、以及经典陷阱(缓存穿透/击穿/雪崩、大 key、过期策略),让你真正用好 Redis。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
我面试时喜欢问一句:"你们 Redis 都拿来做啥?"九成的回答是——"做缓存,放点 session,偶尔做个计数器"。再追问 zset 怎么用、分布式锁怎么释放、AOF 和 RDB 开哪个,基本就接不上了。Redis 是那种"装上就能跑、人人都会 SET/GET"的工具,但它的能力边界和陷阱,大多要等线上出过事才会去补课。这篇文章把 Redis 生产里真正高频的模式和坑一次讲透,按"能扛流量"的标准写。
一、Redis 不只是缓存
把 Redis 当成"带过期时间的 HashMap",是最大的浪费。它本质是一个内存型、单线程(命令执行)、数据结构丰富的 KV 存储——"内存型"决定了快(微秒级),"单线程"决定了无并发竞争(命令原子),而"数据结构丰富"决定了它能干远超缓存的事。
缓存只是它最常见的一种用法。除此之外,它还常被用作:
- 计数器 / 限流器:原子 INCR,毫秒级;
- 分布式锁:SET NX EX,跨进程互斥;
- 排行榜:zset 的天然能力;
- 消息队列:list 的 LPUSH/BRPOP,或 5.0 后的 stream;
- 去重 / 标签:set;
- 基数统计:hyperloglog,12KB 算上亿 UV。
一句话:当你需要"跨进程共享、低延迟、能持久化"的状态,Redis 几乎总是最划算的选择。但代价是它很贵(内存比磁盘贵一个数量级),所以用它存什么、怎么存,需要判断。下面先从数据结构选型讲起。
二、数据结构选型:用对工具的第一步
Redis 的强大来自它的数据结构。选对结构,代码简洁又高效;选错结构,要么写得很丑,要么直接把 Redis 拖垮。
string:最基础,二进制安全(能存图片、JSON、序列化对象)。适用场景:缓存对象、计数器、分布式锁的 token。计数器是它的高光用法——INCR / INCRBY 原子递增,天然适合点赞数、库存、API 调用次数:
INCR post:123:likes # 点赞 +1
INCRBY stock:sku:99 -1 # 扣库存
注意 string 单 key 上限 512MB,别真的往里塞大文件。
hash:一个 key 下存多个字段,适合对象。存用户资料,与其用 JSON string,不如用 hash:
HSET user:1001 name "张三" age 28 email "[email protected]"
HGET user:1001 name
HINCRBY user:1001 age 1
好处是字段级读写,改邮箱不用把整个对象读出来再写回去。单 hash 字段数上限约 42 亿,实际超过几千就该拆。
list:有序、可重复、两端可推/弹。经典用法是简单队列:
LPUSH task:queue "{...job...}"
BRPOP task:queue 30 # 阻塞式消费,30 秒超时
BRPOP 是阻塞版本,消费者空转时不烧 CPU。但 list 不保证消费失败后任务不丢(消费者 pop 出来就没了),要可靠队列请用下文的 stream。
set:无序、不重复。去重和标签是它的高频场景:算"看过这篇文章的独立用户"、"同时打了两类标签的用户"。
SADD post:123:viewers uid:1 uid:2 uid:3
SINTER tag:go tag:redis # 交集:既关注 go 又关注 redis 的人
SUNION tag:go tag:redis # 并集
SDIFF tag:go tag:redis # 差集
zset(sorted set):带分数的 set,按分数排序。这是 Redis 最有辨识度的结构,排行榜是它无可替代的场景:
ZADD leaderboard:weekly 950 "user:1" 870 "user:2" 920 "user:3"
ZREVRANGE leaderboard:weekly 0 9 WITHSCORES # Top 10
ZINCRBY leaderboard:weekly 10 "user:2" # 加分
ZRANK leaderboard:weekly "user:1" # 排名(从 0 起)
同样的需求用 SQL 写:ORDER BY score DESC LIMIT 10,数据量一上来就要全表扫描加索引维护,远没有 zset 的跳表快。
stream:5.0 引入,带消息 ID、支持消费组(Consumer Group)和 ACK 机制,是 Redis 自家的"轻量 Kafka"。需要可靠投递、消费失败重试的消息场景用它,而不是 list。XADD / XREADGROUP / XACK 三件套,能在不大规模上 MQ 之前先把异步任务流跑起来。
hyperloglog:基数估算,固定 12KB 内存就能估算上亿去重数,误差 ~0.81%。算 UV、独立访客数这种"不需要精确、只要量级"的场景,用它比 set 省几百倍内存。代价是只能估个数,不能还原具体是哪些用户。
一句话选型:缓存对象用 string/hash,队列用 list/stream,去重用 set,排行榜用 zset,去重计数用 hyperloglog。先选对结构,再去谈性能。
三、生产模式之一:缓存策略
缓存是 Redis 最常见的角色,但"加个 Redis"不等于有了缓存策略。生产里至少要想清楚三件事:更新策略、过期、空值。
更新策略最常用的是 Cache Aside(旁路缓存):读时先查缓存,miss 再查 DB,然后回写;写时先更新 DB,再删缓存(不是更新缓存)。
读: GET cache → miss → 查 DB → SET cache
写: UPDATE DB → DEL cache
为什么是"删"不是"更新"?因为更新缓存有并发问题——两个写请求交叉到达,缓存里可能留下旧值;删则让下次读自己重建,语义简单。注意,Cache Aside 在"先更新 DB 再删缓存"和"先删缓存再更新 DB"之间有讲究:后者在删完缓存、DB 还没更新的瞬间,一个读请求会把旧值回写缓存,导致脏数据停留很久。生产默认走"更新 DB → 删缓存",再配合下文的延时双删兜底极端并发。
Write Through 是另一种思路:写时同时更新缓存和 DB,由 Redis 或客户端保证一致性。它实现复杂、写放大,普通业务很少用,适合写少读多、一致性要求极高的配置型数据。
TTL 必须设。没有 TTL 的缓存是埋雷——数据无限堆积,内存满了触发淘汰策略,不可控的 key 被踢,行为更难预测。TTL 的值按业务能容忍的"数据不一致窗口"定:用户资料几分钟,排行榜几分钟,热门文章几小时。记住:缓存是"加速",不是"源",DB 才是源。
空值缓存防穿透(见第六节):查不存在的 key,也把一个空值(带短 TTL)写进去,避免恶意请求反复打 DB。
四、生产模式之二:分布式锁
分布式锁是 Redis 第二高频用法,也是被写错最多的地方。一个"能用"的分布式锁,至少要满足:互斥、无死锁、可释放、释放安全。
正确姿势(单实例):
-- 加锁(原子):仅当 key 不存在时设置,并带过期
SET lock:order:1001 <token> NX EX 30
<token> 是客户端生成的唯一随机串(如 UUID),这点极其关键,下面会解释。NX EX 30 保证了"加锁"和"设过期"是原子的——早年常见的 SETNX + EXPIRE 两步写法,在两步之间进程崩了,锁就成了永不过期的死锁,这是经典事故。
释放锁必须用 Lua。直接 DEL lock:order:1001 是错的:你可能因为 GC 停顿,在自己持有的锁过期后被别人拿走,这时你 DEL 掉的是别人的锁。正确做法是"判断 token 一致再删",而判断+删除必须原子,只能用 Lua:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
ARGV[1] 就是加锁时的 token。只有拿到自己锁的人才能释放——这就是 token 的意义。
锁的过期时间(EX 30)是个权衡:太短,业务没执行完锁就被别人抢走;太长,持有者进程崩了之后,锁要等很久才释放。生产里通常给个保守上限(比如 30s),再用**看门狗(watchdog)**机制续期——持有期间定时 EXPIRE 把 TTL 拉长,业务完成后停止续期。
最后一句忠告:单实例 Redis 的分布式锁在 failover 时会出问题——主节点挂了、从节点还没同步到锁就被提升为主,会导致同一把锁被两个人拿到。对正确性要求极高的场景(涉及钱),请用 Redisson 的 RedLock,或者干脆换 ZooKeeper/etcd 这种 CP 系统。Redis 是 AP 偏 CP 的权衡,别拿它当强一致锁用。
五、生产模式之三:限流与排行榜
限流用 Redis 实现极轻。两种主流算法:
固定窗口(简单版):INCR + EXPIRE。
INCR ratelimit:uid:1001
EXPIRE ratelimit:uid:1001 60 # 第一次访问时设过期
-- 返回值 > 100 即拒绝
缺点是窗口边界突刺——59 秒和 61 秒各打满 100 次,实际 1 秒内能到 200。
更精确的是滑动窗口,用 zset 实现:把每次请求的时间戳作为 score 存进 zset,每次访问先清掉窗口外的老记录,再统计窗口内的数量:
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < limit then
redis.call("ZADD", key, now, now) -- member 用 now,近似唯一
redis.call("EXPIRE", key, window / 1000)
return 1
else
return 0
end
令牌桶则更适合"允许突发"的场景(比如 API 网关),实现思路类似,核心是按时间补令牌。
排行榜就是 zset,前面已经写过基础命令。生产里要做的两件收尾事:一是榜单按时间维度分 key(leaderboard:weekly:2026W29、leaderboard:daily:20260717),过期清理;二是用 ZUNIONSTORE 做合并——比如月榜从四周的周榜聚合,不用重新算。榜单数据通常不大,但用户量千万级时要注意 zset 内存占用,必要时可以只缓存 Top N,长尾数据按需查 DB。
六、持久化:RDB 与 AOF
Redis 在内存里,断电即丢。要数据安全,必须开持久化。两种方式,理解了各自的取舍才好选。
RDB(快照):周期性把内存全量快照成二进制文件 dump.rdb。优点是文件紧凑、恢复快、对性能影响小(fork 子进程异步写)。缺点是两次快照之间的数据会丢——默认配置可能丢几分钟。
AOF(日志):把每条写命令追加到日志,重启时回放。优点是丢得少(appendfsync everysec 每秒刷盘,最多丢 1 秒)。缺点是文件大、恢复慢,长期运行要靠 BGREWRITEAOF 压缩。
生产默认配置是两个都开。RDB 用于快速冷启,AOF 用于少丢数据。Redis 4.0 之后支持混合持久化(aof-use-rdb-preamble yes):AOF 重写时前半段用 RDB 二进制(快速恢复)、后半段追加增量命令,兼具两者优点——这是现在的推荐姿势。
要强调一点:持久化保护的是"进程重启 / 断电",不保护"误操作"。FLUSHALL 一敲下去,AOF 照样会写进去。线上务必关掉危险命令(rename-command FLUSHALL ""),并定期把 RDB 备份到异地。
七、高可用:主从、哨兵、集群
单点 Redis 是事故源,生产必须做高可用。三种方案,按复杂度递增:
主从复制(replication):一个主(master)挂多个从(replica),主写从读。配置一句 REPLICAOF master 6379 即可。它解决了读扩展和数据冗余,但主挂了要人工切,不算真正的高可用。
Sentinel(哨兵):在主从基础上加一组独立进程(Sentinel),负责监控主从存活、自动故障转移、通知客户端。主挂了,Sentinel 会在从里选一个提升为新主,并把变更广播给客户端。它适合缓存、session 这类允许秒级中断的场景——故障转移期间会有短暂不可用。这是中等规模业务最常用的方案。
Cluster(集群):把数据分片(sharding)到多个分片,每个分片有自己的主从,共 16384 个哈希槽(key 的 CRC16 取模决定落到哪个槽)。它同时解决容量上限和读写扩展——单机内存不够、单机 CPU 不够时,只有 Cluster 能横向扩。代价是复杂:跨槽操作受限(多 key 命令要求 key 在同一槽)、运维心智重、failover 行为更难调试。
选型一句话:数据量 < 几十 GB、能容忍秒级中断,用 Sentinel;数据量更大、需要横向扩展,上 Cluster。两者之间不是平滑过渡,业务早期就该想清楚,后迁成本很高。
八、经典陷阱:穿透、击穿、雪崩
这三个词被背烂了,但真正在生产里识别和处置过的人不多。它们的共同点是——都是缓存 miss 之后打到了 DB,区别在 miss 的原因。
缓存穿透:查一个根本不存在的 key(比如负数 ID、恶意构造的用户号),缓存永远 miss,每次都穿透到 DB。攻击者最爱用这招。
- 解法一:空值缓存。DB 查不到也回写一个空值(短 TTL,如 60s),下次直接命中。
- 解法二:布隆过滤器。在缓存前加一层布隆,先把所有合法 ID 装进去,请求先过布隆——不存在就直接拒绝,根本不打 Redis/DB。布隆有假阳性(说存在可能不存在),但没有假阴性(说不存在就一定不存在)。
缓存击穿:一个热点 key 突然过期,瞬间几万请求同时 miss,一起打到 DB 重建缓存,DB 被打爆。
- 解法一:互斥重建。缓存 miss 后,只让一个请求去查 DB 重建,其他请求等一会再读缓存(可以用上面分布式锁实现)。
- 解法二:热点 key 永不过期,靠后台任务主动刷新。逻辑复杂,但能彻底避免击穿。
缓存雪崩:大量 key 同时过期(或 Redis 整体挂掉),DB 瞬间被海量请求淹没。
- 解法一:过期时间打散。给 TTL 加随机抖动(
ttl = base + random(0, 300)),避免同时失效。 - 解法二:多级缓存。本地缓存(Caffeine / 进程内)做一层兜底,Redis 整体挂了本地还能扛一阵。
- 解法三:Redis 自身的高可用(Sentinel/Cluster)防"Redis 挂掉"这一路。
穿透、击穿、雪崩,名字花哨,本质都是缓存作为 DB 前面那道闸,一旦漏了 DB 就得直接面对流量。所以治理的核心永远是:少让流量漏过去(空值/布隆),漏过去也别一起漏(打散/互斥),真漏了要有兜底(本地缓存/限流降级)。
九、大 key 与热 key
最后两个容易被忽略的坑,都是"用着用着才暴露"的慢性病。
大 key:单个 key 对应的 value 过大——一个 string 存了几 MB 的 JSON,或一个 hash/list 几十万元素。危害:读写阻塞(单线程,大命令挤占其他请求)、网络出口打满、集群迁移时单 slot 卡死、DEL 大 key 时直接阻塞主线程几秒。
- 自查:
redis-cli --bigkeys,或MEMORY USAGE key。 - 处置:拆分。大 hash 按字段拆成多个小 key,大 list 改成分页,大 zset 按时间/类别分桶。删除大 key 用
UNLINK(异步删)而非DEL。
热 key:某个 key 的访问量远超其他(比如明星爆款商品页的缓存)。危害:单分片 CPU 打满(集群里无法靠多分片均摊)、把流量集中到一个 Redis 节点上。
- 自查:
redis-cli --hotkeys(需开 LFU 淘汰策略),或在客户端埋点统计。 - 处置:本地缓存。在应用进程内(Caffeine、go-bigcache)缓存一份,设置很短的 TTL(几秒),让大部分请求根本不打 Redis。本质上是用"多级缓存"把单点的热点均摊到各应用实例。
小结
- Redis 不只是缓存:它是内存型、单线程、数据结构丰富的 KV 存储。string/hash/list/set/zset/stream/hyperloglog 各有所长,选对结构是用好 Redis 的第一步。
- 生产模式记三件:Cache Aside 缓存(更新 DB → 删缓存 + TTL);分布式锁(
SET NX EX+ 唯一 token + Lua 释放,RedLock/etcd 才是强一致);限流与排行榜(zset 滑动窗口、zset 榜单分 key)。 - 持久化与高可用:RDB 快、AOF 全,生产通常都开并启用混合持久化;Sentinel 解决故障转移(秒级中断),Cluster 解决容量与扩展(分片,运维更重)。
- 穿透 / 击穿 / 雪崩分别对应"查不存在 / 热点失效 / 批量失效":空值缓存 + 布隆过滤器、互斥重建、过期时间打散,核心都是别让流量一股脑打穿到 DB。
- 大 key 要拆、热 key 要散:
UNLINK异步删、本地缓存兜底热点,这两类问题靠监控先行(--bigkeys/--hotkeys),别等线上抖动才查。
一句话收尾:Redis 的强大在于"快 + 结构丰富 + 原子",它的危险也在于"快 + 单线程 + 全在内存"——你写得越随意,它崩得越壮观。把数据结构选对、把过期和淘汰设好、把大 key 和热 key 盯住,Redis 才能从"能跑"变成"扛得住"。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。