你可能遇到过这种场面:监控里 used_memory 还有余量,机器内存却已经亮红灯;删掉一批 key 之后,Redis 说自己确实少用了几 GB,操作系统看到的 RSS 却几乎没动。团队一着急,开始缩短键名、调大紧凑编码阈值、批量加 TTL,最后内存没省多少,P99 反而先坏了。
问题通常不在于“Redis 不会省内存”,而在于我们把几种不同的内存混成了一个数字。业务数据、Redis 对象开销、分配器页面、进程 RSS、复制与 AOF 缓冲、后台持久化时的写时复制峰值,各自回答的是不同问题。内存优化的第一步不是改配置,而是先确认哪一层真的在增长。

这节课会走一条完整的工程路径:测量当前状态,建立可比较的基线,选择低风险改动,用真实数据分布压测,再观察复制和持久化期间的峰值,最后决定推广还是回滚。你不会看到“把 70 GB 一键压到 3 GB”这类承诺,因为 Redis 的实际开销会受到版本、分配器、键值长度、数据分布、TTL、写入模式和命令组合影响。可信的节省比例只能从你自己的基线里算出来。
本章以 Redis 7.x 到 8.x 的常见实现为主。Redis 6.2 及更早版本仍会在一些位置看到 ziplist;Redis 7.0 之后,Hash、ZSet 和 List 内部节点主要使用 listpack,Redis 7.2 又为小型字符串 Set 增加了 listpack 编码。排查时先确认服务端版本,不要把旧文章里的编码名称直接套到新实例上。
INFO memory 里有很多数字,最容易犯的错是挑一个看起来顺眼的当结论。我们先把常用指标按层次拆开。
先采一份快照,可以从下面这些命令开始:
redis-cli INFO memory
redis-cli INFO persistence
redis-cli INFO replication
redis-cli MEMORY STATS
redis-cli MEMORY DOCTORMEMORY STATS 适合程序化读取各类开销。MEMORY DOCTOR 更像体检建议,它只有在实例里有足够数据时才容易给出有意义的诊断,不能替代指标趋势。排查 jemalloc 细节时还可以使用 MEMORY MALLOC-STATS,但它的输出很长,检查成本也可能随分配量增加,不适合高频采集。
STRLEN、HLEN、SCARD 只能告诉你字符串字节数或集合基数,不能告诉你这个 key 在内存里一共花了多少。一个 key 至少还会牵涉数据库字典条目、键字符串、Redis 对象、值对象、分配器对齐;有 TTL 时,还会多出过期索引的开销。具体字节数会随 Redis 版本、编译方式和 allocator 大小类别变化,所以不要背“一个 key 固定几十字节”这种数字。
# 报告 key 与 value 的分配总量
redis-cli MEMORY USAGE cart:{1024}
# 聚合类型默认只采样少量内部元素;0 表示遍历全部内部元素
redis-cli MEMORY USAGE cart:{1024} SAMPLES 0
# 查看实际编码,而不是根据类型猜编码
redis-cli OBJECT ENCODING cart:{1024}对大聚合结构执行 SAMPLES 0 会遍历全部内部元素,精度更高,成本也更高。线上通常先用默认采样筛出候选,再在副本、离线快照或低峰窗口做全量测量。
下面的分析器把 used_memory、allocator 三层指标、RSS、maxmemory 与额外缓冲放在同一张图里。你可以先填入一份真实快照,再试着只改变 allocator_active 或 allocator_resident,观察诊断为什么会不同。
mem_fragmentation_ratio 大致比较进程 RSS 与 Redis 已分配内存,它把分配器碎片、代码段、线程栈和其他非堆开销都混在一起。判断外部碎片时,优先结合 allocator_frag_ratio 与 allocator_frag_bytes;总量只有几 MB 时,即使比例看起来很高,也未必值得处理。
内存优化不是看某个时刻“少了多少”,而是比较同一批数据、同一段流量在改动前后的差异。没有基线,重启后 RSS 下降、缓存刚好过期、流量低谷,都可能被误认成优化效果。
一份可用的基线至少要记录四类数据:
used_memory、used_memory_dataset、used_memory_overhead、RSS、allocator 三层指标、客户端与复制缓冲、历史峰值。current_cow_peak、latest_fork_usec 和复制延迟如何变化。redis-cli --bigkeys 按元素数量或字符串长度寻找每种类型的大 key;--memkeys 按估算内存寻找大 key;较新的 --keystats 会把内存、基数和大小分布合在一起。它们底层使用增量扫描,不会像 KEYS * 那样一次遍历阻塞服务器,但扫描仍会消耗 CPU、网络和命令执行时间。
# 新版 redis-cli:同时看内存、基数与分位分布
redis-cli --keystats -i 0.05 --top 20
# 只按估算内存寻找大 key
redis-cli --memkeys -i 0.05
# 老版本客户端可以先按元素数量排查
redis-cli --bigkeys -i 0.05-i 会在若干批扫描之间让出时间。数据量很大时,最好在从节点或专门的诊断副本上运行,并监控这次扫描本身有没有抬高延迟。扫描结果也只是一个变化中的视图;如果 keyspace 在持续写入,应该保存游标、时间和实例角色,方便复测。
不要只把结果抄成“当前 23 GB”。至少保留采集命令、时间窗、Redis 版本、配置快照、样本数据生成方式和业务流量比例。压测数据还要保留长尾:平均 value 是 200 字节,不代表没有一批 200 KB 的 value;平均 Hash 有 20 个字段,也可能同时存在几个有百万字段的 Hash。
先在改动前连续采样一段完整业务周期,记录内存、延迟、命中率、过期与淘汰数量。只截一分钟,很容易把批任务或流量波峰漏掉。
再用相同的数据生成器和请求比例复制场景。键名长度、TTL、更新频率和批次大小都要接近真实分布,不能只用固定长度的随机字符串。
给每个候选优化定义验收门槛,例如“常态 used_memory_dataset 至少下降 15%,P99 增幅不超过 5%,命中率不得下降,后台保存时峰值仍留有 20% 主机余量”。阈值应由业务目标决定,这里的数字只是写法示例。
Redis 很适合存很多小对象,但“小 value”不等于“小开销”。如果一百万个用户各有 name、city、level、status 四个独立 String key,你实际上付了四遍顶层 key、字典条目和对象元数据。把生命周期和访问方式一致的字段放进一个 Hash,通常可以减少这部分重复开销,小 Hash 还有机会使用紧凑编码。
拆分前:user:8848:name -> 小虎
user:8848:city -> 上海
user:8848:level -> 7
user:8848:status -> active
合并后:user:8848 -> Hash{name: 小虎, city: 上海, level: 7, status: active}这不是无条件合并。合并之后,字段通常共享 key 级 TTL,读取整个对象与只读单字段的网络模式也会变化;大 Hash 还可能失去紧凑编码并形成 big key。较新版本支持 Hash 字段过期,但客户端、运维工具与实例版本是否一致仍要确认。设计时应该按共同生命周期、共同访问模式和可控基数分组,而不是把某个用户的所有东西塞进一个永久增长的 Hash。
键名本身会存入内存,也会进入 AOF、复制流、客户端请求与网络包。把 recommendation:user:8848:recently-viewed-products 改成 rec:u:8848:recent,单个 key 的差值会乘上 key 数量;不过 allocator 按大小类别分配,节省并不总是逐字节线性反映到 RSS。
更稳妥的做法是先计算重复前缀占了多少,再建立团队统一的短前缀字典。像 u:p:8848 这样的名字仍然可以读懂,a:7:x 则可能把节省下来的内存换成排障成本。集群环境还要保留正确的 hash tag,例如 cart:{8848}:items,不能为了缩短名字破坏多 key 操作的槽位约束。
一个 String value 如果能被表示为有符号 64 位整数,Redis 可能使用 int 编码;短字符串常使用 embstr,更长或被修改后的字符串通常使用 raw。这能减少一些对象与分配次数,但不要为了追求 int 把带前导零的订单号、超长账号或本应保持字符串语义的值强制转成数字。
SET stock:8848 120
OBJECT ENCODING stock:8848
SET order:display-id 0008848
OBJECT ENCODING order:display-id0008848 和 8848 对业务未必相同。先守住语义,再谈编码。
Redis 的类型是 API 语义,编码是内部实现。两个都是 Hash 的 key,可能一个是连续存储的 listpack,另一个已经是 hashtable;客户端命令一样,内存和操作成本却不同。

