Redis系列写到第四章,开头先说句实在话:越往后写,越觉得入门容易、排雷难。上篇把基础数据类型和常用命令讲完,这套东西其实已经够你应付日常开发了,可一旦上了生产环境,你很快就会见到各种“怪问题”——明明代码没变,Redis却突然慢得像龟爬;明明数据量不大,内存却快被打满;明明缓存设了过期时间,数据库却还是被冲垮了。这一篇就是专门来收拾这些破事的。
这篇适合谁看?已经会用set、get、expire,能把缓存跑起来,但线上出问题之后只能靠重启解决的同学;也适合那些正准备做系统压测和性能调优,想提前把坑踩明白的后端工程师。我下面的所有内容都来自实际排障场景,每一步都比较啰嗦,因为踩坑的时候我也是一步步查出来的,直接给你结果不如把思路一起给你。
1. 这一篇的定位:不是入门,是排雷
1.1 从“能跑”到“跑得稳”差在哪
很多团队对Redis的使用停留在“缓存 + 分布式锁”这个层面,代码写了、能跑通、压测没爆,就觉得万事大吉。但线上环境和本地是完全不同的两种生物:本地就你一个人访问,缓存失效顶多请求数据库一次;线上是几千个请求同时涌过来,热点key一过期,数据库瞬间就被打穿。本地内存随意用,线上每个实例给多少内存都是精打细算的。本地Redis崩了重启就行,线上主从同步断了、数据丢了、哨兵误切换了,每一件都可能变成事故。
所以下篇的核心就一个字:稳。围绕“稳”这个目标,我把常见问题拆成了几条主线:缓存异常、大Key和热Key、持久化与淘汰、缓存一致性、高可用集群、日常排障。这六条线覆盖了我见过的绝大多数Redis生产事故,你把这几个方向理清楚,至少不会再出现那种“明明用着Redis还是被拖垮”的情况。
1.2 排查实战问题前需要建立的几个观念
先说几个我在实际工作中形成的观念,这些观念比具体命令管用:
第一,Redis不是万能加速器,它天生的定位是“缓存 + 短暂数据存储”,所以别指望它帮你抗下一个毫无节制的业务设计。第二,出问题先看监控,不要瞎猜。很多同学一遇到Redis慢就先怀疑网络或机器,结果查了半天发现是有一条特别大的key在作祟。第三,所有参数调整都要有记录,尤其是内存淘汰策略、AOF刷盘策略这些,改完了一定要写清楚改动原因、预期效果、改动时间,不然下一个接手的人会疯。
有了这三个基础观念,下面这些具体问题就不再是零散的知识点,而是一套可以串起来的排障逻辑。
2. 缓存穿透、击穿与雪崩:三大经典故障现场
2.1 缓存穿透:查询不存在的key
先讲缓存穿透。这个问题说起来特别简单:Redis缓存的是热点数据,如果一个请求查的数据在缓存里没有,就会直接打到数据库,假设这个数据在数据库里也不存在,那这次查询就不会写回缓存。攻击者如果拿一堆不存在的ID来刷接口,缓存永远形同虚设,数据库会被一波又一波的空查询淹没。
我在实际排查中遇到过最典型的一次:一个查询商品详情的接口,传了一个被删掉的商品ID,因为缓存没值、数据库也没值,每次请求都穿透到数据库。本来这个接口设计的时候没太在意,结果有人写了一个爬虫脚本,几分钟内把数据库连接池打满了。
处理方式有几种。最简单的是缓存空值:对不存在的key也写入一个空值或特殊标记,过期时间设短一点,比如60秒,这样同样的请求在缓存里就被拦截住了。还有一种更干净的做法是布隆过滤器,把所有可能存在的ID预加载到布隆过滤器里,查询之前先走过滤器判断,不存在就直接返回。布隆过滤器的延迟和内存开销都很小,缺点是会误判(有一定概率把不存在的当成存在),但对于拦截穿透来说完全够用。
不过要注意,布隆过滤器在Redis里需要额外模块支持,如果你不想引入,缓存空值方案是最省事的。我这里再补一个细节:空值的过期时间别和正常缓存一样,正常缓存可能是30分钟,空值建议只设1到2分钟,避免大量不存在的key堆积成另一种“缓存污染”。
2.2 缓存击穿:热点key过期瞬间的惊群效应
缓存击穿和穿透容易被混在一起,但成因完全不同。穿透是因为“没有这个数据”,击穿是因为“本来有,但是刚刚没了”。具体来说,一个热点key到了过期时间,缓存里没了,此时成千上万个请求同时打过来,全部穿透到数据库。和穿透相比,击穿的伤害更集中,因为热点key的访问量本身就很大。
我见过最严重的一次击穿是首页推荐位的数据,配置部门每天早上准点刷新,所有用户一打开App就会触发同一个key的缓存重建,结果那个key刚好过期,数据库被瞬间打满。那次事故给我留下的教训是:热点key的过期时间一定不能整点、整分,甚至不能是固定值,要带一点随机抖动。
解决击穿的主流方案有两个。第一个是互斥锁,也就是缓存过期之后,只允许一个线程去重建缓存,其他线程先等待一小会儿再重新查询缓存。缺点是实现稍复杂,而且极端情况下会增加一些请求延迟。第二个是逻辑过期,缓存里不设置真正的TTL,只在value中存一个过期时间戳,每次读的时候判断是否过期,过期之后返回旧值,同时后台异步去刷新缓存。优点是读操作不会被阻塞,能扛住极端流量,缺点是实现复杂度高,而且会短暂读到旧数据。
如果是中小规模系统,我建议先用互斥锁方案,代码好抄、逻辑好解释。大流量场景再用逻辑过期,但一定要考虑好异步刷新的线程池和队列大小,别让刷新任务积压成一个新的隐患。
2.3 缓存雪崩:一大批key集中失效
雪崩最怕的不是单个key,而是一大片key在同一时间集体消失。比如说,你批量设置了缓存,全部用相同的30分钟过期时间,那么每隔30分钟就会出现一次缓存集体失效,数据库压力突然暴涨。这和击穿的区别在于:击穿是“单个热点key”,雪崩是“多个key甚至全量key”。
有一次我接手一个老项目,看到代码里缓存过期时间全部写死成了一个常量,当时就觉得这是个雷。结果上线后的第一个整点,数据库连接数直接翻了三倍。还好当时数据库负载还有点余量,不然就是事故。
解决雪崩的办法我总结为三层:时间层、存储层、兜底层。时间层最简单,过期时间加上一个随机范围,比如基础30分钟,再随机加0到300秒,让失效时间分散开。存储层可以引入多级缓存,比如本地应用缓存 + Redis,本地缓存通常设5分钟左右,Redis设30分钟,这样即使Redis整片失效,还有一个本地缓存顶一下。兜底层就是接口层面做限流和熔断,数据库压不住的时候让请求快速失败,而不是积压成批量超时。
这里说一个容易踩的坑:很多人会给同一批key设置同样的“基础过期时间”,然后加随机时长的代码写在初始化缓存的方法里,看起来加了随机,实际上因为并发创建都是在同一时间开始的,随机值的分布并不够散。更稳妥的做法是按业务维度把基础过期时间错开,比如用户维度是20分钟,商品维度是40分钟,再叠加一个小随机。
3. 大Key与热Key:慢查询和宕机的隐形推手
3.1 如何快速定位BigKey
如果说缓存失效只是“数据库有压力”,那么大Key问题就是“Redis本身在崩溃的边缘”。所谓大Key指的是单个key存储的value过大,比如一个list里有几百万条数据,或者一个hash里有几十万个字段,或者一个value就是几十MB的字符串。大Key的危害比你想的要严重:读写大Key会阻塞操作;删除大Key时如果用的是DEL,可能会阻塞Redis主线程;在主从复制时,大Key也会导致从节点同步延迟。
定位大Key有一个简单但有效的手段:Redis自带的redis-cli命令里有一个--bigkeys参数,它会遍历整个Redis的key空间,按类型统计出最大的那几个key。命令长这样:
redis-cli --bigkeys这个命令内部其实是用SCAN分批扫的,不会像KEYS *那样直接卡死Redis,所以生产环境基本可以放心用。但它只能查询比较大的key,对内存的精确分析还是不够。想更细一点,可以用DEBUG OBJECT这个命令,它能返回指定key的内部编码和序列化长度。还有一个MEMORY USAGE命令,可以查看某个key占用的实际内存:
redis-cli -p 6379 memory usage key_name不过注意,MEMORY USAGE在某些版本和编码类型下会比较耗资源,一般排查时再用,不建议写进监控脚本每分钟跑一次。
3.2 大Key治理的几种落地做法
找到大Key之后不能只知道它大,得想办法处理。我的建议是按业务场景分类:
如果是一个大value,比如一个大JSON字符串,可以先看能不能拆分字段,用hash结构替代,把一个大value拆成多个小字段。如果业务上没法拆,那就考虑压缩,比如JSON转ProtoBuf,或者开启压缩算法,这样在Redis里存的数据会小很多,但CPU开销会上升,需要权衡。
如果是一个大集合,比如list或set有几十万甚至上百万元素,通常可以用分批读取的方式处理,比如LRANGE每次只取几百条;如果需要长期保存,可以考虑把集合迁移到其他存储里,Redis只保留一个短期的摘要。
最要命的是删除大Key。Redis 4.0之前用DEL删除大key时主线程会卡住,4.0之后引入了UNLINK命令,它可以异步地在后台线程释放内存,把删除操作丢回给主线程的耗时减到极小。生产环境一定要养成用UNLINK删大key的习惯,尤其是清理过期数据或者下线功能的时候。
3.3 热Key的识别与拆分思路
热Key是另一个方向的“大问题”:key不是大,而是被访问得太多。比如某明星出了绯闻,粉丝全都在刷同一个用户信息key,这个key的QPS可能瞬间到几十万。热Key的危害是:单节点Redis的CPU被打满,请求超时,甚至导致主从切换后又马上切换回来,形成抖动。
识别热Key比较常用的方法有几种。第一种是用redis-cli自带的热Key检测参数(Redis 4.0以后),但需要设置内存淘汰策略为LFU。第二种是打开MONITOR命令观察实时命令,但生产环境跑MONITOR有点重,会额外增加网络和CPU开销,一般只在短时间排查用。第三种也是最推荐的:在客户端做统计,比如所有访问Redis的入口统一拦截,每秒对key做一次频次统计,超过阈值就报警。
处理热Key的思路通常是“拆”和“挡”。拆指的是把单个key拆成多个副本,比如原来的key是user:10001:detail,可以拆成user:10001:detail:0、user:10001:detail:1,然后在客户端或网关里按照某个规则随机访问其中一个副本。注意,拆的前提是数据只读,或者你能接受一定的数据延迟。挡指的是在Redis前面加一层本地缓存,把热点key在应用进程内存里缓存几十秒钟,这样Redis的热度一下子就被削减了。
我自己遇到过最夸张的场景是某个活动页的配置key,几万QPS打在同一个key上,单实例RedisCPU直接飙到100%。后来用本地缓存挡了10秒,Redis负载直接降到正常水平。
4. 持久化、过期与淘汰策略:内存不乱,数据不丢
4.1 RDB与AOF怎么选:丢多久的数据能忍
持久化往往是Redis问题上最容易被忽略的一块,因为本地开发基本不会重启Redis,而生产环境一旦重启,就可能出现“缓存全没了”或者“数据只剩一半”的情况。Redis持久化有RDB和AOF两种,RDB是快照,AOF是追加日志。
RDB的好处是恢复快,文件紧凑,适合做备份和冷备;坏处是如果Redis发生异常宕机,最后一次快照之后的数据都会丢。如果快照时间设得很频繁,又会因为fork进程拷贝内存导致卡顿。AOF的好处是数据丢失少,最多丢一两秒(取决于刷盘策略);坏处是文件会比RDB大很多,而且恢复速度慢。实际上现在主流版本默认开启了混合持久化,RDB作为基础快照,AOF记录增量,兼顾恢复速度和数据量。
选型建议很简单:如果你只是拿Redis当纯缓存,数据丢了可以从数据库重建,那RDB就够了,甚至可以不定期手动bgsave做备份;如果Redis里存了稍微重要一点的数据,比如会话、计数、排行榜,建议用AOF,并在配置里设置appendfsync everysec,等于在“最多丢一秒数据”和“性能损耗可控”之间取平衡。
这里有个我反复遇到的坑:很多人以为开启了AOF就绝对保险,其实AOF也有rewrite的过程,如果AOF文件增长太快,每次BGREWRITEAOF会重新fork,内存占用可能翻倍。所以在内存紧张的环境下,别把rewrite阈值配得太低。
4.2 过期键的被动删除与主动删除
很多同学以为过期键是“一到时间就被删掉”,实际上Redis删除过期key有两种方式。一种是惰性删除,也就是key明明过期了,但只要没人访问,它就还在内存里躺着。另一种是定期删除,Redis每隔一段时间随机抽查一批key,把过期的删除掉。
这两个机制组合起来的意思就是:过期key不会立刻被物理删除,短时间内会占内存空间。如果你在短时间内写入大量带过期时间的key,然后又都过期了,但访问量很少,那它们会攒在内存里等定期删除来消化。所以当你发现内存没降下来的时候,不一定是内存泄漏,可能只是定期删除还没跑到它们头上。
这又牵扯出一个备案:短生命周期的高频key不要建太多,比如每一秒生成一次缓存key,生成后一小时内过期,那这个Redis里马上就会堆积大量“等待被删除”的key。我在实际项目里就见过一天生成上百万个短key,Redis内存飙升,后来通过调整业务设计,把短key合并成hash结构,内存立刻降下来一大截。
4.3 内存淘汰策略该设成什么
内存淘汰策略可能是Redis配置里最能“救火”的一项。所谓淘汰,就是当Redis内存达到maxmemory之后,新写入的数据如何处理。默认是noeviction,也就是写不进去,直接报错,很多线上事故就是这么来的——Redis内存满了,业务一写入就报OOM异常,然后整个服务都被拖慢。
最常见的正确姿势是结合具体业务:如果Redis主要用来做缓存,那我建议设置allkeys-lru或者allkeys-lfu,让Redis在内存满的时候自动淘汰最不常用的key。如果是Redis 4.0以上版本,优先allkeys-lfu,因为LFU比LRU更能抵抗“一阵子热点”带来的误淘汰。如果Redis里有一些数据是不能丢的、必须占住的,只用volatile-lru,只淘汰设置了过期时间的key。
这里有必要解释一下为什么用LFU而不是LRU。LRU只看最近访问时间,一个key如果昨天很火,今天没人碰了,它可能还是被当作“常用key”留了下来。LFU统计的是访问频率,一个昨天火、今天凉的key会慢慢降权,更符合缓存淘汰的直觉。缺点是LFU需要额外维护频率数据,会多占一点内存,但这点开销完全值得。
5. 缓存一致性的几个常见方案:先接受“最终一致”
5.1 不一致是怎么发生的
“Redis缓存和数据库数据不一致”是我在面试里最爱问、也是线上出问题最多的一类场景。想象一下:先更新数据库,再删除缓存。如果更新数据库之后、删除缓存之前,这个节点宕机了,缓存里还是旧数据;或者你选了“先删缓存,再更新数据库”,删完缓存、还没更新数据库的时候,另一个线程读缓存发现没有,从数据库读到旧数据写回缓存,那缓存就永远留着旧数据了。
很多人一说缓存一致性就想要“强一致”,但Redis作为分布式缓存,强一致在实现上代价非常高。现实的思路是:接受最终一致,然后把不一致的窗口尽量缩小。
5.2 常见方案对比与一条实用链路
方案大差不差就是三类。第一类是“先更新数据库,再删除缓存”,这是最省事也很符合直觉的路径。为什么删缓存而不是更新缓存?因为缓存很大概率不是数据库的完整映射,更新缓存要算出一大堆字段组合,不好维护;而删缓存让缓存下次加载时自然重建,代码简单很多。第二类是延迟双删,也就是更新完数据库后,先删一次缓存,过几百毫秒再删一次,用来解决并发读请求把旧数据写回缓存的窗口。第三类是异步订阅数据库的binlog变更事件,让消费程序来删缓存,这个方案把Redis的删除逻辑解耦出来。
我自己比较推荐的实用链路是:先更新数据库后删缓存,加上偏短的缓存过期时间做兜底。比如缓存过期时间设成10分钟,就算中间有几次删除失败了,最多也就再忍受10分钟的旧数据。如果你对一致性要求更高,就在删缓存失败的地方加一张重试表,让后台任务定期把删除失败的记录重删一次。这套方案实现成本低,出问题也好解释。
还有一个经常被忽略的问题:删除缓存时用的命令。有些同学更新完缓存后直接set新值,这样数据库一旦回滚,缓存里就是一笔未经确认的数据。所以我的习惯永远是“删除”,而不是“更新”。删错了最多重新拉取,更新了就留下一个脏数据,后面排查起来特别难。
6. 高可用与集群里容易被忽视的问题
6.1 哨兵模式的典型坑
很多中小团队用Redis主从加哨兵来做高可用,原理是哨兵监控Redis主节点,发现主节点挂了就自动将从节点提升为主节点。这个架构本身没问题,但我在实际维护中踩过几个坑。
第一个坑是client端的处理。Java里如果用的是Jedis,它自带的哨兵模式能在主从切换后重新发现新主节点,但如果你是自己封装了一层连接池,切换之后连接池里还是老地址,就可能导致瞬间大量写入失败。所以选客户端的时候一定要确认它支持哨兵模式自动拓扑刷新。
第二个坑是哨兵数量。三台哨兵是最小的合理规模,两台哨兵可能出现“判主节点挂了”的投票平票问题,结果该切换不切换。这个不是配置能完全解决的,节点的数量必须保证奇数,且大于1。
第三个坑经常出现在切换后。旧主节点恢复后如果配置没处理好,可能自动重新加入集群变成从节点,这时候要确认它不会因为延迟导致全量同步,或者因为数据分叉污染新的主节点。Redis的repl-backlog-szie参数我会适当调大,宁可占用一点内存,也要让短暂断连的从节点通过增量同步补上,而不是动不动就全量同步。
6.2 Cluster集群的数据倾斜和连接开销
如果数据量大到单实例或者主从扛不住,就得用Cluster集群。Cluster的好处是数据自动分片,理论上可以横向扩展。但有一个天然问题:不同的key会有不同的访问热度,可能所有热点都集中在某个槽上,那个节点就变成了新的瓶颈。这就是数据倾斜。
解决数据倾斜的思路分为两部分。一个是散列设计,把原本会集中在一个key的数据拆成多个带后缀的key,让它们天然散落到不同节点。另一个是热点识别,如果某一个key特别热,单独给这个key做本地缓存或者把它的副本均匀分布到多个节点。
还有一个很多人会忽略的点:Cluster的跨slot命令限制。Redis Cluster里面,MULTI事务和Lua脚本都要求涉及的key必须在同一个slot里,否则会报CROSSSLOT错误。如果你在本地单机测试没事,上了集群就报错,大概率就是踩到了这个限制。解决方法是使用hash tag,比如让多个相关key共享user:10001{}这样的公共片段,这样它们就会被哈希到同一个slot。
连接层面也要注意,Cluster模式下客户端需要维护到每个节点的连接,如果请求分散,连接数会远多于单实例。很多同学在服务启动时就直接报“Cannot get connection from pool”,就是因为连接池的配置是按单实例估的,没有把集群节点数量乘以进去。
7. 常见问题速查表与排查思路
7.1 连接与性能问题速查
这一节我把日常遇到的典型问题整理成一个速查表,方便你照着排查。
| 现象 | 可能原因 | 快速排查思路 |
|---|---|---|
| 应用报连接超时 | 连接池耗尽 / Redis节点负载过高 | 查连接池监控,确认是否被慢查询阻塞;用INFO commandstats 看命令耗时 |
| Redis实例CPU接近100% | 热Key / 高复杂命令 / 密集的过期回收 | MONITOR短时间抓取命令;排查热点key;检查是否有大量带短TTL的key |
| 写入报OOM command not allowed | 内存达到maxmemory且淘汰策略为noeviction | 先看内存使用率,调整淘汰策略为allkeys-lfu或volatile-lru |
| 从节点报READONLY | 主从切换或读请求写到了只读副本 | 检查应用连接是否指向只读节点,确认哨兵/Cluster拓扑刷新是否生效 |
| 命令执行很慢 | 大Key操作 / 大量KEYS模糊匹配 / 内存换页 | 用--bigkeys查大key;把KEYS换成SCAN;观察是否发生fork |
| 缓存和数据库不一致 | 更新策略不当 / 删除缓存失败 | 检查更新链路,补充延迟删除或重试机制,设置兜底过期时间 |
这个表不是银弹,但能帮你把“不知道从哪里查”变成“先查这六项”。
7.2 数据安全与日常巡检
最后一个板块,说说日常运维的数据安全问题。很多团队把Redis当纯缓存,不太关心数据备份,结果Redis重启之后,缓存里的会话、验证码、未同步的计数模块全没了,用户被迫重新登录,前面好几天的数据也找不回来。
我个人的建议是,即使Redis只用来做缓存,至少也要每天自动执行一次bgsave,把RDB文件留到异地备份。存了相对重要数据的Redis,AOF必须开。如果你的Redis里存了某些敏感信息,比如用户手机号、身份证号,连接必须走带密码的访问,同时设置rename-command禁掉KEYS、FLUSHALL这种高危命令。
日常巡检方面,我习惯每个星期做一次基础检查:打开INFO memory看内存碎片率,如果超过1.5就要考虑重启或者调整内存分配;用redis-cli --bigkeys扫一遍大key,看有没有新出现的异常数据;用SLOWLOG检查慢查询,把超过100毫秒的命令挑出来分析。另外,还有个很实用的数据指标:键的数量和过期键比例,这两个数字一旦出现异常增长,说明某个业务逻辑在无意识地制造垃圾数据。
最后再分享一个小技巧
排查Redis问题时,我最常用的启动动作不是调配置,而是先打开INFO all,把Server、Clients、Memory、Persistence、Stats、Replication这些模块的输出全部拉一遍,从头到尾看完再决定怎么处理。很多问题在INFO里就有非常直接的征兆,比如某个节点的rejected_connections在增长,说明连接数要爆了;比如sync_full次数突然增加,说明主从一直在做全量同步;比如mem_fragmentation_ratio很高,说明内存碎片严重。看懂了这些指标,排查问题就像看仪表盘,而不是瞎猜。
说到这儿也顺带提醒一句,Redis是一个值得花时间做性能体检的系统,生产环境别等到真的出了故障才想起它。我个人的习惯是每个月固定做一次基线巡检,记录各项指标的日常值,积累三个月之后,哪天某个数据异常了,我一眼就能看出来,不用等监控报警才开始定位。这套方法不复杂,但真的能帮你少熬几次夜。