做后端开发和运维的朋友应该都遇到过这样的场景:服务没崩,但接口突然变慢,内存告警一条接一条,进服务器一看,Redis实例的内存占用已经冲到80%以上。这个时候大家的第一反应通常是"赶紧清一下缓存"。但"清理缓存"这四个字听起来简单,实际操作过的人都知道,这里面的门道比想象中多得多。命令敲错、范围没控制好、Key没删干净,轻则缓存雪崩导致数据库被压垮,重则线上数据异常,一整天都在复盘事故。
这篇文章我把Redis缓存清理这件事从头到尾梳理一遍,包括底层的内存管理和淘汰机制、手动清理的常用命令、批量清理大Key的方案,以及我在生产环境里踩过的一些坑。目标读者是正在接手Redis维护的后端开发、运维同学,以及那些对Redis只知道set和get、想系统了解清理策略的朋友。看完之后,你至少能回答这几个问题:什么时候该主动清缓存、用什么命令清、怎么清才不会出事。
1. 缓存为什么要清理:先搞懂Redis的内存与Key生命周期
1.1 内存模型与内存增长的来源
Redis本质上是基于内存的键值数据库,所有Key和Value都存在内存里。内存是物理资源,总有上限。一个实例默认能用的内存取决于maxmemory配置,不配置的话理论上可以吃掉宿主机大部分内存,直到触发系统内存压力。所以清理缓存的第一步不是背命令,而是先搞清楚内存花在了哪。
内存的消耗其实分成几块:Key本身和Value本身的字节数、Redis哈希表里每个entry的指针开销(一个dictEntry大约是24到48字节,根据结构不同有浮动)、为高效操作创建的各种内部结构(SDS头部、过期字典、起类型编码时的元数据),还有主从复制时的backlog缓冲区、客户端输出缓冲区、AOF和RDB持久化在fork时产生的写时复制内存开销。很多人拿业务侧的预估数据去对比监控上的used_memory,发现对不上,就是因为忽略了这些"看不见的开销"。
举一个我调过的真实例子:有个业务往Redis里写了很多类似user:10001的Key,Value是一个几十字节的JSON字符串。表面上看一条数据不到100字节,但实际占用内存去到200多字节,因为dictEntry、SDS头部、过期时间指针这些全加进去了。几百万条Key堆下来,和预估的差距能有几十GB。所以真正线上内存涨得快的,通常是这三种情况:大Value、没设置过期时间的Key、以及频繁写入产生的大量临时Key。第一种可以直接定位,第二种是慢性累积,第三种是接口逻辑设计问题。
1.2 过期Key的生命周期与清理时机
很多人以为设置了expire之后,Key在到期那一刻就会被删掉。这个认知不完全对。Redis的过期Key清理是两种策略配合执行的。
第一种是惰性删除:当客户端访问一个已经过期的Key时,Redis发现它过期了,才会顺手删除并返回nil。如果一个Key过期后一直没人访问,那它在内存里会一直躺着,占着空间。
第二种是定期删除:即使没有客户端访问,Redis内部也会每隔一段时间(默认每100ms执行一次)随机抽一批设置了过期时间的Key,检查是否过期,过期则删除。但每次抽取的样本数量和耗时都有限制。这意味着如果你的数据量巨大、过期Key分布又很广,可能在一段时间内Redis不会把所有过期Key清扫干净。那这部分没来得及清理的过期Key,依然占用着内存。这就是"明明设置了过期时间,内存还是居高不下"的一个经典原因。
所以做缓存清理之前,先把Key的生命周期这个模型搞清楚。你会理解一个关键点:清理动作不只是简单的DEL,它要配合过期时间设置、内存淘汰策略、甚至业务侧的数据重建机制来设计。只盯着"删除命令"做文章,永远清不干净。
2. Redis自己怎么清:淘汰策略与内存红线
2.1 maxmemory与八种淘汰策略
当实例内存达到maxmemory上限,Redis不会直接罢工,而是根据配置的淘汰策略来决定如何处理新的写请求。官方把淘汰策略分成八种,这里先列个表,避免有人一上来就看迷糊。
| 策略 | 作用范围 | 淘汰依据 | 适用场景 |
|---|---|---|---|
| noeviction | 全部Key | 不淘汰,写命令直接报错 | 只读缓存,或希望写失败也不丢数据的场景 |
| allkeys-lru | 全部Key | 按近似LRU淘汰最久未访问的Key | 无脑兜底,但不推荐 |
| volatile-lru | 只对设置过期时间的Key | 按近似LRU淘汰 | 常用推荐,控制范围在"可过期"层 |
| allkeys-lfu | 全部Key | 按近似LFU淘汰访问频率低的Key | 冷热数据分层明显的场景 |
| volatile-lfu | 只对设置过期时间的Key | 按近似LFU淘汰 | 热点数据集中在缓存键的场景 |
| allkeys-random | 全部Key | 随机淘汰 | 临时兜底,基本不用 |
| volatile-random | 只对设置过期时间的Key | 随机淘汰 | 同上 |
| volatile-ttl | 只对设置过期时间的Key | 淘汰剩余存活时间最短的Key | 一步一坑 |
这里有一个非常重要的认知:淘汰策略不是清理缓存的"兜底手段",它是应对突发写入内存不足的兜底。真正的缓存清理还是要靠主动控制。为什么?因为LRU和LFU在Redis里的实现都是近似算法,不是绝对精确。被淘汰的Key是不可预测的,你很难知道"下一个被淘汰的会不会是热点Key"。在生产环境里,假如缓存里既有热点数据又有普通数据,一旦内存告急触发了allkeys-lru,热点Key也可能被淘汰,然后在下一个请求时被重新加载。极端流量下,这就会形成缓存击穿、雪崩的连环问题。
所以我个人的建议是:如果一定要配置淘汰策略,优先考虑volatile开头的系列。这样Redis在内存紧张时,只在"可以被淘汰的缓存数据"里腾空间,业务层那些不允许丢的冷数据或者重要配置,只要没设置过期时间,就不会被动淘汰。
2.2 LRU与LFU的细节与配置建议
LRU在Redis里的实现不是精确LRU,而是"近似LRU"。Redis会取一小部分样本,从中挑出最久未访问的进行淘汰。样本数量由maxmemory-samples控制,默认是5。样本数越大,淘汰结果越接近真实LRU,但CPU性能开销也越高。这个参数值得专门提一句:很多团队只调了maxmemory,没调maxmemory-samples,结果在内存压力大的时候,淘汰效果不如预期,很大概率就是采样数太少。
LFU则维护每个Key的访问频率信息,通过一个衰减算法避免"很久以前很热的Key"永远不被淘汰。如果你的业务有明显的冷热数据分层,LFU通常比LRU更合理;如果你只希望简单控制内存上限,LRU更好理解,也更好排查。
配置方面,我建议至少在redis.conf里明确三个值:
- maxmemory:设置合理上限。一般建议预留整个实例内存的20%到30%给系统和其他内存开销。比如宿主机有32GB内存,Redis实例的maxmemory可以设置到20GB到24GB,不要贪心顶满。
- maxmemory-policy:明确选择策略。除非业务完全接受任意Key被淘汰,否则优先volatile系列。
- maxmemory-samples:线上常用10左右,数据量级特别大的实例可以考虑调整到15,但要实测性能。
这些设置可以通过CONFIG GET和CONFIG SET动态调整,修改前务必清楚影响:比如把策略从volatile-lru改成allkeys-lru,下一次内存淘汰就可能动到未设置过期时间的Key,这种线上变更需要走变更评审流程。
3. 主动清理缓存:命令选型与批量操作实践
3.1 DEL、UNLINK和EXPIRE怎么选
手动删除指定Key,最直接的是DEL。但DEL有一个隐蔽的坑:当Key的Value特别大的时候,DEL在执行时会阻塞Redis主线程。注意,不是慢,是阻塞。因为DEL需要同步释放该Key关联的所有内存,大Key的释放动作可能持续几十毫秒甚至上百毫秒。在这段时间内,其他所有命令全部排队等待,Redis看起来就像"卡死"了一样。
所以在Redis 4.0以后,引入了UNLINK命令,它的删除语义和DEL一样——从键空间中移除Key并返回删除结果,但实际的释放内存操作放到后台线程异步执行。使用方式几乎一样:
UNLINK user:10001返回1表示Key存在且已删除,返回0表示Key不存在。在绝大多数场景下,我建议直接用UNLINK,哪怕Key很小也没关系,UNLINK是安全的,不会因为Value大小而阻塞主线程。
还有一个容易混淆的操作是EXPIRE。它不是立刻删除Key,而是给Key设置一个未来过期的时间点。如果你为了"清理缓存"去执行EXPIRE key 1,那其实是在告诉Redis:这个Key 1秒后过期。Redis会在1秒后通过过期机制来处理。这个操作和DEL、UNLINK的差异在于:EXPIRE适合"还想保留Key,只是希望它到期后自动淘汰"的场景;如果你想立刻把数据清掉,直接用DEL或UNLINK,不要绕弯子。
这里提一个真实案例:之前有个同事为了省事,把缓存清理写成EXPIRE key 0,以为0秒就是立即过期。实测发现这个命令并不会把Key删掉,部分版本会返回错误,部分版本会把Key变成永久Key。这种隐蔽的"假清理"比不清理还坑,因为监控上内存没降,大家还不知道错在哪儿。
3.2 按Pattern批量删除:KEYS的坑与SCAN方案
很多刚接触Redis的人想清一批前缀相同的缓存,比如所有session:*,第一反应是执行KEYS:
KEYS session:*然后遍历返回结果挨个删除。这个做法在小数据量下没问题,但一旦实例里的Key总量达到几十万、上百万,KEYS会在主线程里全量扫描键空间,整个Redis在扫描期间无法处理其他命令,这在生产环境是绝对禁止的操作。所有大厂运维规范里,第一条就是"生产环境禁用KEYS"。
替代方案是用SCAN,它采用游标的方式分批迭代,每次返回一部分Key和下一个游标:
SCAN 0 MATCH session:* COUNT 1000上面命令里的COUNT不是返回数量的硬上限,而是每个桶的扫描工作量参考值,实际返回的Key数量可能比COUNT大也可能比COUNT小。这个误解需要纠正:很多人以为COUNT=1000就一定返回1000条,其实只是扫描策略的一个提示。SCAN命令的单次耗时很低,不会阻塞主线程,所以可以循环调用直到游标回到0。
把SCAN配合Shell实现批量删除,常见写法是:
redis-cli --scan --pattern "session:*" | xargs -L 100 redis-cli DEL这条命令在单实例单连接下没问题,但有一个隐患:如果Key数量特别多,xargs会分批启动redis-cli进程,每一批都建立一个新连接,效率其实不高。更稳的方式是用redis-cli的管道模式,或者直接用Python客户端配合SCAN循环删除。
集群模式下要注意,单节点SCAN只能扫到当前节点的键空间,其他slot里的Key不在返回结果里。集群需要先拿到每个主节点的地址,分别对每个节点执行SCAN。这部分后面单独讲。
3.3 用Lua脚本保证批量删除的原子性
xargs分批调用DEL,每一步本身是原子操作,但整个批量删除不是原子的——执行到一半出错,可能一批Key删了一半,业务层就会出现"部分缓存已清理、部分还是旧数据"的中间状态。如果业务上要求"要么都删,要么保留",可以用Lua脚本在一个原子操作里遍历并删除匹配的Key。
一个模板如下:
local cursor = '0' local pattern = ARGV[1] local batch = tonumber(ARGV[2]) repeat local scan_result = redis.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', batch) cursor = scan_result[1] for _, key in ipairs(scan_result[2]) do redis.call('UNLINK', key) end until cursor == '0' return true执行方式:
redis-cli --eval clean_by_pattern.lua , "session:*" 1000注意Lua脚本在Redis中运行时会占用主线程,所以脚本里单次UNLINK最好配合大Value场景选UNLINK而非DEL,同时控制好SCAN的COUNT,避免脚本执行时间过长。如果匹配的Key数量特别大,脚本耗时可能达到秒级甚至更久,这时候建议分批跑,或者把任务放在低峰期执行。
我自己生产环境里用Lua脚本删除的次数其实不算多,因为大多数场景对"原子性"没那么敏感。但有一种场景必须用Lua:删除前需要先判断Key的类型、再决定删除方式,这种复杂逻辑用纯命令行很难一次完成,脚本化更可控。
4. 大Key与冷数据的清理方案
4.1 什么样的Key算大Key
大Key没有一个绝对权威的定义标准,不同团队定义不同。实践中我习惯用这几个参考值来划分:
- 单个字符串Value超过10KB。
- 集合类型元素数量超过1000个。
- 集合类型Value的总字节数超过1MB,比如一个Hash有大量小字段,累计下来体积很大。
- 单个Key在内存中的总占用超过100MB,这属于极端大Key。
大Key的危害不只是删除时阻塞Redis,日常的读写、序列化、主从同步都会受影响。比如一个大Hash做HGETALL时,网络输出可能一下就打爆客户端缓冲区;主从同步时,把这个大Key同步给从节点,也可能拖慢同步进度。更隐蔽的是,很多Redis的慢查询日志里,奇怪的缓慢操作往往都跟大Key有关。
4.2 大Key如何安全删除
对于字符串类型的大Value,直接UNLINK就完事了。对于集合类型,直接UNLINK也能删,但在一些需要"保留Key但清空部分数据"的场景,就得用到分批次删除的办法。
比如一个大Hash,外层Key还有业务意义,不能整个删掉,只想清掉其中大部分字段。思路是用HSCAN分批取字段,再逐批HDEL:
redis-cli HSCAN myhash 0 COUNT 100拿到字段列表后:
redis-cli HDEL myhash field1 field2 field3 ...更稳的方式是把这两步包进Lua脚本,每轮取100个字段就HDEL一批,循环直到游标返回0。List类型可以用LTRIM逐步裁剪到只剩下指定长度,Set用SSCAN加SREM,ZSet用ZSCAN加ZREM。原理都一样:不要一次性加载全量,分批操作,控制每条命令的影响范围。
大Key删除还有一个"垃圾桶"技巧:对超大Key,先用RENAME把它改名为huge_key_to_be_deleted,然后不急着删,等低峰期再用UNLINK删除。虽然RENAME本身也会短暂阻塞,但比直接删大Key的阻塞时间短很多。这个技巧在高峰期清理紧急大Key时特别好用,因为业务侧对"这个Key不存在了"的容忍度,往往高于"Redis卡了几百毫秒"的容忍度。
4.3 定期清理任务怎么设计
建议每个团队都有一套定时清理Redis缓存的机制,而不是每次内存告警才临时登录服务器处理。设计定时清理任务时,我习惯从四个维度考虑。
第一个维度是时间窗口。清理任务必须避开业务高峰,一般放在凌晨。但要确认凌晨有没有批量对账、报表类任务也在使用同一个Redis实例,不然清理任务会跟业务任务抢资源。我见过凌晨的报表任务把CPU吃满,然后清理任务又把内存压力拉高,两边互相踩踏的情况。
第二个维度是删除批次大小。每轮删除的Key数量、每条命令的执行耗时都要想办法控制。宁可多跑几轮,也不要一次性清太多。比如按前缀删除时,每轮SCAN的COUNT控制在500到1000,每轮之间sleep 100毫秒左右,这样即使业务侧对某个Key有强依赖,也不会因为瞬间删除大量Key导致缓存雪崩。
第三个维度是可观测性。删了哪些前缀、删了多少个Key、释放了多少内存,一定要有日志。建议清理前后各记录一次INFO memory里的used_memory,这样能算出实际释放空间,也能发现"删了但内存没降"这种异常情况。
第四个维度是优雅降级。清理任务本身也要有熔断机制:如果某轮删除耗时超过阈值,应当停止这一轮剩余操作并告警,而不是继续执行直到出问题。这个阈值可以通过历史数据统计得出,比如正常一轮SCAN加UNLINK在10毫秒以内,如果连续三次超过1秒,直接中断任务并通知值班人员。
5. 生产环境踩坑实录:常见问题与排查思路
5.1 内存清理了还报警?排查顺序很重要
有一种情况特别让人头大:日志显示清理任务正常执行,删除了几万个Key,但监控上内存还是高位。我在实际运维中总结了一套排查顺序,比较适合"内存占用高但看起来没多少业务Key"的场景。
第一步,看INFO memory里的关键指标:
redis-cli INFO memory重点看used_memory、used_memory_rss、mem_fragmentation_ratio。碎片率(mem_fragmentation_ratio)大于1.5说明内存碎片严重,这不是Key多导致的,而是分配器在频繁分配和释放小块内存时产生了碎片。这种情况下删除Key并不能降低RSS,甚至可能越删碎片越严重。处理方式是重启实例让jemalloc重新整理内存布局,或者在部署层面预留足够内存缓冲。
第二步,用redis-cli --bigkeys扫描大Key:
redis-cli --bigkeys这个命令会遍历整个键空间,统计各类Key中占比大的成员。注意这个命令本身也是全量扫描,谨慎在高峰期执行,最好在低峰期跑一次。它输出的结果会告诉你哪个Key的Value最大、哪个Hash字段最多、哪个List元素最多。
第三步,检查有没有没设置过期时间的Key在持续累积。方法是用SCAN遍历所有Key,对每个Key执行TTL命令,筛选出TTL为-1的Key。TTL为-1说明没有设置过期时间,-2说明Key不存在或已过期。这个过程也建议用脚本跑,不要手动一个个查。
第四步,检查客户端连接、输出缓冲区和backlog。比如某个客户端一次性拉取了一个大Key的数据,输出缓冲区可能占用大量内存;主从复制的backlog也可能占用固定内存。这些东西不看Key根本发现不了。用CLIENT LIST看一眼客户端的输出缓冲区大小,就能定位到是不是有客户端在"拉大对象"。
这一套排查下来,你才会知道内存到底被谁吃了。清理缓存不能只盯Key,其他内存消耗也要纳入视野。
5.2 缓存清理引发的雪崩和击穿
清理缓存最容易引发的事故是缓存雪崩。比如你通过一个Pattern把某个前缀下的所有Key一次性删除,而这些Key正是业务读多写少的流量入口,删除后大量请求会同时落到数据库,数据库压力瞬间打满。这就是"清理成了事故"的典型场景。
避开的思路有几个:
- 大批量删除时,把清理时间拉长、批次缩小。比如本来准备1分钟内删完10万个Key,改成每批500个Key、每批间间隔200毫秒,大概20分钟删完。从业务角度看,这20分钟里有少量请求因为缓存命中率下降而回源数据库,但数据库能扛得住。
- 删除前做好业务侧的错峰。如果清理的是冷数据,影响不大;如果清理的可能是热点数据,先把业务侧的缓存Key切换成新版本,比如加个后缀,等流量都打到新Key上之后再删除旧Key。
- 给重要的缓存场景多留一个兜底:删除之后如果数据库扛不住,至少要有限流和降级开关。很多团队没有这个准备,删完缓存数据库被打死,只能手忙脚乱扩容。
缓存击穿则更像是一个点上的事故:某个热点Key正好在清理任务执行时被删掉,然后瞬间所有请求全部穿透到数据库。规避方式通常是这几种:
- 热点Key不要纳入全量清理的Pattern,单独维护一份热点Key名单,清理任务执行前先排除这些Key。
- 删完Key后立即做一次缓存预热,把数据库里那份数据重新写回Redis。
- 或者用分布式锁保证只有一个请求去重建缓存,其他请求在锁等待期间短暂阻塞。
5.3 集群环境下的清理一致性
在Redis Cluster模式里,每个Key根据hash slot分布在不同节点上,跨节点的批量清理不能简单地用单节点的SCAN加DEL解决。推荐的做法有三种。
第一种,对每个主节点依次执行SCAN加DEL。先拿到集群中所有主节点的IP和端口,然后写一个循环脚本,对每个节点执行批量删除。这种做法的好处是逻辑清晰,不容易出错。
第二种,用redis-cli -c --scan --pattern "xxx:*"在集群模式下扫描。-c(cluster)模式会自动跳转到对应节点执行命令,但要注意,SCAN返回的Key可能来自多个节点,后续对每个Key执行UNLINK时,-c模式能帮我们路由到正确的slot。实际用下来发现redis-cli -c --scan的可用性还算稳定,但大批量删除时还是建议走第一种方案,自己控制每个节点的删除节奏。
第三种,自研方案:客户端用CLUSTER SLOTS命令获取slot分布,计算每个Key的hash slot,再把Key按slot分组,路由到对应节点批量执行删除。这个方案优点是精准可控,缺点是要写代码。
跨槽Key删除如果没处理好,最常见的报错是CROSSSLOT Keys in request don't hash to the same slot,这是集群对多Key操作的限制。逐Key删除则不会触发这个限制,因为每个命令只涉及一个Key。
另外,主从节点不需要单独处理。删除操作写入主节点后,删除命令也会通过主从复制传到从节点。所以清理任务只需要对主节点操作即可。不要同时对从节点也执行一遍DEL,那是浪费资源,而且如果在从节点上开了只读,写操作还会报错。
5.4 一个实用的清理脚本模板
这里分享一个我常用的临时清理脚本模板,核心是参数化,方便根据场景调整。
#!/bin/bash # 用法: ./clean_redis_prefix.sh <redis_host> <redis_port> <pattern> <count> <batch_sleep_ms> HOST=$1 PORT=$2 PATTERN=$3 COUNT=$4 SLEEP_MS=${5:-100} # 使用SCAN分批取出Key,每批之间sleep redis-cli -h "$HOST" -p "$PORT" --scan --pattern "$PATTERN" > /tmp/keys_to_delete.txt total=0 while IFS= read -r key; do redis-cli -h "$HOST" -p "$PORT" UNLINK "$key" total=$((total + 1)) # 每200个Key暂停一下,避免集中删除触发雪崩 if (( total % 200 == 0 )); then sleep 0.1 fi done < /tmp/keys_to_delete.txt echo "deleted total: $total"这个脚本本身不复杂,核心思想是:控制删除节奏、控制单次操作的Key数量、每批之间加间隔。真要上线跑,建议加上日志和统计输出,至少记录开始时间、结束时间、删除总量和删除前后的used_memory。
如果Redis中有密码,需要在每个redis-cli命令里加-a参数;如果有特殊字符的Key名,脚本里的命令替换要小心,建议用eval加双引号包裹Key名,避免被Shell错误解析。这个脚本用在测试环境验证逻辑没有问题之后,再在生产低峰期执行。
5.5 清理完后注意观察的指标
清理任务执行完成,不等于这件事就算完了。我习惯在清理后的一段时间里持续观察三个指标。
第一个是命中率。缓存清理后,命中率会有一个短暂的下降,这是正常的。但如果命中率持续低位超过一个业务周期,说明清理范围太大了,把大量还有热度的Key删掉了,业务会对数据库产生持续压力,需要及时恢复或者预热。
第二个是内存的下降曲线。UNLINK是异步删除,内存不会瞬间降下来,而是有一个滞后。用INFO memory多采样几次,观察used_memory是否有逐步下降的趋势。如果过了很久内存还是不动,可能是有碎片问题,或者有大量Key的过期时间在清理之后依然有效,惰性删除和定期删除还需要时间处理。
第三个是慢查询日志。清理过程中的大Key删除,可能产生慢查询。如果发现删除阶段有大量慢查询,下次做清理时要把批次调得更小、间隔调得更长。用SLOWLOG GET查看最近慢查询,就能定位到是哪条命令、哪个Key、耗时多少。
我个人在长期运维Redis的过程中有一个很深的体会:缓存清理这件事,做得好了是日常巡检的一部分,做得糙了就是定时炸弹。它不像写业务代码那样有明确的功能边界,但它直接影响整个系统的稳定性和数据一致性。每次动手清理之前,花十分钟想清楚这几个问题——清理目的是什么、影响范围是什么、失败后怎么回退——比闷头敲命令要值钱得多。
最后再分享一个小技巧:如果公司有完善的监控平台,可以给Redis实例的used_memory配置一个"动态阈值"告警,比如按业务容量的80%和90%分两级告警。80%告警时,清理任务还能从容执行;90%告警时,就需要人工介入排查,因为再往上走就可能触发maxmemory淘汰策略,到时候就不是你想删谁就删谁的问题了。把这个规则沉淀到团队的运维文档里,新接手的人也不容易手忙脚乱。