下面这组值是 Redis 7.x/8.x 常见默认配置的起点,实际实例必须用 CONFIG GET 核对:
List 这一行很容易被讲错:现代 Redis 的 List 不是“整个列表小就用 listpack,大了就换链表”。它通常一直表现为 quicklist,quicklist 把多个 listpack 节点串起来。OBJECT ENCODING 对 List 常返回 quicklist,而不是内部节点的 listpack。
CONFIG GET hash-max-listpack-entries
CONFIG GET hash-max-listpack-value
CONFIG GET zset-max-listpack-entries
CONFIG GET zset-max-listpack-value
CONFIG GET set-max-intset-entries
CONFIG GET set-max-listpack-entries
CONFIG GET set-max-listpack-value
CONFIG GET list-max-listpack-size
CONFIG GET list-compress-depthlistpack 把元素连续放置,省掉大量独立节点和指针,但查找某个字段或成员时通常要顺序走过前面的内容,插入和删除还可能移动内存。Hash 切到 hashtable 后,单字段查找通常更接近期望 O(1);ZSet 切到 skiplist 加哈希表后,很多排序相关操作可以利用 O(log N) 的结构。intset 查找可用二分,但向中间插入仍可能移动后续元素。
所以,把 hash-max-listpack-entries 从 512 改成 50000,可能会在测试里看到内存下降,同时把频繁 HGET、HSET 的 CPU 和尾延迟推高;某个超长值触发编码转换时,还可能出现一次明显的转换成本。紧凑结构就是用更多 CPU、扫描与搬移换更少内存,不是免费压缩。
List 的 list-compress-depth 还可以压缩 quicklist 中间节点,同时保留两端若干节点不压缩,方便头尾操作。压缩深度越激进,内存可能越低,访问中间节点时的解压与重新压缩成本越高。默认值通常不启用节点压缩,改之前要把 LINDEX、LRANGE、LSET 等真实访问比例放进压测。
不要在生产实例上直接调大阈值,然后看到 CONFIG SET 返回 OK 就宣布优化完成。配置变化不会瞬间重写整个 keyspace;已有对象可能保持原编码,直到后续操作触发转换或你主动重建。正确做法是在隔离数据集上重放真实写入,分别测内存、CPU、P99 和转换瞬间,再用灰度 key 前缀上线。
当一个 Hash 或 ZSet 持续增长,拆成多个有界分片可以控制单次命令、网络回复、删除释放和持久化写时复制的影响。有些分片还会保持在紧凑编码范围内,但这应该是测量结果,不是分片的唯一理由。
一个稳定的数值 ID 可以按范围分片:
SHARD_SIZE = 256
def profile_shard(user_id: int) -> str:
shard_id = user_id // SHARD_SIZE
return f"profile:shard:{{{shard_id}}}"
def profile_field(user_id: int) -> str:
return str(user_id)这里每个 Hash 最多容纳 256 个用户字段,低于常见的 512 条 Hash listpack 起点。不过能否保持 listpack,还取决于 field 与 value 的最大字节数;一条超过阈值的 JSON 就可能触发转换。value 若包含整份用户档案,更新单个属性还要在应用层解码并重写,CPU、带宽和写时复制都可能增加。
对非连续 ID,可以使用固定分片数的稳定哈希。分片数必须版本化,不能今天按 1024 取模,明天直接改成 2048 后还指望旧数据自动移动。更安全的迁移方式是新旧前缀并存:新写双写,后台渐进回填,读路径先查新结构再回退旧结构,校验完成后切换,最后等回滚窗口结束再删除旧 key。
分片会带来真实代价:
分片大小应该从“单 key 最大允许字节、最大允许基数、单次命令延迟、迁移和删除窗口”反推,再用压测调整。把阈值写死成“每片 512 条”只是碰巧贴着默认编码边界,通常缺少安全余量。
当 ID 是稠密整数,状态又能压成一两个 bit 时,继续为每个用户建立独立 key 会把大部分空间花在元数据上。Bitmap、BITFIELD 与 HyperLogLog 可以把这些开销大幅摊薄,但三者解决的不是同一件事。

