凌晨三点,微信群里的告警机器人突然开始刷屏,redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool。你揉着眼睛打开电脑,连接数打满,CPU 100%,INFO一查,keys一个hotkey的ttl直接过期,然后所有请求像饿狼一样扑向了数据库——这一个晚上,Redis 这个“最快”的中间件,成了整个系统里最慢、最脆的环节。
Redis 看着简单,无非get/set/del加一堆数据结构,但真正让它成为“黑暗森林”的,不是那些面经里的八股题,而是藏在命令、配置和业务节奏里的一组隐形陷阱。我这些年处理过的线上事故,十有八九是夜里由这些陷阱引爆的。下面这 10 个,是我认为最值得刻在脑子里的,按杀伤力和隐蔽程度排了个序,每一个都配上排查命令和避坑操作,务必收藏。
1. 大 Key 删除:阻塞主线程的“慢动作炸弹”
1.1 大 Key 是怎么悄悄长出来的
很多人对“大 Key”没有概念,总觉得 Redis 嘛,内存里几百 M 的数据很正常。但这里的关键不是内存大小,而是单个 Key 内部结构的大小。比如一个list里存了几百万条消息,一个hash里塞了几十万个字段,甚至一个普通的string值有几 MB 的 JSON。这些 Key 平时读写没问题,一旦你去DEL它,就出事了。
为什么?Redis 是单线程处理命令的(这里不谈多线程 IO 和异步删除的细节),执行DEL的时候,如果这个 Key 是个大集合,它需要遍历所有元素去释放内存。这个遍历过程在事件循环里是同步的,期间其他所有命令都堵在门口排队。等你好不容易把DEL跑完了,客户端这边的超时错误已经刷了好几屏了。
我见过最夸张的一个案例:一个hash存了半年的用户行为数据,大概 200 万个 field,某天做清理任务时直接DEL掉,主线程阻塞了整整 8 秒。这 8 秒里,所有缓存读写全部卡死,上游服务超时,链路雪崩。而且这类问题很阴的一点是:平时你看 CPU 不高、内存也没满,根本想不起来去查它。
1.2 发现与安全删除大 Key 的实操方法
发现手段很简单,Redis 自带了扫描工具:
redis-cli --bigkeys它会遍历整个实例(生产环境找个低峰期跑),按string/list/set/hash/zset分类找出最大的 Key 和大小排名。还有一个更精细的排查方式,用DEBUG OBJECT key看序列化长度:
redis-cli DEBUG OBJECT user:behavior:202401返回里的serializedlength能告诉你这个 Key 占用多少字节。如果发现单个 Key 超过 100KB(string类型)或者集合元素超过 1 万个,我建议就当成大 Key 来治理了。
删除时千万别直接DEL。比较新的 Redis(4.0 以上)提供了UNLINK命令,它是异步删除,主线程只负责把 Key 从字典里摘掉,立刻返回,真正释放内存的工作交给后台线程:
redis-cli UNLINK user:behavior:202401如果手头还是老版本(或者面对的是redis-cluster里某些受限场景),那就用分批淘汰的办法:HSCAN配合HDEL,每次删 100 个 field,循环几次删完,把阻塞时间切成碎片,而不是一次性砸下去。
建议:新写代码时,任何集合类型的 Key 都要预设一个合理的“拆除方案”。要么设过期时间自动消失,要么写清除脚本时统一走
UNLINK。永远不要把DEL用在没把握的大 Key 上。
2. 过期时间的“薛定谔状态”:不太会儿就彻底消失
2.1 永远不过期的 Key 是怎么把内存吃光的
很多开发为了图省事,存缓存不设TTL,美其名曰“反正数据量不大”。然后半年后你就会看到INFO memory里used_memory一路走高,maxmemory设了等于没设——因为淘汰策略还没触发,内存先满了。
最坑的不是内存增长本身,而是没有过期时间 = 这个 Key 永远得不到清理机会,你既不知道它还剩多少空间,也不知道它哪天把实例撑爆。我接手过一套老系统,缓存 Key 有几十万个,从第一个到最后一个全是永久 Key,根本没法清理,最后只能挑低峰期写脚本按前缀批量扫出来加过期时间。
2.2 过期机制的三个隐藏细节
第一个细节:惰性删除 + 定期删除。Redis 不会实时盯着你的 Key 等它过期,它靠的是定期随机抽查一部分过期 Key 删除,外加每次访问时检查当前 Key 是否过期。这意味着一个已经到期的 Key,如果没人访问,它可能还会在内存里躺很久(最多到定期删除扫描到它)。
第二个细节:过期 Key 在 master 和 slave 上的表现不一致。在 Redis 集群主从架构下,过期删除是由 master 发DEL命令同步给 slave 的,slave 自己不做主动过期。如果 master 挂了,slave 还没来得及处理DEL同步就升为 master,那理论上应该消失的 Key 会“短暂复活”,而且新 master 上它的 TTL 可能变了。这就是分布式锁场景里经典的“锁提前失效/永久不失效”问题来源之一。
第三个细节:别把EXPIRE设置当成一劳永逸。业务更新了同一个 Key 并重新SET,TTL 会被清掉。很多人改完数据忘了重设过期时间,结果 Key 就变成了永久 Key,内存压力就是这么一天天堆起来的。写代码时我一般建议用统一的缓存工具类封装,SETEX、SET key value EX seconds这类带过期时间的原子命令是默认选项。
3. 分布式锁:主从切换那一刻的“锁丢失”
3.1 简单 SETNX 锁为什么会在极端场景下失效
网上有一堆 Redis 分布式锁教程,最经典的实现是SET lock:order unique_value NX EX 30。这套逻辑在单实例下没问题,但在主从架构(或哨兵架构下)有个著名的坑:如果锁写入 master,master 还没来得及把这条数据同步给 slave 就宕机了,哨兵把 slave 提升为新的 master,其他线程就能对新 master 再次加同一把锁。旧持有者还在跑业务,新持有者也进来了,互斥直接失效。
这个问题的严重程度取决于你的业务对互斥的容忍度。如果只是防止缓存击穿时重复查库,问题不大;如果拿它做金融级别的幂等控制,那就是事故级别。
还有一种更常见的锁失效场景:锁过期时间设置太短,业务执行没跑完锁先没了。我见过真实案例,一个定时任务平均执行时间 20 秒,锁过期时间设了 10 秒,结果两个节点同时执行,产生了双份数据。排查日志时崩溃了,代码逻辑完全正确,但就是脏数据。
3.2 加锁过程的完整校验与 Lua 原子性
正确姿势至少要做三步:
第一步,加锁时用唯一随机值作为value。加锁命令应该是:
SET lock:order b3f0xyz123 NX EX 30第二步,释放锁时不能无条件DEL。要用 Lua 脚本先比对 value 再删除,防止自己超时后,别人加的锁被自己误删:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end第三步,对执行时间不可控的业务,要么给锁加一个“看门狗”续期机制(这也是 Redisson 里watchdog的实现思路),要么接受“锁过期 + 业务超时”的极端场景,在业务侧做幂等兜底。我个人实践中更推荐“缩短业务执行时间 + 精确设置 TTL + 兜底幂等校验”三个一起上,完全指望续期机制反而把系统搞复杂。
经验:分布式锁在 Redis 里不是“银弹”。能用数据库唯一索引解决的互斥,就别上锁;必须上锁的,一定要识别出“锁提前失效”的业务后果是什么,能不能补偿。
4. 缓存穿透、击穿与雪崩:三兄弟一个比一个狠
4.1 穿透:查了一个根本不存在的 Key
缓存穿透就是大量请求查询一个缓存里没有、数据库里也不存在的数据。比如恶意用户疯狂用不存在的 ID 打接口,缓存永远查不到,请求全部落到数据库,数据库连接被打满。
最经典的解决方案是三层:第一层参数校验,非法 ID 直接拒绝;第二层缓存空值,比如查不到商品就缓存一个“空标记”30 秒,告诉下游这个 ID 没有商品;第三层布隆过滤器,启动时把所有合法商品 ID 全部 hash 到一个 bitmap 里,请求来了先过布隆,查不到的直接返回,不过数据库。布隆过滤器有误判率,但它只会误判“存在”而不会误判“不存在”,所以不会把真实数据挡掉。
我个人的建议是:空值缓存是最省事且见效最快的,布隆过滤器适合数据量特别大、ID 连续性不明显的系统。不要一上来就追求过滤器,先看业务规模和异常请求量。
4.2 击穿与雪崩:热 Key 过期的连锁反应
缓存击穿是指某个特别热的 Key 正好在过期的那一瞬间,来了大量请求,缓存没挡住,全打到数据库。雪崩则是大量 Key 在同一时间段集体过期,数据库瞬间扛不住。前者是单点热 Key,后者是群体性过期。
解决击穿最简单的方式是“互斥锁重建缓存”:发现缓存过期后,先让一个线程去查库重建缓存,其他线程暂时阻塞等待。重建完以后大家都走缓存,数据库压力只有一次查询。
解决雪崩最简单的办法有两个:第一个是过期时间加随机数,比如 TTL 设为 5 分钟加 0 到 60 秒的随机值,避免整片 Key 同步死亡;第二个是热点数据永不过期 + 后台异步刷新,访问时发现数据快过期就主动去刷新缓存,这适合读多写少的高热场景。
这里有一个容易忽略的细节:如果用了“互斥锁重建缓存”,重建期间其他线程不能真的无限阻塞,要设置一个等待超时,超时后放行去查库(然后承受一次小规模的并发击穿),保全整体可用性。我在实际项目里的做法是:等待 200ms,拿不到锁就强制查库返回,同时记录告警日志,靠“短暂击穿一次 + 监控告警”换“不长时间阻塞”。
5. key 序列化:你看得懂的代码,Redis 读不懂
5.1 序列化混用:明明都是 Redis,数据却是“天书”
很多团队的 Redis 缓存会同时被 Java、Go、Python 多个服务读写。Java 里默认的JdkSerializationRedisSerializer会把对象序列化成一坨以\xAC\xED\x00\x05开头的二进制数据,里面包含了类名、包名、序列化版本号。这玩意儿 Java 自己反序列化没问题,但 Go 的服务去读,直接解析失败。更可怕的是,同一个 Key 如果先被 Go 写成了 JSON 格式,Java 再去读,反序列化直接报错。
这个问题的隐蔽性在于:单服务开发时一切正常,一旦跨语言协作,或者切换了序列化框架,线上数据就全乱套了。排查的时候你会看到缓存数据变成了一堆乱码,完全不像 Redis 该有的样子,甚至敲命令都看不出问题。
5.2 统一序列化方案与版本兼容策略
我的建议是:团队内部约定统一的序列化协议,默认用 JSON。Java 侧用GenericJackson2JsonRedisSerializer,带上@class类型信息;Go 侧就用标准的encoding/json。如果非要存对象,就存合理粒度的 DTO,不要直接把实体类怼进缓存,这样既减少冗余字段,也降低反序列化失败的风险。
对于已经存在的“历史遗留序列化混乱”数据,不要试图读出来转格式,直接让缓存失效重建。给 Key 加个新前缀(比如v2:),让新旧数据自然过渡,比在线上写“数据转换脚本”安全十倍。
顺带提醒一个细节:反序列化失败不等于 Redis 不可用。很多时候你的服务日志里报Could not read JSON,但 Redis 本身响应正常,问题出在代码里的RedisTemplate配置上。排查时先看一眼缓存内容长什么样,再决定是清缓存还是改配置。
6. RDB 与 AOF 持久化:夜里 fork 一下,主线程就“抖”一下
6.1bgsave与fork的噩梦账单
Redis 的持久化默认是 RDB 快照,触发bgsave时需要fork一个子进程来拷贝内存页表。fork过程本身在 Linux 上是“写时复制”的,理论上不会真正拷贝全量内存,但它有个躲不开的开销:fork 的那一瞬间,操作系统要扫描父进程的全部内存页表,内存越大,这个耗时越长。一个 30GB 的 Redis 实例,fork可能阻塞 300 毫秒甚至 1 秒。
别小看这一秒。对高 QPS 的业务,主线程阻塞 1 秒的含义是:所有请求排队 1 秒,超时一大片。更惨的是,如果业务触发了一次手动SAVE(同步持久化),那阻塞时间直接翻倍,堪称事故级操作。
6.2 持久化策略怎么配才不会半夜炸
生产环境我的配置原则是三个字:能不要就不要。如果 Redis 纯粹做缓存(挂了可以重建),我建议彻底关闭 RDB 和 AOF,把save ""写进配置,内存靠缓存重建机制自愈。这能省掉 fork 阻塞、磁盘 IO 两个大坑。
如果必须做持久化(比如做 session 存储或临时数据存储),那就只开 AOF,并且选appendfsync everysec。这个配置每秒钟把写命令刷一次磁盘,最多丢 1 秒数据,性能影响可控。不要开always,那等于每个写命令都 fsync,Redis 性能直接腰斩。
RDB 和 AOF 同时开着不是不可以,但你要理解它们各自会在什么时候触发写盘,低峰期还好,高峰期一旦同时碰上bgsave+ 业务写入高峰,IO 延迟告警几乎是必然的。我见过最惊险的运维操作,是在晚上流量高峰期手动敲了个BGSAVE,然后整个集群的延迟从 2ms 飙到 50ms。记住这条铁律:线上不要在高峰期做任何手动持久化操作。
排查 fork 阻塞可以用:
redis-cli INFO stats | grep latest_fork_usec这个值如果超过 100 万(微秒,即 1 秒),那你这个实例的内存规模对单机来说已经偏大了,考虑拆分为 Redis Cluster 或者用多个小实例分摊内存。
7. 慢查询:不是 Redis 慢,是命令太“重”
7.1KEYS *就是一把“全表扫描”的冲锋枪
KEYS *这个命令,你在本地两万 Key 的环境跑没感觉,在线上全量几百万 Key 的实例上跑一次,直接阻塞主线程几百毫秒。它就是数据库里的SELECT * FROM table,而且是没加 LIMIT 的那种。
很多人会问:那我想遍历 Key 怎么办?答案是SCAN。它每次返回一小批 Key(默认 10 个),配合游标游走,不会阻塞主线程。但注意,SCAN的循环条件不能想当然,你要用一个循环去取完整游标:
redis-cli --scan --pattern 'user:*' --count 100--count可以调大,但别太贪,100 到 1000 之间比较合适,太大照样会引起瞬时延迟。
7.2 慢查询日志怎么用起来
Redis 自带慢查询日志,两个配置要打开:slowlog-log-slower-than(微秒阈值,默认 10000 微秒 = 10ms)和slowlog-max-len(保留多少条日志)。我一般会把阈值调到 5000 微秒(5ms),抓更细的慢命令。
排查慢命令用:
redis-cli SLOWLOG GET 10它会把最近 10 条慢命令原样打出来,包括命令参数、耗时、时间戳。看到耗时长的命令再针对优化:如果是MGET打不过来的批量请求,拆成小的批次;如果是ZRANGEBYSCORE干了一大批排序,看能不能用别的结构替代。
隐藏的坑在SMEMBERS这类命令:取整个 set 的所有成员。平时只有几十个元素没问题,如果 set 长了,这个命令耗时是 O(N),线上很容易直接被一个脚本拉满。更安全的替代方案是SSCAN分批拿。
8. 热点 Key 与连接管理:被流量冲垮的“单点瓶颈”
8.1 热 Key 把单节点 CPU 打到 100%
热点 Key 问题是 Redis 架构里最难根治的问题之一。某个商品秒杀、某个热搜话题、某个明星动态,一瞬间同一 Key 的 QPS 暴涨,Redis 单实例的 CPU 被打满,所有读写延迟飙升。
常规解法有几个方向:
第一,本地缓存(多级缓存)。在应用内存里缓存一份热点数据,设置很短的过期时间(比如 1 秒),命中后根本不查 Redis。比如 Java 侧用 Caffeine,Go 侧直接用 sync.Map 存一下,把热点流量挡在应用层。这个方案最有效,副作用是每台机器会短暂持有旧数据,但热点场景通常能接受秒级延迟。
第二,热 Key 的复制 + 随机后缀。把hotkey复制成hotkey:1、hotkey:2、hotkey:3,写的时候同步写多份,读的时候随机挑一个 suffix。这样流量分散到多个 Key 上,垂直打散单 Key 的压力。缺点是需要业务代码配合,且数据一致性要自己保证。
第三,监控与告警。在没有出现故障前就要有盯着热 Key 的手段。redis-cli --hotkeys可以扫出访问频率最高的 Key,配合监控平台看CPU和keyspace_hits。
8.2 连接池满:Redis command timed out的真相
线上最常见的报错就是这句:Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。很多人第一反应是“给连接池加连接数”,但大多数情况下真正的原因是连接池被打满 + 有慢命令阻塞了连接释放,或者服务端已经不堪重负,请求排队超时。
排查思路不能盲目:
第一步看服务端,redis-cli INFO stats里的connected_clients和blocked_clients,如果connected_clients异常高,说明有连接泄漏的风险。INFO commandstats看高频命令,如果某个命令的调用次数特别大,可能就是它把连接占住了。
第二步看客户端侧,检查连接池配置。Lettuce 默认超时是 10 秒,如果你用的是Jedis则看timeout参数和maxTotal。我遇到过一个经典场景:maxTotal设成 500,单机 20 个实例,峰值流量上来每实例需要 30 个连接,理论上够用,但某个业务查询在 Redis 上耗了 3 秒,连接被占用,其他查询就只能等超时。把连接池上限调大只是延缓问题,真正的解法是找到那个 3 秒的慢命令。
9. 连接与资源泄漏:每个“疑难杂症”背后都有一个关闭失败
9.1 忘了close()的客户端连接池泄漏
Java 用 Jedis 的时候,经常有人手写jedis.getResource()用完了却忘记close()。这里的close不是真正的 TCP 关闭,而是把连接归还给连接池。如果你不归还,连接池的可用连接越来越少,最终全部耗尽。
排查方法是看服务端INFO的连接数,connected_clients超过几百并且持续增长不下降,基本可以断定有连接泄漏。逐个服务排查,重点看线程池里的请求是不是“吃完不吐”。
Go 和 Python 也有类似问题:Go 的redis.Client是连接池管理的,一般不用手动释放,但从连接池Get出来的redis.Conn必须要Close,否则连接数照样涨。
9.2 无界队列与背压:超时的根本不是 Redis
另一个容易忽视的点是:你的服务在 Redis 变慢时,自身也会跟着变慢,然后线程池队列堆满。Redis 的 timeout 报错只是表象,真正的元凶是服务端线程池满了、任务在排队。
处理方式就一句话:给 Redis 调用加上“有界信号量 + 快速失败”。每个业务接口的 Redis 调用,加上合理的超时和并发限制,Redis 慢的时候宁可快速返回失败,也不要让全部线程趴在 Redis 上等。这也是后端服务设计里的背压思想:不让一个环节的抖动拖垮整条链路。
10. 可视化客户端的“隐形手雷”:你怎么死的都不知道
10.1 刚打开 Redis Desktop Manager,顺手一删
Redis Desktop Manager 和 Another Redis Desktop Manager 这类可视化工具,极大地方便了调试,但也带来了一些意料之外的问题。最经典的场景是:线上排查问题时,你想看看某个 Key 的值,于是打开工具连上生产 Redis,输了个KEYS *。这个操作在可视化工具里只是一个按钮,但工具后台执行的就是KEYS *,全量实例直接被拖慢。
另外一个坑是可视化工具默认连接是走的普通 TCP,不代表生产环境就能直连。生产环境的 Redis 一定要设置访问控制,即便在内网,也不应该让每个开发者都能直接连上去查数据。我之前就遇到过,一个同事用可视化工具连上生产环境,清理“疑似无用”的 Key,一晚上删掉了三套业务正在用的数据,第二天业务全废了。
10.2 可视化工具的正确使用姿势
我对可视化客户端的建议是:调试环境随便用,生产环境只能用只读账号。Redis 6.0 以上支持 ACL,可以创建一个只读用户,限制只能执行GET、MGET、HGETALL等只读命令,没有DEL、FLUSHALL、KEYS的权限:
redis-cli -a password ACL SETUSER readonlyuser on >pwd ~readonly +@read +@connection生产环境连接工具的账号必须配成这个只读用户,从工具层面断绝误操作的可能。另外,排查生产数据尽量用redis-cli命令行,配合SCAN而不是KEYS,并且把它放在跳板机上操作,不要从你本地 Mac 直接连生产 Redis。
写在最后
这些“隐形陷阱”里,最讽刺的是:每一个在事发前都显得完全“正常”。大 Key 平时读写没感觉,慢查询日志没去看就一直没人知道,连接池数字只要不到 100% 就没人关心。Redis 的黑暗森林,其实就是“默认配置 + 失控增长 + 缺少监控”这三件事的合谋。
我个人处理事故时的习惯是:先把INFO、SLOWLOG、MONITOR三个工具抓起来,再看业务代码,最后才碰配置。因为大部分 Redis 事故都不是 Redis 本身坏了,而是你敲给它的命令、存进去的数据、设好的过期时间,在某个特定流量形态下恰好变成了炸药。
这里没有放之四海皆准的“最佳配置”,只有一套结合业务特点的持续治理预案。希望你读到这里时,能想起你自己的 Redis 里,是不是也藏着某个让你失眠的隐形定时炸弹。