做了这么多年后端,我见过太多团队把 Redis 用得稀里糊涂:业务逻辑里随手set一个 key,过期时间随缘填,线上出问题了就重启 Redis。网上聊 Redis 的文章汗牛充栋,但大部分都在讲命令,很少讲"什么时候该用、什么时候不该用、出了问题怎么查"。这篇文章不打算重复手册,我想从实际项目里 Redis 的定位、缓存三兄弟的坑、结构化数据设计、持久化策略、线上排障这五个维度,把我这些年积累的真实经验捋一遍。适合正在写业务的后端开发、刚接手 Redis 运维的同学,也适合那些"Redis 用了两三年但遇到问题就懵"的朋友。
Redis 本身的安装部署我就不展开了,默认你已经有一个能跑起来的实例。需要提醒的是,本文所有经验都来自通用场景,不绑定特定版本,但建议至少用 6.x 以上,很多新特性和改进确实香。
1. 别急着写代码:先弄懂 Redis 在你项目里到底是什么角色
很多人有个错觉:Redis 就是缓存数据库,所有数据塞进去就完了。这个认知偏差,是后面一系列线上事故的根源。
1.1 Redis 为什么快:单线程模型的底层逻辑
Redis 的核心处理模型是单线程事件循环,配合 IO 多路复用。我见过不少初级开发一听到"单线程"就皱眉:单线程怎么可能快?这里要把因果搞清楚。Redis 的操作基本都在内存里完成,哈希表的读写是微秒级,真正的瓶颈在网络 IO。单线程模型最大的好处是:没有锁竞争,没有上下文切换的开销。数据结构和命令实现里可以放心地做各种原子操作,不用考虑并发改写同一块内存的问题。
这就解释了一个现象:Redis 的读写吞吐量能轻松到十万级 QPS,而同样做缓存的某本地内存组件,一旦并发线程多了,性能反而因为锁竞争往下掉。所以在判断"这个操作能不能让 Redis 扛"的时候,优先考虑的是命令复杂度,而不是线程数。
1.2 什么数据适合进 Redis:一个三层筛选漏斗
我在项目里判断一个数据是否该放进 Redis,会过三层筛子:
- 读写比例:是不是明显读多写少?如果写和读一样频繁,缓存的价值就大打折扣。
- 时效容忍度:能不能接受数据有短暂的不一致?比如用户头像可以容忍 5 分钟延迟,但库存扣减绝对不能靠缓存做最终一致。
- 数据量级:单个 value 是不是超过 100KB?如果 value 动不动几 MB,Redis 的吞吐会迅速劣化,反而不如直接走磁盘存储。
一条配置数据、一份商品详情、一个用户会话,这些天然符合"读多写少、小体积、弱一致"的特征。而一份几十 MB 的报表、一套复杂的跨表查询结果,就不适合硬塞 Redis。
1.3 最容易翻车的两种用法
第一种是把 Redis 当万能存储。有人把订单数据、账单流水直接写 Redis,然后说"反正 Redis 快"。可 Redis 的持久化能力压根不是为这个设计的,内存成本也高得离谱。第二种是把 Redis 当强一致数据库。Redis 的主从复制是异步的,主节点刚写完还没同步,从节点就切上来了,数据就丢了。那些"用 Redis 存钱包余额"的方案,我每次看到都替他们捏把汗。
这个角色判断没做对,后面的缓存命中率、过期策略都是空中楼阁。我见过最夸张的案例,有人把全量用户表灌进 Redis,内存干到 20GB,结果命中率不到 10%——因为大量冷数据永远没人访问。
2. 缓存三兄弟的坑:穿透、击穿、雪崩的处理方案怎么选
缓存最经典的三道坎,几乎每个上规模的项目都会遇到。网上方案很多,但真正落地时各有取舍,我挨个说说实际经验。
2.1 缓存穿透:攻击型流量怎么挡
穿透是指请求一个缓存和数据库里都不存在的数据。比如查一个不存在的用户 ID,缓存查不到,数据库也查不到,于是每次请求都打到数据库。如果有坏人拿不存在的 ID 疯狂刷,数据库直接被打挂。
常规方案是缓存空值。查不到数据库记录时,向 Redis 里写一个 value 为 null 的 key,过期时间设短一些,比如 60 秒。这样后续同样的请求直接命中空缓存。这个办法简单有效,但有个副作用:空值 key 会占内存,而且如果非法 ID 是随机生成的,空值缓存策略也会失效。
更稳妥的方案是布隆过滤器。启动时把所有存在的 ID 加载到布隆过滤器里,请求先进过滤器:
def is_id_exist(bloom_filter, user_id): if not bloom_filter.might_contain(user_id): # 一定不存在,直接返回 return False # 可能存在,继续走缓存 -> 数据库链路 return True布隆过滤器的特点是"一定不存在的肯定不存在,存在的可能有误判"。它能挡住绝大多数非法 ID 请求。代价是需要维护过滤器和数据源的一致性:新 ID 入库时必须同步加入过滤器,否则合法请求也会被误杀。我在项目里通常两者结合:布隆过滤器挡非法流量,空值缓存挡合法但数据确实缺失的场景。
2.2 缓存击穿:热点 key 过期瞬间的惊群效应
击穿和穿透一字之差,含义完全不同。击穿是指一个热点 key 在过期那一瞬间,大量请求同时涌入,发现缓存没了,全冲去数据库。比如电商秒杀的商品详情页,平时都打缓存,突然 key 过期了,几百个请求同时查库,数据库压力瞬间拉满。
最常用的方案是互斥锁重建缓存。核心逻辑是:缓存 miss 后,先尝试获取分布式锁,只有拿到锁的线程才去查库并回填缓存,其他线程先 sleep 一下再查缓存:
def get_hot_value(key): value = redis.get(key) if value is not None: return value # 尝试获取锁,锁的 key 用业务 key 加后缀 if redis.set(f"lock:{key}", "1", nx=True, ex=5): try: value = db_query(key) redis.set(key, value, ex=3600) return value finally: redis.delete(f"lock:{key}") else: time.sleep(0.05) return get_hot_value(key) # 递归重试这个方案的缺点是要写递归重试逻辑,而且会有短暂的窗口期:第一个拿到锁的线程还在查库回填时,其他线程都在 sleep,相当于短暂"卡"了一下。另一个思路是逻辑过期:缓存不设置物理过期时间,而是把过期时间写进 value 里。每次读缓存时发现逻辑过期,就异步起一个线程去刷新,当前请求继续返回旧值。这个方案没有卡顿,但拿到的数据可能已经过期了一段时间,适合对实时性要求不高的热点数据。
我个人的取舍标准是:数据一致性要求高就选互斥锁,实时性要求更高就选逻辑过期。没有银弹。
2.3 缓存雪崩:批处理 key 同时失效的连环爆炸
雪崩是大量 key 在同一段时间集中过期,或者 Redis 节点本身就挂了。前者可以通过设置过期时间抖动来规避:给每个 key 的过期时间加一个随机偏移量,比如 240 到 360 秒之间随机取,让过期时间分散开:
import random expire_time = random.randint(240, 360) redis.set(key, value, ex=expire_time)这个随机偏移能解决"批量导入缓存时全部设成同一过期时间"的问题。我见过有团队用pipeline批量设置 1 万个 key,过期时间都是 300 秒,于是每 5 分钟整点数据库就迎来一波流量尖峰。加了抖动之后,峰值直接削平。
至于 Redis 节点宕机导致的雪崩,那就是运维层面的高可用问题了,主从复制加哨兵是底线,这个后面展开说。
3. 把 Redis 当数据结构用:我在业务中高频复用的五种设计
这是 Redis 最被低估的部分。大部分人只会用set和get,但 Redis 的价值大头在于那几种原生数据结构。用好它们,很多业务逻辑能省掉一大半代码。
3.1 ZSET 做排行榜:实时 Top N 的正确打开方式
排行榜需求到处都是:积分榜、热销榜、活跃榜。用关系型数据库做排行榜,等你 ORDER BY + LIMIT 跑完一次查询,榜单早就变了。Redis 的 ZSET 把 score 从小到大维护,插入、更新、排名查询全是 O(logN)。
以积分榜为例:
# 用户 1001 增加 5 分 ZINCRBY leaderboard 5 user:1001 # 查用户 1001 的排名(从高到低,所以用 REV) ZREVRANK leaderboard user:1001 # 查 Top 10 ZREVRANGE leaderboard 0 9 WITHSCORES我做过一个积分活动的实时榜单,百万级用户,每秒钟都有积分变动,全程靠 ZSET 扛,没有一条 SQL 参与榜单计算。需要注意的一点是:ZREVRANGE的排名是从 0 开始的,业务里展示如果要"第 1 名",记得加 1。
3.2 HyperLogLog 做 UV 统计:12KB 内存装下千万级用户访问
UV(独立访客)统计是个典型的"准确率不用 100%,但浪费内存可不行"的场景。用 SET 去重,千万级 UV 的内存开销非常可观。HyperLogLog 的标准错误率是 0.81%,内存固定大约 12KB。
PFADD today_uv user:1001 user:1002 PFCOUNT today_uv我用这个方案做过一个活动页的每日 UV 统计,跑了一个月,内存占用稳定得可怕。当然,HyperLogLog 有个坑:它不支持删除单个元素。如果你需要精确到"某个用户是否访问过",那就得用 SET 或者布隆过滤器,不能指望 PF 系列。
3.3 Lua 脚本做库存扣减:把原子性写进 Redis
秒杀、抢购这类场景最怕超卖。Redis 的DECRBY本身是原子的,但"先查库存够不够,再扣减"这个逻辑组合起来就不是原子的了——除非你用 Lua 脚本:
-- 库存扣减脚本 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1调用的时候用EVAL把这脚本整体发过去,Redis 在单线程模型里执行整个脚本,中间不会被其他命令插队。这个方案成功压测扛住了某抽奖活动整场的并发扣减,数据库端只处理最终落库,压力小了很多。
需要提醒的是:Lua 脚本里最好不要做耗时的操作,比如循环几千次甚至KEYS查询,因为它会阻塞整个 Redis 实例,吃掉的性能是全局性的。
3.4 SETNX 分布式锁:锁误删的坑我替你们踩过了
用 Redis 做分布式锁是老话题,但最常见的坑是误删锁。线程 A 拿到锁,执行时间超长,锁因为过期时间到了自动释放;线程 B 拿到锁开始执行;这时候线程 A 终于跑完了,执行DEL,把线程 B 的锁删了。
标准解法是锁的 value 存一个唯一标识,删除前先比对再删,而且比对和删除必须是一个原子操作(还是 Lua):
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end关于过期时间续期,我见过很多简陋的自研方案,其实主流客户端库里已经有看门狗机制:锁加成功后,后台线程会每隔一段时间自动延续过期时间,直到业务逻辑执行完释放锁。如果你不想自己造轮子,直接引入成熟的分布式锁库是最稳的选择,不要重复发明这个轮子。
3.5 Stream 做轻量消息队列:比 List 可靠得多
List 的BLPOP也能实现队列,但消息消费后如果客户端崩溃,消息就丢了。Stream 结构提供了消费者组和消息确认机制,每条消息都有一个 ID,消费者消费后必须显式XACK,否则消息会留在队列里待重新投递。
XADD order_stream * order_id "1001" XREADGROUP GROUP order_group consumer1 COUNT 1 STREAMS order_stream >Stream 在 Redis 5.0 之后就比较成熟了。如果你的团队不想引入额外的消息队列中间件,而且消息量不大(比如每秒几千条以内),Stream 是个轻量可行的选择。但它毕竟不是专业 MQ,没有延迟队列、死信队列这种高级语义,量大了还是老实上专业消息队列。
4. 线上 Redis 慢和丢数据:持久化与内存策略的取舍
这一章聊运维侧的硬核问题。Redis 用着用着变慢了、重启后数据丢了,这两件事我都遇到过,核心都在持久化配置和内存淘汰策略上。
4.1 RDB 和 AOF:要性能还是要安全
Redis 默认开 RDB,定期把内存快照写盘。它的恢复速度快,但两次快照之间的数据会丢。AOF 则是把每个写命令追加到日志文件,可以做到秒级甚至更小的数据丢失窗口,但文件大、恢复慢。
我建议生产环境用混合持久化方式:aof-use-rdb-preamble yes。它的原理是:AOF 文件头部用 RDB 格式存一个全量快照,快照之后追加增量命令。这样重启时先加载 RDB 再应用增量,恢复速度快,数据丢失窗口也小。
对比一下各方案:
| 持久化方式 | 数据丢失窗口 | 恢复速度 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| 纯 RDB | 两次快照之间全丢 | 快 | 低 | 可容忍分钟级丢失的缓存场景 |
| 纯 AOF | 取决于 fsync 策略,最差秒级 | 慢 | 高 | 数据较重要,需要接近不丢 |
| 混合持久化 | 秒级 | 较快 | 中 | 生产环境兜底推荐 |
fsync 策略也要注意:appendfsync everysec是性能和安全的均衡点,always每条命令都刷盘,性能损失很大,磁盘差的机器直接拖垮写入。
4.2 内存淘汰策略选错了,数据会悄悄消失
很多新人不知道 Redis 有一个maxmemory上限。一旦内存用完,写入就会触发淘汰策略。默认策略是noeviction(不淘汰,直接报错),很多线上实例就是这么"莫名其妙"写不进去数据的。
各策略怎么选,我用一张表说清楚:
| 策略 | 淘汰范围 | 适合场景 |
|---|---|---|
| allkeys-lru | 所有 key 中淘汰最久未访问的 | 纯缓存,数据可重建 |
| allkeys-lfu | 所有 key 中淘汰访问频率最低的 | 热点明显的缓存 |
| volatile-lru | 设置了过期时间的 key 中淘汰 | 有冷热之分且有业务数据保底要求 |
| volatile-ttl | 按剩余存活时间淘汰最快的 | 批量过期场景 |
| noeviction | 不淘汰,写满报错 | 绝对不能丢数据的业务 |
我的建议是:如果你是纯缓存,直接上allkeys-lfu。LFU 比 LRU 聪明的地方在于能识别"曾经热但现在不热"的 key,LRU 面对一次性的热点访问很容易误淘汰真正频繁访问的 key。
至于监控命中率,别靠猜。连上 Redis 实例,看两个计数器:
INFO stats # 关注 keyspace_hits 和 keyspace_misses命中率 = hits / (hits + misses)。如果命中率长期低于 80%,别急着调淘汰策略,先检查是不是有大量冷数据压根不该进缓存。
5. 高危命令与排查链路:一次 slowlog 定位的完整过程
我记忆中最清晰的一次线上事故,是某次活动预热期间 Redis 节点 CPU 突然飙到 90% 以上,所有读写都慢了一倍。当时排查过程我完整复盘一下,这个过程比最终结论更有参考价值。
5.1 第一层:用 INFO 看命中率和连接数
我先连上实例执行INFO,发现keyspace_hits和keyspace_misses的比例倒挂——命中率只有 40% 左右。按道理缓存命中率骤降,说明大量请求根本打不到缓存。正常业务不会突然改变访问模式,我第一反应是有人引入了新的查询逻辑,key 设计可能有问题。
顺着这个思路,看了当前连接的客户端列表和每秒命令统计,发现keyspace_misses暴涨的 key 前缀很集中,都是一个业务模块在查。这个模块的代码我扫了一遍,发现有人直接用KEYS命令做模糊匹配:
KEYS user:*5.2 第二层:slowlog 和 big key 的实锤
KEYS是出了名的高危命令,它需要遍历整个 key 空间,而且单线程模型下会阻塞主流程。我当时直接用SLOWLOG GET拉最近慢命令:
SLOWLOG GET 50果不其然,几十条慢日志全是KEYS user:*,单条执行耗时都在几百毫秒到秒级。这里必须强调:生产环境禁用KEYS是底线。你不确定能不能用的时候,就默认不能用。
替代方案是SCAN游标遍历:
SCAN 0 MATCH user:* COUNT 100它不会阻塞,每次返回一批,需要循环迭代直到游标回到 0。模糊查询的需求用这个完全能解决。
顺着 slowlog 继续挖,我又发现一个隐藏隐患:部分 value 已经涨到几 MB。用内置命令快速扫一下大 key:
redis-cli --bigkeys结果有几个 Hash 结构里存了上百万字段,每次读写都极慢。这就是典型的 big key 问题:单个 key 太大,Redis 处理的时间就长,一长就开始排队,排队就拖垮所有请求。
5.3 第三层:修复与验证
修复方案分三步:
- 把业务代码里的
KEYS全部替换为SCAN,并给 Redis 配置禁用危险命令,从源头堵死:
rename-command KEYS "" rename-command FLUSHALL "" rename-command FLUSHDB ""- 把 big key 拆分,比如大的 Hash 按业务维度拆成多个小的 Hash,每个控制在 10KB 以内。
- 拆完再跑
INFO stats,命中率回到 95% 以上,P99 延迟恢复到毫秒级。
这个排查链路看起来顺理成章,其实有个容易忽略的细节:替换代码后务必要灰度观察slowlog是不是真的不再出现新记录了。很多人改完代码就收工,结果还有定时任务在偷偷执行KEYS,第二天又复现。
5.4 日常巡检清单:别等线上出事才想起 Redis
这一章最后分享一份我一直在用的巡检清单,每周花十分钟跑一遍,能挡住 80% 的低级事故:
- 检查
INFO stats的命中率和INFO memory的内存水位,内存水位超过maxmemory的 70% 就要评估扩容或清理。 - 拉
SLOWLOG GET 20,看最近有没有慢命令,重点排查KEYS、HGETALL、SMEMBERS这类 O(N) 命令。 - 跑
redis-cli --bigkeys,识别大 key,制定拆解计划。 - 确认主从复制正常:
INFO replication看master_link_status是否为 up。 - 核对持久化配置,确保
appendonly yes且appendfsync everysec或者混合持久化开启。 - 监控淘汰策略触发的 key 数量,如果触发频繁,说明内存真的不够了,该扩容扩容,该调整过期时间调整过期时间。
这套巡检我跑了几年,Redis 相关的线上生产事故基本都在早期被拦住了。真等用户反馈说系统慢了再去查,代价往往是加班到凌晨。
最后再分享一个小技巧:很多排查经验不是看书看来的,是线上事故逼出来的。我建议你把自己负责的 Redis 实例所有的配置项导出来一份,逐条看一遍注释,比任何教程都管用。尤其是那些你从没动过的参数——往往就是你最该理解的参数。