Bitmap 不是独立类型,它是一组把 String 当位数组操作的命令。若用户 ID 从 0 连续增长,SETBIT signin:2026-08-13 8848 1 可以精确记录某个用户当天是否签到;一亿个连续位置的理论位数据约为 12 MB,再加 String 和 key 的管理开销。
SETBIT signin:2026-08-13 8848 1
GETBIT signin:2026-08-13 8848
BITCOUNT signin:2026-08-13GETBIT、SETBIT 针对单个位置是 O(1),BITCOUNT、BITOP 等批量操作要扫描字符串,成本和范围长度有关。Bitmap 会自动扩展到访问过的最高偏移;如果第一条写入就是一个极大的稀疏 ID,中间空洞仍要占空间,而且首次扩容可能分配大量连续内存并阻塞。String 上限为 512 MB,对应最多约 个 bit。生产中通常按日期、租户或固定 bit 数分片,让扩容和批量计算保持有界。
如果每个用户不只是“是或否”,而是需要 3 bit 等级、10 bit 计数或几个有符号小整数,可以用 BITFIELD 定义偏移和宽度。每个子操作通常是 O(1),还可以选择溢出时回绕、饱和或失败。
# 在第 0 个无符号 3 bit 槽位写入等级 5
BITFIELD user:levels SET u3 #0 5
# 读取第 0 个槽位
BITFIELD_RO user:levels GET u3 #0代价落到应用设计上:字段宽度、符号、偏移和溢出策略都变成数据协议。以后把等级从 3 bit 扩成 5 bit,不能只改一行代码;你需要迁移旧数据、兼容双读,避免不同客户端按不同布局解释同一串二进制。
如果你只需要估算每天独立访客数,不需要列出成员,也不要求精确到个位,HyperLogLog 通常比保存完整 Set 更合适。Redis 的实现进入稠密表示后最多使用约 12 KB,标准误差约 0.81%;元素很少时还会使用更小的稀疏表示。
PFADD uv:2026-08-13 user:8848 user:9527
PFCOUNT uv:2026-08-13它不能回答“用户 8848 是否来过”,也不能枚举或删除单个成员。PFCOUNT 得到的是估算值,不应直接拿来做精确计费、权益发放或审计。hll-sparse-max-bytes 可以调整稀疏表示转稠密表示的边界,但稀疏更新会用更多 CPU;把阈值调得很高并不一定划算。
SETRANGE、GETRANGE 可以让一个 String 像固定宽度数组一样存每个用户的省份码、状态码或计数。它减少 key 数量,却把 schema、并发更新和迁移责任交给应用。ID 稀疏时仍会产生空洞,读取少量离散用户时还可能放大网络读取。和 Bitmap 一样,最好给每段设定上限,并保存清楚的版本号与编码说明。
big key 不只代表“占内存多”。一个有几百万成员的 Set 可能让 SMEMBERS 长时间占用主线程,回复在网络和客户端缓冲区里继续放大;复制时同样的大回复或写命令会进入复制链路;后台 RDB 或 AOF 重写期间,大对象被频繁修改还会弄脏更多内存页,抬高写时复制峰值。

