08-Redis 性能优化篇:快在哪、BigKey、HotKey、慢查询与内存

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 本身变慢,它只是让所有等在那条线上的请求都变慢。

相关推荐
阳光九叶草LXGZXJ1 小时前
达梦数据库-学习-68-dmasm0X_XXXXXX.log日志激增
linux·运维·数据库·sql·学习
Mortalbreeze1 小时前
MySQL 基础篇(三):一文掌握 MySQL 常见数据类型
linux·服务器·数据库·mysql
青山木1 小时前
秒杀系统设计(一):需求拆解与流量治理
java·数据库·redis·后端·架构
lusklusklusk1 小时前
Oracle数据库基础之8_闪回
数据库·oracle
lusklusklusk2 小时前
Oracle数据库基础之10_性能优化
数据库·oracle·性能优化
Omics Pro2 小时前
预测性虚拟细胞中显式机理算子
大数据·数据库·人工智能·python·算法·机器学习·自然语言处理
看浪的路人2 小时前
第5讲:Prompt 管理与版本追踪
大数据·数据库·elasticsearch
Eloudy2 小时前
全文 - version.A - AMBA CHI Chip-to-Chip(C2C)
java·开发语言·数据库·gpu·chiplet