Redis 快,这句话被说过太多次。面试里答一句「因为内存、单线程、IO 多路复用」就能过关,可真到线上出问题,这三个词一个都用不上。有用的问法是,它省掉了哪一档开销,省不掉哪一档,什么条件下这个快会消失。
这篇按这个思路走。先讲正面,快在哪。再翻到反面,讲让它变慢的三件事,大 key 一次搬几十 MB,热 key 把单节点打满,慢命令把整条事件循环卡住,这三件事覆盖了线上绝大多数 Redis 性能问题。最后讲成本面,内存决定你能装多少数据,也决定你什么时候必须开始淘汰,而淘汰本身又会带来延迟抖动。
路线是有承接的,为什么快决定了什么会破坏快,大 key 和热 key 破坏的是命令执行和节点负载,慢查询是把破坏量化出来的手段,内存和淘汰是长期运行绕不开的约束。读完你应该能拿到三样东西,一套判断实例有没有病的方法,一套定位到具体 key 的脚本,一套内存参数怎么配的取舍标准。
版本口径以 Redis 7.x 为准。本机没有装 redis-server、redis-cli 和 redis-benchmark,下面的命令只给命令本身,需要看返回形态的地方用引用框列关键字段,涉及耗时的地方只给量级和推导过程,不编造具体数字。
Redis 为什么这么快
内存访问省掉的是磁盘那一档
拆开「快」,第一层是数据全放内存。这层容易被讲成废话,其实值得算账,因为它决定了 Redis 快的上限,也决定了它省不掉什么。
| 操作 | 典型量级 |
|---|---|
| 内存随机访问 | 约 100 纳秒 |
| 同机房网络往返 RTT | 约 0.1 到 0.5 毫秒 |
| SSD 随机读 | 约 100 微秒 |
| 机械磁盘随机读 | 约 10 毫秒 |
内存和机械磁盘差了大约五个数量级,Redis 省掉的就是这一档,不用等磁头寻道,不用等盘片旋转。这是它比直接查磁盘的数据库快那么多的根本原因,跟单线程和 epoll 都没关系。
但表里还有一行要单独看,网络往返。同机房 RTT 是零点一到零点五毫秒,比内存慢三个数量级,跟一次 SSD 随机读倒是同一量级。这一档 Redis 省不掉,请求得从客户端出发,过网卡、过交换机、到服务端、执行、再原路返回,这一来一回由物理条件决定。
Redis 省掉的是磁盘 IO 那一档,省不掉的是网络那一档。很多文章把「快」归给内存就结束了,而线上真正卡住的地方经常是网络 RTT。
顺着推一步。RTT 按 0.2 毫秒算,一个连接一次发一条命令、等回复、再发下一条,一秒最多跑 1 除以 0.0002,也就是 5000 次。这个数跟 Redis 能跑十万 QPS 不矛盾,它只说明单连接同步调用的天花板是网络给的。想突破,要么加连接,要么用 Pipeline 把多条命令压进一次往返。
还有一点,内存快不等于访问次数少。HGETALL 取回一百万个字段,就算每次访问 100 纳秒,加起来也不是零,大 key 的问题就出在这。
单线程的收益和代价
Redis 的命令执行是单线程的,但进程不止一个线程,这点后面单独讲。
单线程最直接的好处是不用加锁,没有第二个线程同时改同一份数据,锁粒度、加锁顺序、死锁、锁本身的开销全都不存在。省掉锁还换来两样。一样是单条命令的原子性天然成立,INCR、LPUSH、SETNX 执行中不可能被别的请求打断,Lua 脚本和 MULTI 事务的原子性也建立在这个前提上,分布式锁用 SET key value NX EX 一条命令搞定就是因为它原子。另一样是没有多线程的隐性成本,上下文切换要保存恢复寄存器和栈,多个线程改相邻内存地址会让缓存行反复失效,也就是伪共享,而 CPU 缓存对单线程顺序访问更友好。
代价也要说清楚。一个慢命令阻塞所有请求,事件循环只有一条线,线上任何一环卡住,后面全部排队,KEYS * 在几百万 key 的实例上跑几秒,这几秒里所有客户端都在等。另一个代价是用不上多核做命令执行,机器三十二核,Redis 也只用一核跑命令,应对方式不是改 Redis,是横向拆,一台机器起多个实例或者用 Cluster 分片。
从 select 到 epoll
一个进程要同时盯住几千个连接,等它们哪个有数据可读。每个连接开一个线程阻塞读是不行的,几千个线程光栈和调度就压垮机器,IO 多路复用解决的就是这件事。
select 把所有关心的 fd 放进一个集合,调一次,内核告诉你哪些可读。三个硬伤。fd 集合每次调用都要从用户态完整复制到内核态;数量上限卡在 FD_SETSIZE,通常 1024;返回后还得遍历整个集合看哪个位被置上,O(N) 的扫描,下次还要重建集合。
poll 解决了前两个,用数组代替位图,没有 1024 的限制,也不用重建集合。但第三个还在,返回后仍要遍历整个数组,活跃连接只有三个,你也得扫完一万个。
epoll 换了思路,把「哪些 fd 要监听」和「哪些 fd 就绪了」分成两件事。epoll_create 创建实例,内核里维护一棵红黑树和一条就绪链表;epoll_ctl 往树里增删改 fd,只做一次,不用每次调用重复;fd 有数据时被挂到就绪链表上;epoll_wait 只看就绪链表有没有东西,有就取出来。所以监听一万个连接、其中十个活跃时,epoll_wait 的成本跟那十个有关,跟一万无关。
text
select 每次调用复制整个 fd 集合,上限 1024,返回后 O(N) 遍历
poll 不复制集合,无数量上限,返回后仍然 O(N) 遍历
epoll fd 用红黑树管理,就绪事件挂链表,返回后只处理就绪部分
触发模式这里要澄清一个高频误解。LT 是只要 fd 上还有数据没读完,下次 epoll_wait 还告诉你可读;ET 只在状态变化那一刻通知一次,之后不再提醒,要求你必须读到 EAGAIN 为止,否则数据留在缓冲区没人管。
Redis 用的是 LT ,水平触发。很多人以为高性能网络库都得用 ET,其实 Redis 的 ae_epoll.c 在 epoll_ctl 时用的是默认模式,没设 EPOLLET。理由也实在,LT 实现简单、不容易因为漏读数据出 bug,而 Redis 每轮事件循环都会把可读的 fd 处理一遍,LT 的语义正好匹配这种轮询式节奏。
「IO 多路复用让单线程能处理成千上万连接」是对的,但容易被理解成「同时处理成千上万个请求」。不是。任何时刻 Redis 只处理一个命令,多路复用做的是把「等」从线程里拿掉,连接挂着,事件来了才处理,这叫事件驱动的非阻塞处理,并行度仍然是 1。
事件循环的骨架
前面三层说的是「为什么可以快」,事件循环说的是「这些机制怎么组织起来跑」。
Redis 的事件循环实现在 ae.c,核心是 aeEventLoop 结构体,装着两张表,文件事件和时间事件。aeCreateEventLoop 创建循环并把 aeApiPoll 绑定到底层 epoll,aeMain 进入主循环,循环里反复调 aeProcessEvents,每轮做四件事,算出 aeApiPoll 的等待超时、阻塞等事件、处理就绪的文件事件、处理到期的时间事件。
c
/* ae.c 里事件循环的主干,去掉错误处理和统计后大致是这样 */
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
/* 每轮先调 beforeSleep,再阻塞等待,醒来后调 afterSleep */
aeProcessEvents(eventLoop,
AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
}
}
beforeSleep 和 afterSleep 在进入阻塞前和醒来后各调一次。beforeSleep 做的事比名字重要,Redis 在这里把本轮的回复写回客户端 socket、处理 AOF 刷盘、做需要在阻塞前完成的收尾,appendfsync everysec 的触发点之一就在这条路径上。放在这里的原因很直接,阻塞等待期间进程什么都不干,正好把不依赖新事件的活儿干完。
serverCron 是最重要的时间事件,负责过期 key 清理、统计更新、客户端超时检查、内存淘汰触发,频率由 hz 控制,默认 10,也就是每 100 毫秒一次,它跑在同一个线程里,花的每一毫秒都算在主线程头上。aeApiPoll 的阻塞超时是从时间事件表里算出「下一个到期的事件还有多久」,这样 30 毫秒后有任务要跑就最多睡 30 毫秒。hz 调大,定时任务更准时,代价是更多系统调用和更碎的 CPU 占用。
单线程事件循环,但不止一个线程
Redis 进程里除了主线程还有几个后台线程,统称 bio,它们的活儿是那些慢、又不需要立刻返回结果的清理动作。UNLINK 删除大 key 时,释放内存交给后台线程,主线程把 key 从字典摘掉就返回;FLUSHALL ASYNC 和 FLUSHDB ASYNC 同理;AOF 的 everysec 刷盘也由后台线程做;关闭大客户端连接、释放它的输出缓冲区同样在后台。
所以 Redis 的单线程指的是命令执行路径,不是整个进程,判断一个操作会不会阻塞主线程,要看它有没有走 lazyfree 这条异步路径。至于 6.0 引入的 IO 多线程,它解决的是读写 socket 和协议解析的开销,命令执行仍然是单线程,不改变本篇任何结论。
数据结构为什么快
这些结构的实现细节在同系列文章里单独展开过,这里只点一下每个「快在哪」。SDS 头部存了长度,取长度是 O(1),不用遍历到 \0,还做了预分配。字典用两个哈希表做渐进式 rehash,搬迁摊到每次读写上,避免一次卡住几毫秒。跳表的查找插入删除都是 O(log N)。listpack 是紧凑的连续内存布局,元素少时省掉大量指针开销。intset 存整数集合,查找用二分,比哈希表省内存。
它们背后是同一条思路,数据量小的时候用紧凑、对缓存友好的布局,大了再换成查找更快但更占内存的结构,这个「小用紧凑、大用高效」在第五章讲 encoding 时还会再遇到一次。
什么时候 Redis 不快
Redis 快是默认状态,但有几种情况会让它不快,这几种几乎覆盖了线上所有性能问题。
大 key 是第一种。HGETALL、LRANGE key 0 -1、SMEMBERS 都是 O(N),一个百万字段的哈希,一条命令要遍历一百万个字段,就算每次都是内存访问,这条命令也是毫秒级,而这一毫秒里整个事件循环都停着。
慢命令是第二种。KEYS 遍历整个数据库,生产环境基本等于自杀;FLUSHALL 同步版本要释放所有内存;SORT 先取全量再排序,ZRANGE 取的范围越大越慢。这些命令不是不能用,是不能在不知道数据规模的情况下用。
网络 RTT 是第三种,被低估最多。单连接同步调用受 RTT 限制,同机房 0.2 毫秒对应 5000 次每秒,这时候 Redis 可能只用了百分之一的 CPU,接口就是慢。Pipeline 一次发一百条,往返次数降到百分之一,理论上限从 5000 涨到 50 万,实际上打不到,因为单实例命令执行能力大概在十万量级,Pipeline 之后瓶颈从网络转回 Redis 自己。这说明 Pipeline 的价值是把瓶颈从错误的地方挪到正确的地方。
持久化是第四种。BGSAVE 要 fork 子进程,fork 会阻塞主线程,内存越大阻塞越久,子进程写 RDB 期间被改的页触发写时复制,写入越猛额外内存越大。AOF 的 always 每条命令都要 fsync,吞吐明显下降。主从同步是第五种,全量同步时主库要 fork 加传输 RDB,从库要清空并加载,这段时间主库延迟会抖,大 key 让过程更慢。
CPU 密集操作是第六种。Lua 脚本里放个死循环,整个 Redis 直接卡死,SCRIPT KILL 只能杀掉还没执行过写命令的脚本,已经写过的杀不掉,只能等它跑完或者 SHUTDOWN NOSAVE。内存碎片是第七种,mem_fragmentation_ratio 大于 1.5 意味着物理内存比逻辑数据多出一半,它不直接影响命令速度,但会让内存提前到顶、触发淘汰,间接影响延迟。
自己验证一下快慢
想验证得自己动手。redis-benchmark 是 Redis 自带的压测工具,本机没有装,这里只给命令和你会看到什么。
bash
# 基础命令吞吐,-n 是总请求数,-c 是并发连接数,-q 只打印摘要
redis-benchmark -h 127.0.0.1 -p 6379 -n 100000 -c 50 -t set,get,lpush,incr -q
# 把并发拉高,看看 QPS 还涨不涨
redis-benchmark -t get -n 100000 -c 500 -q
# 开 Pipeline,-P 100 表示一次往连接里塞 100 条命令
redis-benchmark -t set -n 100000 -c 50 -P 100 -q
# 测单连接的纯延迟
redis-benchmark -t get -n 10000 -c 1 --latency -q
跑完你会看到几件事。SET 和 GET 的 QPS 通常在同一档,LPUSH 和 INCR 也差不多,因为命令执行本身都很轻,不会差出数量级。把 -c 从 50 拉到 500,QPS 一般不会线性增长,很可能只涨一点甚至不涨,因为瓶颈不在连接数,在命令执行那一个线程上。真正让曲线跳台阶的是 -P,Pipeline 打开后 QPS 明显上一个量级,直到撞到单实例命令执行的天花板。redis-benchmark 跑的时候 Redis 的 CPU 接近打满,别在线上实例上跑。
回到主线。这一章讲快在哪,五个来源从下到上是内存、单线程、IO 多路复用、事件循环、数据结构。下一章讲反面,先讲那个最容易把快抵消掉的东西,大 key。
Redis BigKey 问题
多大的 key 算大
大 key 没有官方标准,这是第一件要说清楚的事。Redis 文档里没定义过什么叫大 key,只有一些经验阈值,因为「大」是相对实例内存和业务形态说的。一个 512MB 的实例里,1MB 的 key 就是灾难;一个 64GB 的实例里,1MB 的 key 满地都是也无所谓。下面这些数字是判断起点,不是红线。
| 类型 | 值得关注 | 建议处理 |
|---|---|---|
| String | value 超过 10KB | 超过 1MB |
| Hash、List、Set、ZSet | 元素数超过 5000 | 总大小超过 1MB |
比阈值更重要的是理解「大」有两个维度,元素数量和总字节数,这两个维度可以完全不相关。一个哈希只有一百个字段,但每个字段的值都是 1MB 的 JSON,总大小一百 MB,妥妥的大 key,可元素数只有一百;反过来,一个集合有五十万个成员,每个成员只有几个字符,总大小可能只有几 MB。所以两个维度都要看,只看一个会漏。
再补一条经验,看这个 key 会不会被 O(N) 的命令整体访问。一个 5MB 的哈希,如果业务永远只 HGET 其中几个字段,其实不太痛;如果经常 HGETALL,那 5MB 就是每次请求都要搬一遍的量。真正决定伤害的不是 key 的大小,是这个大小乘以命令复杂度再乘以访问频率。
阻塞、带宽和删除
危害里排第一的是阻塞。命令执行是单线程的,一个 O(N) 命令执行期间,所有其它客户端的请求都在排队。HGETALL 取百万字段的哈希、LRANGE key 0 -1 取百万元素的列表、SMEMBERS 取百万成员的集合、DEL 删除上面任何一种,复杂度都跟元素数量成正比。你以为自己在做一个读操作,实际上是在让整个实例停下来等它。
第二个危害是网络带宽。一次取回几十 MB 的数据,占的是服务器出口带宽,而带宽跟其它业务共享。单机千兆网卡的理论上限是 125MB 每秒,一个 50MB 的响应就吃掉接近一半,剩下的请求全部排队。更糟的是这类传输往往伴随客户端侧的解析开销,慢的可能根本不在 Redis 侧,而你在 Redis 的指标上看不出异常。
第三个危害是删除本身也会阻塞。DEL 是同步释放内存的,删一个百万字段的哈希,主线程要遍历并释放一百万个对象。Redis 4.0 引入了 UNLINK,把 key 从键空间摘掉之后就返回,真正的内存释放交给后台线程。同一时期引入的 lazyfree 系列参数还包括 lazyfree-lazy-expire、lazyfree-lazy-eviction、lazyfree-lazy-server-del,分别让过期释放、淘汰释放、服务端主动删除走异步路径。
过期、持久化和主从的连带影响
大 key 的危害不只在命令执行那一刻,它在几个地方会反复找上门。
一个是过期。设了 TTL 的大 key 到期时,惰性删除和定期删除两条路径都可能踩到它,前者是访问时发现过期再释放,后者是 serverCron 里采样带 TTL 的 key 检查并删除,释放动作都在主线程。开了 lazyfree-lazy-expire yes 之后可以走后台线程缓解。
另一个是持久化。RDB 的 BGSAVE 依赖写时复制,子进程 fork 之后主线程改过的内存页会被复制一份。大 key 占的内存页多,而且经常被整体改写,一个 50MB 的哈希更新一个字段,如果改动落在不同内存页上,可能触发好几页的复制,所以大 key 会让 COW 的内存放大更明显。
还有主从同步。全量同步要传输整个数据集,大 key 是整块搬的,会让传输时间显著拉长,从库的 slave_repl_offset 长时间落后。Cluster 下还有额外一层,大 key 让槽之间的数据量差异变大,而 redis-cli --cluster rebalance 是按 key 数量平衡的,它看不出内存倾斜,结果就是某个节点内存先满。
最后是槽迁移。MIGRATE 传的是 key 的完整内容,没有批量版本,大 key 让单个 key 的迁移耗时变成几十秒量级,期间客户端对它的请求直接超时,所以扩容之前先扫一遍大 key。
redis-cli --bigkeys 的原理和局限
发现大 key 最常用的工具是 redis-cli --bigkeys。
bash
# 扫全库找大 key,-i 0.1 表示每扫一批 sleep 0.1 秒,降低对线上的压力
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1
原理不复杂,用 SCAN 游标遍历所有 key,对每个 key 先 TYPE 判断类型,再按类型调对应的长度命令取元素数量,String 用 STRLEN,Hash 用 HLEN,List 用 LLEN,Set 用 SCARD,ZSet 用 ZCARD。跑完每种类型输出一个「最大的那个 key」,再加上全库 key 总数和各类型分布。
局限有三条。它只输出每种类型最大的那一个 key,不是一个大 key 列表,你的库里有一百个大哈希,它只告诉你最大的那个,剩下九十九个看不到。它按元素数量比大小,不按总字节数,前面说的「一百个字段每个 1MB」的哈希在它眼里只有一百个元素,排不上号,String 类型看的是值的字节长度,这个维度是对的,但它不会告诉你 key 名本身有多长。它跑的时候确实占主线程时间,SCAN 是渐进式的,不会像 KEYS 那样一次卡死,但每个 key 都要发 TYPE 加一个长度命令,几百万 key 就是几百万次命令执行,-i 能让它在批次之间睡一会儿把压力摊开。
高负载的实例上更好的做法是在从库上跑,从库的数据是主库的副本,大 key 也一样,扫它不会影响主库。
上面这些手段都要在实例上跑命令,大实例上仍有风险。最安全的做法是离线分析 RDB,用 rdb-tools 或者 redis-rdb-cli 把 RDB 解析成可查询的格式,按类型和大小统计 key 分布,全程不碰线上实例,代价是分析的是快照时刻的数据。另外,INFO keyspace 和 DBSIZE 只能给出 key 总量,定位不到具体是哪个 key 大,做容量规划有用,排查大 key 帮不上忙。
text
--bigkeys 的输出形态,用文字描述一下
按类型分组,每种类型一行
String 最大的 key 名和它的字节长度
Hash 元素最多的 key 名和它的字段数
List 元素最多的 key 名和它的长度
Set 成员最多的 key 名和它的成员数
ZSet 成员最多的 key 名和它的成员数
最后是全库 key 总数和平均大小
坦率的讲,这个工具粗糙,但对大多数团队够用,因为它至少能告诉你这个实例里有没有那种一眼就该拆的 key。
--memkeys 与 MEMORY USAGE
--bigkeys 看元素数量,--memkeys 看内存占用,两者互补。
bash
# 按内存占用找大 key,采样 100 个元素估算每个 key 的内存
redis-cli -h 127.0.0.1 -p 6379 --memkeys --memkeys-samples 100 -i 0.1
这个选项是 Redis 5.0 时期加进 redis-cli 的,底层调 MEMORY USAGE,按实际内存占用排序输出。前面那个「一百个字段、每个 1MB」的哈希,--bigkeys 漏得掉,--memkeys 能抓到,因为它的判据是字节数。
单看一个 key 用这条命令。
redis
# 默认采样 5 个元素估算,采样越多越准也越慢
MEMORY USAGE user:1:profile
# SAMPLES 0 表示全量采样,最准,但 key 很大时会明显变慢
MEMORY USAGE user:1:profile SAMPLES 0
它的估算逻辑是按类型遍历一部分元素,算平均大小再乘以元素总数,所以对元素大小均匀的集合比较准,对差异巨大的集合误差会大,这时候把采样数调大或者直接 SAMPLES 0。注意 SAMPLES 0 在大 key 上等于让主线程完整遍历一遍,本身就是一次大 key 危害,别在线上随便用。
还有一个老办法,DEBUG OBJECT key 返回的 serializedlength 字段表示 key 序列化后的大小,但 DEBUG 是一组高危命令,生产环境应该在配置里禁掉(rename-command DEBUG ""),所以它只在测试环境用,而且 serializedlength 是 RDB 序列化后的长度,跟实际内存占用不是一回事。
自己写脚本扫大 key
--bigkeys 只给一个结果,实际治理需要完整清单。下面这段 Python 用 SCAN 遍历全库,对每个 key 同时取内存占用和元素数量,两个维度都判断,最后按内存占用排序输出。
python
# scan_bigkeys.py
# 依赖 redis-py,先 pip install redis
# 输出超过阈值的 key,按内存占用从大到小排序
from collections import defaultdict
import redis
HOST = "127.0.0.1"
PORT = 6379
DB = 0
SCAN_COUNT = 500 # 每次 SCAN 返回的提示数量,不是精确值
SAMPLES = 10 # MEMORY USAGE 的采样数
LEN_THRESHOLD = 5000 # 集合类型元素数阈值
SIZE_THRESHOLD = 1024 * 1024 # 单 key 内存阈值,1MB
TOP_N = 50 # 每种类型打印多少条
def scan_all(r):
"""用 SCAN 分批遍历,避免 KEYS 那种一次性阻塞"""
cursor = 0
while True:
cursor, keys = r.scan(cursor=cursor, count=SCAN_COUNT)
if keys:
yield keys
if cursor == 0: # 游标回到 0 表示遍历结束
break
def main():
r = redis.Redis(host=HOST, port=PORT, db=DB, decode_responses=True)
found = defaultdict(list) # 类型 -> [(内存, 元素数, key)]
total = 0
for keys in scan_all(r):
# 用 Pipeline 批量取类型,减少往返次数
pipe = r.pipeline()
for k in keys:
pipe.type(k)
types = pipe.execute()
for key, ktype in zip(keys, types):
total += 1
# 内存占用,采样 10 个元素估算
size = r.memory_usage(key, samples=SAMPLES) or 0
# 元素数量,按类型取
if ktype == "string":
length = r.strlen(key)
elif ktype == "hash":
length = r.hlen(key)
elif ktype == "list":
length = r.llen(key)
elif ktype == "set":
length = r.scard(key)
elif ktype == "zset":
length = r.zcard(key)
else:
length = 0 # stream 等类型先跳过
# 两个维度都判断,元素少但单元素大的 key 也会被捞出来
if size >= SIZE_THRESHOLD or length >= LEN_THRESHOLD:
found[ktype].append((size, length, key))
print(f"扫描完成,共 {total} 个 key")
for ktype, items in sorted(found.items()):
items.sort(reverse=True) # 先比内存,内存相同再比元素数
print(f"\n类型 {ktype},命中 {len(items)} 个")
for size, length, key in items[:TOP_N]:
print(f" {size:>12,} 字节 元素数 {length:>10,} {key}")
if __name__ == "__main__":
main()
几个使用上的注意。SCAN 的 COUNT 只是提示,返回数量可能比它多也可能比它少,不要当精确分页用。脚本对每个 key 至少发一次 MEMORY USAGE,大实例上会跑很久,建议在从库上跑,或者限定前缀分批跑。stream 类型这里跳过了,需要的话用 XLEN 加 MEMORY USAGE 补上。
只想临时看一眼某个前缀,用 Shell 更快。
bash
# 按前缀扫一遍,把每个 key 的内存占用打出来再排序
redis-cli --scan --pattern 'user:*' | while read -r key; do
size=$(redis-cli memory usage "$key")
echo "$size $key"
done | sort -rn | head -20
代价是每个 key 至少一次往返,适合排查不适合全库体检。
按类型拆分大 key
拆的原则是让单次操作的数据量可控,同时保证业务读的时候能拿到完整数据。每一种拆法都要交代查询时怎么合并,不然拆完业务就跑不通。
String 大 value 有四种处理方式。最直接的是拆成多个 key,把一个 10MB 的字符串切成 big:0 到 big:9,每片 1MB,读的时候按需读其中几片,全量读就循环取完再拼。第二种是把 JSON 拆成 Hash,一个大 JSON 里的字段变成哈希字段,业务只 HGET 自己要的那几个,从每次都搬全量变成按需取,代价是嵌套结构不好表达。第三种是压缩后再存,gzip 或者 lz4 压完再 SET,代价是 CPU 和无法局部读取。第四种是内容根本不该放 Redis,图片、视频、大附件放对象存储,Redis 里只留一个 URL 引用。
Hash 大 key 按字段维度切。如果字段有天然的类别,比如一个用户对象里既有基础资料又有扩展属性,就拆成 user:1:base 和 user:1:ext,读的时候业务通常只关心其中一类,真要全量就用 Pipeline 一次发两个 HGETALL 再合并。如果字段没有类别,就按哈希分片,user:1:part:0 到 user:1:part:9,写入时用 crc32(field) % 10 决定落在哪一片,读单个字段先算分片再 HGET,读全量就 Pipeline 发十个 HGETALL 然后合并。分片数量太少起不到拆的作用,太多会让每次全量读的往返次数变多。
List 大 key 按时间分片最自然,一个消息流拆成 feed:20240901、feed:20240902 这样按天存的 key,读的时候根据时间范围决定读哪几个分片,最近一页通常只落在当天那一个 key 上。第二种是 LTRIM 限长,只保留最近 N 条,key 的大小天然有上界。第三种是换成 Stream,XADD 带自增 ID,XRANGE 按 ID 范围读,天然支持分页,XTRIM 控制长度,还多了消费者组的语义,如果本来就是「按时间顺序追加、按范围读」的模型,Stream 更合适,代价是改造工作量。
Set 大 key 按成员哈希分片,tag:java:0 到 tag:java:9。写入时 SADD 到 hash(member) % 10 那一片,判断成员是否存在要查所有分片,求交集要先在各分片内部求交再合并,这部分逻辑比单 key 复杂不少,改造前先确认业务真的需要集合运算。如果集合里存的是连续的用户 ID,另一个选择是 Bitmap,SETBIT user:active 1001 1,一个 bit 表示一个人,一百万人只占约 125KB,判断存在是 O(1),求交可以直接用 BITOP AND,代价是 ID 必须密集、非负、有上界。
ZSet 大 key 主要是排行榜,按时间分片是常规做法,日榜、周榜、月榜各存一个 key,读的时候业务本来也只会看其中一个周期。第二种是冷热分离,Top 100 单独放一个 key 供高频读,全量数据落库,需要翻到后面再回源。这里有个细节值得知道,如果只读 Top N,ZREVRANGE key 0 99 其实不会扫全量,跳表的范围查询是 O(log N + M),M 是返回的元素数,所以「大 ZSet 只读前一百」没那么痛。真正痛的是 ZRANGE key 0 -1 这种整体读取,以及 ZREMRANGEBYSCORE 这种大范围删除。
预防和对立面
拆是补救,预防才是正事。key 设计规范里加一条长度上限,比如单个 value 不超过 100KB、集合元素数不超过 5000,写进文档并且 code review 时看。上线前按业务预估的峰值数据量算一遍每个 key 的量级。写入侧加校验,列表追加时顺手 LTRIM,哈希写入时检查字段数,这是最省事的防线,因为它在数据变大的那一刻就拦住了。监控侧定期跑一次 --bigkeys 或者在从库上跑扫描脚本,把结果存下来做趋势对比,比单次快照有用得多,INFO stats 里的 expired_keys 突然涨起来也说明有大 key 在集中过期。
对立面也得承认。有些业务天然就是大 key,改不掉。一个全站的配置对象,几千个字段,业务每次都要整体读,你拆成十个哈希也只是把一次 HGETALL 变成十次,没解决问题。一个全站排行榜,几百万成员,业务要按名次查任意位置,分片之后跨分片的名次计算反而更复杂。这类情况下正确的做法不是硬拆,是承认它存在,把它纳入监控,单独评估内存和带宽,必要的时候挪到独立实例上,别和核心业务抢资源。
--bigkeys 也是同理。它粗糙,只给每种类型最大的一个 key,不按字节数排序,但它零成本、随 Redis 自带、能跑在从库上。很多团队的大 key 问题就是靠它第一次暴露出来的。工具的价值不在于完美,在于它让人开始看这件事。
Redis HotKey 问题
什么算热 key
热 key 跟大 key 不是一个维度。大 key 说的是单个 key 占的资源多,热 key 说的是单个 key 承受的请求多。一个 1KB 的字符串,每秒被访问十万次,它就是热 key,而且比大多数大 key 更危险,因为它打的是 CPU 和网络,不打内存。
判定标准比大 key 好定,因为 QPS 是可测的。一条常用经验是单 key 的 QPS 超过所在节点总 QPS 的百分之十就值得关注,按单实例十万 QPS 的量级算,百分之十就是一万 QPS,足够让一个 CPU 核忙起来;另一条更保守的经验是绝对值超过 1000 QPS 就先看一眼,因为小规格实例的总能力可能只有几千 QPS。原则是拿单 key 的 QPS 跟节点总 QPS 比,而不是看绝对数字。
比阈值更重要的是看趋势。QPS 从一百涨到一千,这是业务在变热,你有时间准备;从一千涨到十万,可能是活动上线或者被爬了,留给你的时间只有几分钟。
热点是怎么产生的
业务天然热点是最常见的一类。秒杀的一个商品、爆款的详情页、首页的推荐位、明星发的一条微博、正在直播的直播间,这些场景下流量本来就集中在一个对象上,不是设计有问题,是业务形态决定的。这类热点可预测,大促前你知道哪个商品要上,可以提前预热。
缓存设计问题产生的热点是第二类。最典型的是所有请求都打同一个 key,比如一个全局配置、一个全局开关、一个全局排行榜。写这个 key 的人当时可能只是想「大家都要读,放 Redis 里吧」,没想到几万台机器每秒都在读同一个 key。这类热点本可以避免,改 key 设计就能解决。
热点转移是第三类,也最容易被忽略。你给一个 key 加了本地缓存或者做了分片,结果本地缓存集体失效、或者分片之一所在的节点挂了,流量瞬间全打向剩下的那一个 key,这类热点是自己制造的,出现时机是故障时刻。
突发流量是第四类。一条新闻上了热搜、爬虫盯上了某个页面、有人拿脚本刷接口,都会在几分钟内把某个 key 的 QPS 推上去,只能靠监控和限流兜住。Cluster 槽倾斜是第五类,它不是某个 key 热,是某个节点上的 key 集体热,比如所有 {user} 打头的 key 因为同一个 hash tag 落到同一个槽。
热 key 和大 key 不是一回事
这两件事经常被混着说,其实成因、危害和应对都不同。大 key 的问题是数据量大、命令复杂度高,伤害集中在一条命令执行很久;热 key 的问题是请求集中,伤害集中在一个节点被反复打。一个 key 可以又大又热,比如爆款商品的详情 JSON;也可以只大不热,比如归档的历史数据哈希;也可以只热不大,比如一个全局开关,1KB 大小但每秒十万次读,这是最容易被低估的一种。
怎么发现热 key
redis-cli --hotkeys 是最省事的入口。
bash
# 依赖 LFU 淘汰策略,否则直接报错
redis-cli -h 127.0.0.1 -p 6379 --hotkeys
它的原理是遍历 key 读 OBJECT FREQ,也就是 LFU 的访问频率计数器,按频率排序输出。这里有个前提很多人不知道,maxmemory-policy 必须是 LFU 系列 ,也就是 allkeys-lfu 或 volatile-lfu,否则 Redis 根本不记录访问频率,命令会直接报错。如果你现在的策略是 allkeys-lru,得先改策略,然后等一段时间让计数器积累起来,再跑 --hotkeys 才有意义,刚改完立刻跑看到的全是初始值。
第二个手段是 MONITOR,它把执行的每一条命令实时推给当前连接,能直接看到哪个 key 被反复访问。但代价很大,官方文档明确说明它会显著降低吞吐,因为每条命令都要格式化后推给监控连接,这活干在主线程,监控连接本身也可能成为输出瓶颈。所以它只能短开,绝不能在线上长期挂着,真要用就配合管道限条数,比如 redis-cli monitor | head -n 5000,看完就断。
第三个手段是客户端埋点,最准确也最安全。在客户端封装层统计每个 key 的访问次数,定期汇总上报,它不影响 Redis 本身,能看到真实的 key 维度分布,还能带上业务标签。代价是要改代码,统计逻辑本身也有开销,得用低成本的计数结构。
python
# 客户端埋点统计 key 访问频次,思路示例,生产环境要换成无锁计数加定期上报
import time
from collections import Counter
_counter = Counter() # 累计计数,简单场景够用
_last_flush = time.time()
FLUSH_INTERVAL = 10 # 每 10 秒上报一次
def counted_get(r, key: str):
# 读路径上只加一次内存计数,开销极低
_counter[key] += 1
_flush_if_needed()
return r.get(key)
def _flush_if_needed():
global _last_flush
now = time.time()
if now - _last_flush < FLUSH_INTERVAL:
return
_last_flush = now
# 上报给监控系统,然后清空计数器
report(dict(_counter))
_counter.clear()
def report(snapshot: dict) -> None:
# 这里接你们的监控 SDK,比如打点到 Prometheus 或者日志系统
for key, count in snapshot.items():
Metrics.counter("redis.key.qps", count, tags={"key": key})
第四个手段是代理层统计。如果流量走 Redis Proxy,代理是最理想的观测点,它能看到所有请求又不影响 Redis,在代理里按 key 做计数和滑动窗口统计,成本比客户端埋点低,覆盖面比客户端全,代价是引入代理本身多了一层网络跳数和运维复杂度。
INFO commandstats 和 INFO latencystats 只能看命令维度,看不到 key 维度,你只知道 GET 被调了多少次、平均耗时多少,不知道是哪个 key 在拖后腿。SLOWLOG 这里要澄清一个误解,慢命令和热 key 不是一回事,热 key 如果命令本身很轻,比如 GET,耗时可能只有几十微秒,永远进不了慢日志;冷 key 的 HGETALL 可能因为数据量大进慢日志,但它完全不热。慢日志能帮你找大 key,找热 key 得靠访问频次统计。
Cluster 下还要看槽倾斜。
redis
# 看每个槽里有多少 key,用来发现数据倾斜
CLUSTER COUNTKEYSINSLOT 3999
# 看槽到节点的映射,确认哪些槽在同一台机器上
CLUSTER SLOTS
CLUSTER COUNTKEYSINSLOT 给的是 key 数量不是 QPS,要结合业务访问模式判断,如果某个槽里的 key 都是同一类高频访问的,那这个槽所在的节点就是热点节点。
本地缓存,最有效的一层
扛热 key 最有效的手段是本地缓存。逻辑很直接,请求在应用进程里就命中返回,根本到不了 Redis,Redis 上的 QPS 自然降下来。对一个每秒十万次读同一个 key 的场景,本地缓存能把到达 Redis 的请求数降到个位数。
Java 侧最常用的是 Caffeine,它是 Guava Cache 的替代品,并发性能和内存效率都更好。
java
// 本地缓存,用来挡住热 key 的读流量
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
public class LocalCache {
// maximumSize 控制内存上限,expireAfterWrite 控制最长的不一致窗口
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存一万个 key
.expireAfterWrite(Duration.ofSeconds(5)) // 五秒后强制回源
.recordStats() // 打开统计,用它看命中率
.build();
public String get(String key) {
// 命中就直接返回,请求根本到不了 Redis
return cache.get(key, k -> loadFromRedis(k));
}
private String loadFromRedis(String key) {
// 回源逻辑,回源失败时可以在这里返回兜底值
return RedisClient.get(key);
}
public double hitRate() {
// 命中率是判断本地缓存有没有用的第一指标
return cache.stats().hitRate();
}
// 数据变更时主动失效,把不一致窗口压到最小
public void invalidate(String key) {
cache.invalidate(key);
}
}
用本地缓存有三件事必须想清楚。容量控制是第一件,maximumSize 和 expireAfterWrite 是两个维度,前者管内存,后者管新鲜度,不设 maximumSize 的话,一个爬虫用不同的 key 刷接口就能撑爆 JVM 堆,这比 Redis 被打满更严重。
一致性是第二件,也是最大的代价。多实例部署时每个进程的本地缓存是独立的,一个实例更新了数据,别的实例可能还在用旧值,这个窗口就是 expireAfterWrite 的长度。缩小窗口只有两条路,把 TTL 设短,代价是回源次数变多、命中率下降;或者用广播失效,数据变更时通过 Redis Pub/Sub 或消息队列通知所有实例清掉对应的缓存项,能把窗口压到毫秒级,但消息丢了就退化成 TTL 方案。冷启动是第三件,应用刚启动时本地缓存是空的,所有请求同时回源,如果这个 key 本来就是热 key,重启一批机器等于对 Redis 发起一次集中攻击,解决办法是启动时预热,或者只对已确认的热点 key 启用本地缓存。recordStats 打开的统计能帮你判断哪些 key 真的值得缓存,命中率低的 key 缓存它只是浪费内存。
key 分片,把一份读放大成多份
第二个手段是 key 分片,把一个热 key 复制成多份,读的时候随机挑一份。
python
# 把热 key 复制成 N 份,读的时候随机挑一份,把单 key 的 QPS 摊开
import random
import redis
N = 10 # 分片数量,10 到 100 之间比较常见
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def write_hot(key: str, value: str) -> None:
# 写的时候要写全部分片,否则读到的可能是旧值
pipe = r.pipeline()
for i in range(N):
pipe.set(f"{key}:{i}", value)
pipe.execute()
def read_hot(key: str) -> str:
# 读的时候随机挑一份,单 key 的 QPS 被摊到 N 个 key 上
idx = random.randrange(N)
return r.get(f"{key}:{idx}")
def delete_hot(key: str) -> None:
# 删除也要删全部分片,漏掉一片就是脏数据
pipe = r.pipeline()
for i in range(N):
pipe.delete(f"{key}:{i}")
pipe.execute()
这个方案的适用面比想象中窄,因为它是拿写入复杂度换读性能。写的时候要写 N 份,写放大了 N 倍,所以它只适合读多写少的场景。另一种写法是只写主分片,再异步同步到其它分片,代价是有一致性延迟窗口,只有在业务能容忍时才用。
分片数量是个权衡,太少起不到拆分效果,只分两片的话单 key QPS 从十万降到五万,还是热的;太多会让写入的 Pipeline 变长,也会让全量删除和统计变麻烦,Cluster 下这些分片 key 还会散到不同节点。十到一百之间是比较常见的落点。还有一点,分片之后 key 名变了,任何按前缀扫描、按 key 统计的逻辑都要跟着改。
其余几种扛法
Cluster 多副本读是第三种。Cluster 下每个分片有从节点,客户端可以发 READONLY 命令让当前连接允许从从节点读数据,把读流量分摊到从节点上。它适合读远多于写的场景,代价是主从复制是异步的,从节点数据可能比主节点旧,凡是写完立刻要读到自己刚写的值的链路不能用。
代理层本地缓存是第四种。如果流量经过 Redis Proxy,可以在代理进程里加一层短 TTL 的本地缓存,请求在代理这一层就被挡掉,连后端 Redis 都不用碰,好处是业务代码完全不用改,坏处是多了一层不一致窗口和运维复杂度。
限流和降级是最后一道防线。热点的本质是请求量超过了单点能承受的上限,前面所有手段都在提高上限或者分摊请求,但总有一个量级是接不住的。这时候要在网关或者业务层限流,把超过阈值的请求拒掉,保护 Redis 不被打死;降级则是给被拒的请求一个体面的回应,返回本地缓存里的旧值、返回默认值、或者返回静态兜底页面。这两件事的取舍要提前跟业务方谈好。
组合方案
实际线上通常不是单一手段,而是按层次叠起来,下图是一条常见的读路径。
#mermaid-svg-Qd2zlv0AXpMNtat7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Qd2zlv0AXpMNtat7 .error-icon{fill:#552222;}#mermaid-svg-Qd2zlv0AXpMNtat7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Qd2zlv0AXpMNtat7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .marker.cross{stroke:#333333;}#mermaid-svg-Qd2zlv0AXpMNtat7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Qd2zlv0AXpMNtat7 p{margin:0;}#mermaid-svg-Qd2zlv0AXpMNtat7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster-label text{fill:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster-label span{color:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster-label span p{background-color:transparent;}#mermaid-svg-Qd2zlv0AXpMNtat7 .label text,#mermaid-svg-Qd2zlv0AXpMNtat7 span{fill:#333;color:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .node rect,#mermaid-svg-Qd2zlv0AXpMNtat7 .node circle,#mermaid-svg-Qd2zlv0AXpMNtat7 .node ellipse,#mermaid-svg-Qd2zlv0AXpMNtat7 .node polygon,#mermaid-svg-Qd2zlv0AXpMNtat7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .rough-node .label text,#mermaid-svg-Qd2zlv0AXpMNtat7 .node .label text,#mermaid-svg-Qd2zlv0AXpMNtat7 .image-shape .label,#mermaid-svg-Qd2zlv0AXpMNtat7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Qd2zlv0AXpMNtat7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .rough-node .label,#mermaid-svg-Qd2zlv0AXpMNtat7 .node .label,#mermaid-svg-Qd2zlv0AXpMNtat7 .image-shape .label,#mermaid-svg-Qd2zlv0AXpMNtat7 .icon-shape .label{text-align:center;}#mermaid-svg-Qd2zlv0AXpMNtat7 .node.clickable{cursor:pointer;}#mermaid-svg-Qd2zlv0AXpMNtat7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .arrowheadPath{fill:#333333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qd2zlv0AXpMNtat7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Qd2zlv0AXpMNtat7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qd2zlv0AXpMNtat7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster text{fill:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 .cluster span{color:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Qd2zlv0AXpMNtat7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Qd2zlv0AXpMNtat7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Qd2zlv0AXpMNtat7 .icon-shape,#mermaid-svg-Qd2zlv0AXpMNtat7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qd2zlv0AXpMNtat7 .icon-shape p,#mermaid-svg-Qd2zlv0AXpMNtat7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Qd2zlv0AXpMNtat7 .icon-shape .label rect,#mermaid-svg-Qd2zlv0AXpMNtat7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qd2zlv0AXpMNtat7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Qd2zlv0AXpMNtat7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Qd2zlv0AXpMNtat7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
命中
未命中
是
否
是
否
客户端请求
进程内本地缓存
直接返回
代理层缓存
返回并回填上层
key 在热点列表里吗
随机读一个分片 key
读原始 key
超过限流阈值吗
降级返回兜底值
正常读 Redis
回填本地缓存与代理缓存
每一层都在减少到达下一层的请求量,最上面那层效果最大。
热点探测到自动缓存
组合方案里有个隐含问题,哪些 key 该进本地缓存。手动维护一个列表在大促前可行,长期跑就得自动化。思路是让一个独立的探测模块统计 key 访问频次,超过阈值就把 key 提升成本地缓存,冷下来再退出去。
python
# 热点探测到自动本地缓存,伪代码,重点是流程不是具体 API
import time
from collections import Counter
WINDOW = 10 # 统计窗口,秒
THRESHOLD = 1000 # 一个窗口内访问次数超过这个值算热点
_local_cache = {} # 进程内缓存,key 到值的映射
_hot_keys = set() # 当前的热点 key 集合
_counter = Counter() # 本窗口的访问计数
def on_read(key: str, loader):
"""业务读路径,热点 key 走本地缓存,其余直连 Redis"""
if key in _hot_keys:
hit = _local_cache.get(key)
if hit is not None:
return hit
_counter[key] += 1 # 只做一次内存加一,开销可忽略
value = loader(key)
if key in _hot_keys:
_local_cache[key] = value # 热点 key 回填本地缓存
return value
def detect_loop():
"""独立线程,每个窗口汇总一次,负责热点的进出"""
global _counter
while True:
time.sleep(WINDOW)
snapshot, _counter = _counter, Counter() # 换一个新计数器,避免累加
current = set()
for key, count in snapshot.items():
if count >= THRESHOLD:
current.add(key)
if key not in _hot_keys:
# 新晋热点,立刻预热,避免刚发现时集中回源
_local_cache[key] = loader_from_redis(key)
_hot_keys.clear()
_hot_keys.update(current)
# 掉出热点的 key 直接从本地缓存移除,内存不会被长期占着
for key in list(_local_cache):
if key not in _hot_keys:
_local_cache.pop(key, None)
def loader_from_redis(key: str):
# 回源读一次,填充本地缓存
return redis_client.get(key)
几个点要注意。统计和业务读路径要解耦,探测不能加锁阻塞读,所以用换一个新计数器的方式做快照,而不是清空再累加。热点列表要有上下界,只加不减的话,长时间运行会攒出几万个曾经热过的 key,本地缓存的内存就失控了。新晋热点必须立刻预热,否则刚被判定为热点的瞬间,所有实例同时回源,正好制造一次集中冲击。
预防和边界
预防热 key 的第一件事是压测,而且是大促前按预估峰值的倍数压,压测的价值在于它能暴露单 key 维度的瓶颈,而平时的监控只能看到实例维度。第二件事是 key 设计上避免全局单点,凡是所有请求都读同一个 key 的地方,先问一句这个 key 的访问量级是多少,能拆成带维度的 key 就拆掉,拆不掉就给它加本地缓存和限流。第三件事是监控告警,单 key QPS 需要客户端埋点或代理层统计才能拿到,单节点 CPU 和槽分布可以从 Redis 侧拿到,告警阈值不要只设绝对值,设一个相对节点总 QPS 的占比更稳,因为实例扩容之后绝对值会变。
对立面同样要承认。大部分系统其实没有真正的热 key 问题,一个日活几万的应用,单个 key 的 QPS 可能只有几百,本地缓存和分片带来的复杂度纯属自找。判断标准很简单,看你的 Redis 有没有出现过单节点 CPU 打满、或者单个 key 的 QPS 占节点总 QPS 的百分之十以上,没有的话把精力放在大 key 和慢查询上收益更大。本地缓存也不是免费的,它引入了不一致窗口、内存管理问题、冷启动冲击,如果你的业务读的是用户自己的数据、本来就没有热点,加本地缓存只会让排查问题变难,因为数据有了两个来源。
回到主线。大 key 和热 key 是两个不同的敌人,一个打的是单次操作的复杂度,一个打的是单点的请求集中度,它们的共同点是都能让 Redis 从快变成慢,而慢的具体表现是什么,得靠下一章的排查手段去看。
Redis 慢查询排查
SLOWLOG 记录了什么,没记录什么
排查慢的第一步是 SLOWLOG。它记录执行时间超过阈值的命令,两个配置项决定它的行为。
redis
# 超过 10000 微秒,也就是 10 毫秒的命令才记录,设 0 记录所有命令,设负数关闭
slowlog-log-slower-than 10000
# 慢日志最多保留 128 条,超出后最老的被丢弃
slowlog-max-len 128
查看用这几条命令。
redis
# 取最近 10 条,不带参数默认也是 10 条
SLOWLOG GET 10
# 看当前有多少条
SLOWLOG LEN
# 清空,id 也会归零
SLOWLOG RESET
SLOWLOG GET 返回的每一条是一个数组,字段含义要记清。
id 是自增编号,RESET 之后从 0 重新开始
时间戳是命令执行完的 Unix 秒
耗时是执行时间,单位微秒
命令与参数是一个数组,参数过多时会被截断
客户端地址与端口,能定位是哪个应用发来的
客户端名称,用 CLIENT SETNAME 设置过才有
这里有个关键限制必须说清楚,慢日志只记录命令执行时间,不含网络传输和排队时间。所以从客户端视角看到的「慢」,在慢日志里可能是空的。网络问题、客户端到服务端的 RTT、连接池排队、大响应体的传输时间,这些都不在慢日志的统计范围内。它只能回答「Redis 自己执行这条命令花了多久」。
还有个反直觉的点,慢日志为空不等于没问题。如果阈值是 10 毫秒,大量耗时 8 毫秒的命令一条都不会出现,但它们累积起来足以把 P99 拖垮,而这些命令通常正是大 key 造成的。所以排查时可以把阈值临时调低,比如设成 1000 微秒,跑一段时间看形态,看完再调回去。
LATENCY 补上事件视角
慢日志看的是单条命令,LATENCY 看的是事件,两者互补。它默认是关闭的,要先设一个阈值。
redis
# 事件耗时超过 100 毫秒才记录,0 表示关闭
CONFIG SET latency-monitor-threshold 100
# 看当前有哪些事件在记录
LATENCY LATEST
# 看某个事件的历史采样,每个事件保留 160 条
LATENCY HISTORY fork
# 重置
LATENCY RESET
# 让 Redis 自己给一份诊断建议
LATENCY DOCTOR
事件类型里常见的有这些,command 是普通命令执行超时,fork 是 BGSAVE 或 BGREWRITEAOF 的 fork 耗时,expire-cycle 是过期 key 清理,eviction-del 和 eviction-cycle 是内存淘汰,aof-fsync-always 是 AOF 每条刷盘的耗时,active-defrag-cycle 是碎片整理。
它记录的是「某个事件从开始到结束花了多久」,所以能看到慢日志完全看不到的东西。一次 fork 卡了 200 毫秒,慢日志里一条记录都不会有,因为它不是某条命令慢,但客户端的请求确实被阻塞了 200 毫秒。这就是 LATENCY 的价值。
要注意阈值设太低会让记录本身变成负担,每个被记录的事件都要占内存,而且 LATENCY MONITOR 这类东西在事件频繁时开销不小,排查完就 RESET 掉。
commandstats 找平均耗时高的命令
INFO commandstats 给的是按命令聚合的统计。
redis
INFO commandstats
每条记录形如 cmdstat_get,字段有 calls 调用次数、usec 总耗时微秒、usec_per_call 平均每次耗时、rejected_calls 被拒绝的次数、failed_calls 执行出错的次数。找问题的用法是看 usec_per_call,哪个命令的平均耗时明显高于同类,就去查它操作的数据规模。GET 和 HGETALL 的平均耗时不在一个量级,如果 HGETALL 的平均值异常高,基本就是有超大哈希。
rejected_calls 和 failed_calls 的差别也要分清,前者是因为内存满了或者权限被拒而没执行的命令,后者是执行了但报错的命令,比如对 String 用 LPUSH。
Redis 7.0 还加了 INFO latencystats,它给每个命令的百分位分布,p50、p99、p999。平均值会被少量极值拉偏,一个每秒调用十万次、平均 50 微秒的命令,只要有几十次耗时 50 毫秒,平均值就会被抬高,但真正影响用户体验的是 P99。所以看延迟分布比看平均值更接近真实感受。
MONITOR 只在必要时短开
MONITOR 能看实时命令流,排查「到底谁在发这个命令」时非常直接。
redis
MONITOR
但它的性能代价很大,官方文档明确说明它会显著降低吞吐。原因有两层,每条命令都要格式化后推给监控连接,这活干在主线程;监控连接本身也可能因为输出太快而撑大输出缓冲区。所以它只能短开,配合 head 限制条数,或者用 timeout 命令限时,绝不能在线上长期挂着。
CPU 和网络
命令层面看完,还得排除 Redis 之外的因素。
INFO cpu 给四个字段,used_cpu_sys 和 used_cpu_user 是主进程在内核态和用户态消耗的 CPU 秒数,used_cpu_sys_children 和 used_cpu_user_children 是后台子进程消耗的,主要是 RDB 和 AOF 重写。如果子进程的 CPU 在涨而主进程没怎么涨,说明压力来自持久化而不是命令执行。redis-cli --stat 每秒打一行,能同时看到 ops、内存、连接数的变化趋势,适合盯一段时间。top -H -p <pid> 看线程级 CPU,单核打满说明命令执行是瓶颈,多核同时打满则可能是 fork 或者 IO 线程在干活。
网络这块要区分「Redis 慢」和「网络慢」,redis-cli 提供了几个工具。
bash
# 采样 ping 的延迟,看平均值
redis-cli -h 127.0.0.1 -p 6379 --latency
# 分段采样,能看出延迟随时间的波动
redis-cli -h 127.0.0.1 -p 6379 --latency-history
# 延迟分布图,能看出长尾
redis-cli -h 127.0.0.1 -p 6379 --latency-dist
# 测这台机器本身的调度延迟,跑 10 秒
redis-cli --intrinsic-latency 10
判断逻辑是这样。如果 --latency 的数值稳定且接近 RTT,而业务侧的 P99 很高,问题大概率在客户端到 Redis 的链路或者客户端自己。如果 --intrinsic-latency 本身就很高,说明机器有问题,可能是虚拟化环境下的 CPU 争抢、内存交换、或者网卡中断不均衡,这时候 Redis 再优化也没用。大 value 导致带宽打满是另一个常见原因,看网卡流量就能确认。TCP 重传也要看,netstat -s 或者 ss -ti 里的重传计数持续增长,说明链路有丢包,表现就是客户端时不时超时。
排查决策顺序
把上面的手段串起来,就是一套有顺序的排查路径。顺序很重要,因为它决定了你每一步在排除什么。
先看 SLOWLOG,这是最直接的入口。有慢命令就定位命令类型,如果是 HGETALL、KEYS、SMEMBERS、SORT 这类 O(N) 或全库命令,基本就是大 key,直接去查 key 的规模并拆分。如果慢日志是空的,说明单条命令都不慢,问题不在命令执行本身,往下走。
然后看 INFO commandstats,找 usec_per_call 明显偏高的命令,再看 INFO latencystats 的百分位。这一步是在找「平均看起来还行但长尾很糟」的命令。
接着看 LATENCY,它能把 fork、expire-cycle、eviction-del、aof-fsync-always 这些系统级事件暴露出来。如果这里看到 fork 有几百毫秒的尖峰,那优化方向是调整持久化策略,不是优化命令。
再往下是排除外部因素,看 CPU 是不是单核打满,看网络延迟和重传。这两步是排除「Redis 其实没问题」的情况,很多排查走到最后发现是客户端连接池配小了,或者网卡带宽跑满了。
最后回到大 key 和热 key。命令统计和延迟事件都没问题时,瓶颈往往就在这两个地方,用 --bigkeys、--memkeys 和客户端埋点去定位。
渲染错误: Mermaid 渲染失败: Parse error on line 4: ...令类型] C --> D{是 O(N) 或全库命令吗} D -- ----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
顺着这条路走,大部分问题在第二步或第三步就能定位。真正难的是那种「所有指标都正常但业务就是慢」的情况,那时候要看的是客户端侧,连接池、超时配置、序列化开销,这些都在 Redis 的视野之外。
回到主线。慢查询这一章解决的是「怎么知道它慢」,知道了之后通常会发现两类根因,大 key 和热 key,前面两章已经讲完。剩下的是长期运行的成本问题,内存。下一章看内存怎么算、怎么省。
Redis 内存优化
先把内存看清楚
排查内存的第一步是搞清楚 Redis 认为自己用了多少、操作系统认为它用了多少,这两个数经常不一样。
redis
INFO memory
# 单个 key 的内存占用,默认采样 5 个元素估算
MEMORY USAGE user:1:profile
# 按用途拆分的内存统计
MEMORY STATS
# 让 Redis 自己给一份诊断意见
MEMORY DOCTOR
INFO memory 里要盯住的字段有几个。used_memory 是分配器统计的逻辑内存,也就是 Redis 自己认为在用的量;used_memory_rss 是操作系统看到的物理内存;used_memory_peak 是历史峰值,容量规划要按它来;used_memory_dataset 是纯数据占的部分,等于 used_memory 减去自身开销;used_memory_lua 是 Lua 虚拟机占的;mem_fragmentation_ratio 是碎片率;mem_allocator 是分配器名字,默认是 jemalloc。
used_memory 和 used_memory_rss 的差就是碎片,正常在百分之十以内,差得越多说明分配器留下的空洞越多。MEMORY STATS 更进一步,把内存按用途拆开,能看到客户端缓冲区、复制 backlog、AOF 缓冲、每个数据库各占多少,排查「内存不知道被谁吃了」的时候很有用。MEMORY DOCTOR 是让 Redis 自己给建议,碎片率偏高或者内存接近上限时会给出提示。
encoding 决定同一份数据的代价
同样的数据用不同编码,内存能差一个数量级,这是内存优化里性价比最高的一层。
redis
OBJECT ENCODING user:1:profile
小对象会用 listpack 这类紧凑结构存,大了才换成 hashtable 或者 skiplist。一个只有十个字段的哈希,用 listpack 存是连续内存,没有指针也没有哈希桶;字段数超过阈值之后变成 hashtable,每个字段都要一个 dictEntry 和一个 robj,内存直接翻几倍。所以阈值参数值得记住。
| 参数 | 默认值 | 作用 |
|---|---|---|
| hash-max-listpack-entries | 512 | Hash 的字段数上限 |
| hash-max-listpack-value | 64 | Hash 单个字段名或值的字节上限 |
| zset-max-listpack-entries | 128 | ZSet 的元素数上限 |
| zset-max-listpack-value | 64 | ZSet 成员的字节上限 |
| set-max-intset-entries | 512 | Set 全是整数时的元素数上限 |
| set-max-listpack-entries | 128 | Set 用 listpack 的元素数上限 |
| list-max-listpack-size | -2 | List 的 listpack 大小,负数按字节算,负二对应 8KB |
这里有个坑必须提醒,转换是不可逆的 。一个哈希一旦超过阈值变成 hashtable 编码,即使你之后删掉一部分字段让数量降回阈值以下,它也不会自动转回 listpack,需要把 key 删掉重建或者用 RESTORE 迁回来。所以调大阈值这件事要在数据量小的时候做,等数据长起来再调就晚了。
小对象优化的几个细节
整数用 int 编码,SET n 100 存的是整数编码的对象,不占字符串空间。短字符串用 embstr,长度不超过 44 字节时 robj 和 SDS 分配在一块连续内存里,省掉一次分配和一次指针跳转。0 到 9999 的整数在 maxmemory 为 0 或者淘汰策略不是 LRU 和 LFU 系列时会复用共享对象,因为共享对象没法单独记录访问时间和频率,一旦开了 LRU 或 LFU,Redis 只能给每个 key 单独建对象。
key 名本身也占内存。user:1001:profile 这样的 key,字符串本身、SDS 头、dictEntry、robj 加起来是几十字节的量级,几百万个 key 就是几百 MB。所以别写没有意义的长前缀,mycompany:prod:cn:user:profile:1001 这种省掉一半字符就能省出可观的内存。
Hash 为什么比一堆 String 省
把十个字段拆成十个 String key,每个 key 都要一份 robj、一个 dictEntry、一个 SDS 头,这些开销加起来往往比字段值本身还大。用一个 Hash 装同样的十个字段,只需要一个 key 的固定开销加十个 listpack 条目,字段数不多的时候能省下大半内存。
代价是使用上的限制,Hash 里的字段没有单独的 TTL,也没法单独设过期时间,所以只适合整组一起读写的场景。在 listpack 编码下字段名也要占空间,把 userNickname 改成 nn 这种短名,几十万个 key 累积下来差别不小。反过来,不要把 Hash 当 Set 用,也就是别用一堆值都是 1 的字段来表示集合,那样既不能做集合运算,也不如 Set 省。
TTL 与过期速率
用不上的 key 一定要设 TTL,这是最容易被忽略的一条。很多线上实例内存涨到上限,查下来是一堆业务早就不用的 key 还躺在那里,因为写入时压根没设过期时间。
设 TTL 时要注意别让大批 key 在同一时刻过期。一批缓存统一在凌晨写入、统一设 24 小时,第二天凌晨它们会一起过期,过期清理要连续处理几十万个 key,同时后面的请求全部回源,形成一次小规模雪崩。做法是给基础 TTL 加随机抖动,比如 3600 秒的 TTL 加上 0 到 300 秒的随机值。
过期速率看 INFO stats 里的 expired_keys,它突然跳高说明有大 key 在集中过期。过期删除有两条路径,惰性删除是访问时判断,定期删除是 serverCron 里采样,两者都在主线程释放内存,所以大 key 过期会卡,lazyfree-lazy-expire yes 可以让释放走后台。
碎片率与 activedefrag
碎片率是 used_memory_rss 除以 used_memory。大于 1 说明物理内存比逻辑数据多,多出来的是分配器留下的空洞,原因是 jemalloc 按固定的大小档分配内存,一批 key 反复增删之后空闲块凑不出连续的大块。大于 1.5 就该关注了,它意味着你有相当一部分内存是浪费的。小于 1 更危险,说明 used_memory_rss 比 used_memory 还小,通常是因为部分内存被换到了 swap,这时候延迟会突然飙到毫秒级。
Redis 4.0 引入了 activedefrag,可以在运行时整理碎片。
redis
# 打开自动碎片整理
activedefrag yes
# 碎片浪费的字节数低于 100MB 就不动手
active-defrag-ignore-bytes 100mb
# 碎片率超过 10% 开始整理,超过 100% 全力整理
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100
# 每次整理占用的 CPU 比例,最低 5%,最高 75%
active-defrag-cycle-min 5
active-defrag-cycle-max 75
这几个参数的逻辑是,碎片浪费太小不值得动,碎片率越高越积极,同时给 CPU 占用设上下限,避免整理本身把请求延迟拖垮。要提醒一句,碎片整理是要花 CPU 的,active-defrag-cycle-max 设得太高,整理期间延迟会明显上升。
最彻底的解法还是重启。把主库切到从库、重启原主库再切回来,碎片就没了,代价是一次切换窗口。
maxmemory 与客户端缓冲区
maxmemory 要留出写时复制的余量。fork 之后如果写入很猛,被改的内存页会被复制,最坏情况下额外内存接近原数据量的一半到一倍,所以 maxmemory 通常设成物理内存的一半到三分之二,而不是设成物理内存本身。
客户端缓冲区也是内存黑洞。一个慢客户端连着,Redis 往里写数据它却不读,输出缓冲区就会一直涨,client-output-buffer-limit 用来给它设上限,分成 normal、replica、pubsub 三类,超过限制就直接断开连接。Redis 7.0 又加了 maxmemory-clients,可以给所有客户端的缓冲区设一个总量上限,防止个别客户端把整个实例的内存吃掉。
回到主线。内存优化解决的是「同样的数据占更少的内存」,但内存总会有满的时候,满了之后怎么办,是下一章要讲的淘汰策略。
Redis 内存淘汰策略
maxmemory 和它的作用范围
maxmemory 定义实例能用多少内存,设为 0 表示不限制。它比较的是 used_memory,也就是分配器统计的逻辑内存,不含碎片。达到上限之后,Redis 在执行每条可能分配内存的写命令前会先检查内存,超了就按 maxmemory-policy 淘汰一部分 key 腾空间,腾不出来就报错。
所以淘汰不是后台任务,它挂在写命令的路径上。这一点决定了它的代价,后面讲抖动时会回到这里。
八种策略逐一过
| 策略 | 作用范围 | 淘汰依据 | 适用场景 | 风险 |
|---|---|---|---|---|
| noeviction | 不淘汰 | 无 | 当数据库用,数据不能丢 | 写满直接报错 |
| allkeys-lru | 所有 key | 最久未访问 | 纯缓存 | 冷数据淘汰后回源变慢 |
| allkeys-lfu | 所有 key | 访问频率最低 | 纯缓存且热点稳定 | 新 key 容易短期被淘汰 |
| allkeys-random | 所有 key | 随机 | 访问分布均匀 | 可能淘汰掉热点 key |
| volatile-lru | 设了 TTL 的 key | 最久未访问 | 缓存与持久数据混用 | 没 TTL 的 key 永不淘汰 |
| volatile-lfu | 设了 TTL 的 key | 访问频率最低 | 同上且热点稳定 | 同上 |
| volatile-random | 设了 TTL 的 key | 随机 | 少见 | 同上 |
| volatile-ttl | 设了 TTL 的 key | 剩余 TTL 最小 | 想让快过期的先走 | 同上 |
noeviction 是默认值。内存满了之后,需要分配内存的写命令直接返回错误,错误信息是 OOM command not allowed when used memory > 'maxmemory',但读命令和 DEL 仍然可以执行,这一点很关键,因为它意味着你还有机会删掉一些数据把实例救回来,而不是只能重启。
allkeys-lru 是最常用的一个,从所有 key 里挑最久没被访问的淘汰。allkeys-lfu 是 4.0 引入的,按访问频率淘汰,适合热点长期稳定的场景。allkeys-random 随机淘汰,只在访问分布本身很均匀、不介意淘汰谁的时候用,实际很少见。volatile-lru、volatile-lfu、volatile-random 只在一部分 key 里淘汰,也就是设了 TTL 的那些。volatile-ttl 的依据是剩余存活时间,越快要过期的越先被淘汰。
allkeys 和 volatile 的差别
这两组最大的区别是候选集。volatile-* 只在设了 TTL 的 key 里挑,如果一个实例里没有 TTL 的 key 占满了内存,候选集是空的,淘汰就无从下手,表现退化成 noeviction,写入直接报错。所以用 volatile-* 的前提是所有该被淘汰的 key 都设了 TTL,而且要在监控里确认这一点,否则你会在某天发现实例突然写不进去了。
allkeys-* 不需要 key 有 TTL,任何时候都能淘汰,代价是它可能淘汰掉你其实不想丢的数据。缓存和持久数据混在同一个实例里的时候这个取舍很纠结,因为 Redis 并不知道哪个 key 重要。更稳的做法是物理隔离,缓存和持久数据放不同实例,各自用不同的策略。
LRU 是怎么近似的
Redis 不维护完整的 LRU 链表。链表要给每个 key 加两个指针,几百万 key 就是几十 MB 的额外开销,还要在每次访问时把节点挪到链表头部,这个动作在单线程里是实打实的成本。
它的做法是给每个对象存一个 24 位的访问时间戳,精度是秒。淘汰时随机采样 maxmemory-samples 个 key,默认是 5 个,从中挑空闲时间最长的那个淘汰。采样数越大越接近真正的 LRU,CPU 成本也越高。
为什么 5 个采样就够用,直觉是这样。假设大多数 key 都很久没访问了,只有少数几个是热点,那随机抽 5 个,大概率抽到的都是冷的,挑出来的那个也就够冷了。它不保证找到全局最老的那个,但它避开了「恰好淘汰了一个热点 key」这种最坏情况。相比之下 allkeys-random 是纯随机,抽到热点的概率等于热点在 key 总量里的占比,热点越少越容易误伤。
Redis 3.0 之后还加了一个候选池,每次采样到的较老 key 会被放进池子里,淘汰时从池子里挑最老的,这样多轮采样的结果会累积,效果比单次采样好。
LFU 的计数器和衰减
LFU 复用同一个字段,把它拆成两部分,16 位存最后一次衰减的时间,8 位存访问计数。计数器的增长不是线性的,lfu-log-factor 控制对数递增的陡峭程度,默认 10,值越大越难涨。这个设计是为了防止一个曾经被访问几百万次的 key 把计数器占满,导致后来者永远追不上。
计数器还会随时间衰减,lfu-decay-time 默认是 1,单位是分钟,表示每过一段时间计数就减一点。衰减的作用是让曾经热但现在不热的 key 慢慢掉下来,否则一个昨天的爆款会一直占着位置。
新 key 的初始计数是 LFU_INIT_VAL,值是 5,不是 0。这个设计的意图很明确,如果一个新 key 从 0 开始,它会在下一轮淘汰里立刻被赶出去,哪怕它马上就要变成热点。当前频率用 OBJECT FREQ key 查看,但只有在 LFU 策略下才有意义。
LRU 和 LFU 的差别在于看什么。LRU 看最近有没有被访问,适合「近期访问最重要」的场景,比如刚上线的活动页;LFU 看被访问了多少次,适合「热点长期稳定」的场景,比如一个常年被读的配置。选错了会有副作用,用 LFU 跑一个每天换一批热点的业务,计数器衰减跟不上,老热点会赖着不走。
怎么选
纯缓存场景用 allkeys-lru 或者 allkeys-lfu。访问模式里「最近」比「频率」更重要就用 LRU,热点长期稳定就用 LFU,拿不准先用 LRU,它的行为更好预测。
缓存和持久数据混在一起,理论上用 volatile-lru,但必须确保所有缓存 key 都设了 TTL,并且用监控确认没有「没有 TTL 的 key 占大头」的情况。做不到这一点就选 allkeys-*,至少它不会在某天让写入突然失败。真把 Redis 当数据库用,就配 noeviction,同时把内存告警提前,让运维在写满之前介入。
淘汰带来的抖动
maxmemory 接近上限时,每次写命令前都可能触发淘汰,淘汰要采样、要释放内存、释放大对象时还要走异步删除,这些动作都挤在命令执行路径上,结果是延迟出现周期性抖动,P99 明显抬高。
观察指标是 INFO stats 里的 evicted_keys,看增长速率比看总量有用,速率稳定说明内存在持续紧张,速率突然跳高说明有大批数据在集中写入。Redis 7.0 还加了 total_eviction_exceeded_time 这类字段,记录内存超过 maxmemory 的累计时长。
缓解办法有几个,maxmemory 留出余量不要贴边、用 lazyfree-lazy-eviction yes 让释放走后台、把冷数据提前清理掉而不是等它被淘汰。核心思路是别让实例长期处在「一写就要淘汰」的状态。
回到开头那条线。Redis 快,是因为它省掉了磁盘那一档开销,而省不掉网络那一档;让它变慢的,是大 key 的操作复杂度、热 key 的请求集中、以及慢命令对事件循环的占用;决定它能装多少、能跑多久的,是内存和淘汰策略。这三块连起来看,性能优化其实不是让快的更快,是让慢的别拖累快的。一个百万字段的哈希不会让 Redis 本身变慢,它只是让所有等在那条线上的请求都变慢。