工程上最好同时限制:
MEMORY USAGE 不能超过多少;一个 50 MB String 和一个拥有 300 万个短字段的 Hash,都可能危险,但危险命令不同。前者的大 GET、SET、复制与网络突发更明显;后者的 HGETALL、全量删除、扫描和重编码更值得担心。
LTRIM,Stream 使用有界 XTRIM,按时间窗口轮转统计 key,避免永久追加。HSCAN、SSCAN、ZSCAN 或分页范围读取,不把全部元素一次塞进回复。UNLINK:把大对象的实际释放交给后台线程,减少主线程释放停顿,并监控 lazyfree_pending_objects。UNLINK 只解决释放阶段的主线程阻塞,不能让这个 key 之前的 HGETALL、复制、备份或网络传输变快。大量 UNLINK 还会形成后台释放队列,内存不会在命令返回的一瞬间全部归还。
不要用 KEYS * 配合逐个 MEMORY USAGE ... SAMPLES 0 在生产主节点做“大键巡检”。前者会阻塞遍历 keyspace,后者可能全量遍历聚合值。使用增量扫描、限速和副本,把精确测量留给少量候选。
TTL 解决的是生命周期:到期数据应该离开。淘汰策略解决的是内存达到 maxmemory 后,Redis 为了继续写入应该删谁或拒绝什么。把两者混在一起,会出现两种常见事故:事实数据被当缓存淘汰,或者全是缓存的数据因为选择 noeviction 而突然写失败。
Redis 会在访问 key 时做惰性过期,也会在后台主动采样过期 key。大量 key 同时到期,可能形成过期和回源波峰;从 TTL 变成 0 到对象真正被删除之间也可能存在短暂延迟。批量写缓存时应给 TTL 加符合业务范围的随机抖动,而不是让整个小时的数据在整点同时失效。
TTL 自己也占内存,因为需要维护过期时间。若实例完全是缓存,而且希望在内存满时从所有 key 中按访问模式选择淘汰,allkeys-lru 或 allkeys-lfu 不要求每个 key 仅为了参与淘汰而额外设置 TTL;但业务生命周期仍然需要 TTL 时,不能为了省这点开销删掉过期语义。
Redis 的 LRU、LFU 会从样本中挑候选,而不是维护一份精确全局排序。提高 maxmemory-samples 可能让近似更接近理想选择,也会增加淘汰时 CPU。选择策略后,要同时观察 keyspace_hits、keyspace_misses、evicted_keys、expired_keys 和写入拒绝,不能只看内存是否贴住上限。
maxmemory 不能等于机器可用内存复制和 AOF 的部分缓冲不会计入淘汰判定,INFO memory 用 mem_not_counted_for_evict 暴露这部分当前值。进程还需要 allocator 碎片、客户端缓冲、复制 backlog、Lua/Functions、线程栈以及后台任务的写时复制空间。把 32 GB 机器上的 maxmemory 直接设成 32 GB,等于把所有峰值都押给操作系统 OOM 处理。
一次产生巨大结果的新命令,也可能在淘汰完成前短暂越过限制。因此,容量规划应该使用真实命令与峰值,而不是把 maxmemory 当成绝对不会超过的硬墙。
删除 key 后,Redis 会把内存还给 allocator;allocator 常常先保留这些小块,等待后续分配复用。只有一整页满足释放条件时,内存才更容易回到操作系统。因此 used_memory 可以先下降,RSS 暂时保持在历史高位。这不是 Redis 还藏着已经删除的业务数据,而是释放层次不同。

常用的分配器关系可以这样理解:
第一组更接近活跃页内部的外部碎片,第二组更接近 allocator 已经不用、但仍常驻且可能归还给操作系统的页。两个比例都要和对应字节差一起看:1 MB 分配上多出 1 MB,比例是 2;50 GB 分配上多出 1 GB,比例只有 1.02,但后者的绝对成本更高。
allocator_frag_bytes、allocator_rss_bytes 和趋势,排除代码、栈、客户端输出缓冲、子进程 COW 等其他来源。MEMORY PURGE,请求 allocator 尝试归还脏页;这个命令可能较慢,也不保证所有空洞都能归还。activedefrag yes。主动整理会复制对象到更连续的位置并释放旧块,它会占用 CPU,不应该因为比例偶尔抖高就常开。active_defrag_running、主动整理命中、CPU 和 P99;收益不够或延迟越界就回滚配置。主动整理依赖 Redis 使用兼容的 jemalloc 构建。常见配置默认不开启,阈值与 CPU 努力程度也都有单独选项。最稳妥的方式是保留原始配置,先用 CONFIG SET 灰度,确认效果后再决定是否 CONFIG REWRITE 持久化;否则重启后配置可能回到文件里的旧值。
如果 used_memory 明显大于 RSS,要警惕一部分 Redis 内存已经被换出到 swap。此时问题不是“内存利用率很高”,而是访问这些页面可能触发磁盘换入并制造长尾延迟。应先处理主机内存压力与容量,而不是用主动碎片整理掩盖它。
常态放得下,不代表 BGSAVE、BGREWRITEAOF 或全量同步时也放得下。Redis 创建后台子进程后,父子最初共享内存页;父进程继续写入时,操作系统通过 Copy-on-Write 为被修改的页复制新副本。额外内存取决于后台任务持续时间、写入速率、被改动对象在页面上的分布,而不是一个固定百分比。
写密集场景里,COW 额外内存可能接近原数据集,使进程峰值接近常态的两倍;读多写少时则可能低得多。把“最多两倍”当容量上界提醒是可以的,把它当每个实例都会发生的固定公式则不准确。真实校准应观察 INFO persistence 里的 current_cow_peak、current_cow_size、后台任务状态与 fork 延迟。
可以先用一个保守关系做纸面检查:
这个式子不是配置生成器。maxmemory 还要从可用内存里扣掉不参与淘汰的缓冲、碎片与后台任务余量,再通过压测验证。
redis-benchmark 适合快速检查环境和建立粗略对照,但默认固定大小、固定命令的请求不能代表你的线上数据。内存优化压测至少要还原:
压测过程中记录 used_memory_peak、RSS 峰值、COW 峰值、CPU、网络、P99、命中率、过期与淘汰计数。内存下降 30%,但 CPU 翻倍、P99 超时或命中率下降,不能算通过。
真正稳妥的优化通常不是一次改五个参数,而是每次只验证一个假设。先删重复数据,再调整数据模型;先控制 big key,再评估紧凑阈值;能用精确的 Bitmap 才用 Bitmap,只需要基数估算才换 HyperLogLog。风险越高、数据语义变化越大,越应该使用新 key 前缀而不是原地覆盖。

保存原始配置、基线快照和回滚开关。若要改变 key schema,创建 v2 前缀,不覆盖 v1 数据。
在影子流量或少量租户上双写新旧结构,抽样比较值、数量、TTL 和业务结果。迁移期间要限制回填速度,避免后台扫描自己变成延迟来源。
用相同数据分布跑常态与峰值压测,同时触发 RDB/AOF 后台任务。比较内存、CPU、P99、命中率、COW 峰值和复制延迟,不只比较单 key 的 MEMORY USAGE。
达到验收门槛后逐级扩大读流量;任何指标越界,立刻把读路由切回旧结构或恢复旧配置。旧数据保留到完整回滚窗口结束。
本章真正要带走的不是某个万能阈值,而是一条判断顺序:先确认增长发生在哪一层,再用版本与实际配置解释编码;每次只改一个假设,用真实数据压测常态和峰值;内存、延迟、命中率和复制持久化都通过才推广,否则按预先保留的旧结构或旧配置回滚。
推广完成后再渐进 UNLINK 旧 key,监控 lazyfree_pending_objects、RSS 与 allocator 复用情况。最后把新的配置、容量模型和数据协议写入运维文档。