Redis内存压缩实战:从15G降到6G的编码、序列化与碎片治理
发布时间:2026/10/5 2:55:40 作者:尧图编辑部 阅读量:1,286

Redis 内存压缩实战去年下半年我接手了一个让人头疼的线上项目服务本身平平无奇Redis 却成了最大的成本黑洞。刚开始部署时Redis 实例只占了 2G 内存结果上线三个月后内存一路飙到 15G告警邮件几乎天天躺在我邮箱里。查了一圈发现数据量确实涨了但涨得没这么夸张——真正的问题是内存根本没被有效利用大量空间浪费在底层数据结构、序列化冗余和不合理的编码方式上。后来我花了差不多两周时间把编码结构、序列化方案、持久化策略和内存碎片挨个梳理了一遍最终在数据量没减的前提下把内存压回了 6G 左右。这篇文章就是那次实战的完整记录内容包括 Redis 内存到底花在哪里、数据编码如何选型、序列化怎么改造、持久化要不要关、内存碎片怎么清以及压缩过程中我踩过的坑。适合正在被 Redis 内存告警逼疯的运维和开发同学也适合想系统认识 Redis 内存模型的进阶使用者。1. 内存告警那一刻Redis 从 2G 涨到 15G 的真实场景1.1 先别急着加内存先把账算清楚我们的业务是一个典型的读多写少场景缓存内容主要是用户画像、商品信息、推荐结果列表Key 数量大概在 3000 万到 5000 万之间。最开始我以为是容量估算失误于是直接把内存扩到了 16G。结果只撑了一个月16G 又快满了。这时候我才意识到问题不是不够用而是用得不对。我先在凌晨低峰期对线上实例做了一次全面体检核心动作有三个用redis-cli --bigkeys扫了一遍全量 Key看看有没有超大 Key 在占用空间用DEBUG OBJECT或OBJECT ENCODING查看不同类型 Key 实际使用的底层编码用INFO memory查看内存分配的细节指标。--bigkeys扫出来的结果触目惊心有十几个 Hash 的字段数超过了 50 万单个 Key 的 Value 大小在 300MB 以上。这些大 Key 不仅占内存还会在扩容、持久化、主从同步时引发严重的阻塞属于必须优先处理的问题。1.2 INFO memory 里那几行英文才是重点很多人看INFO memory只看used_memory其实更有用的是下面这几项指标含义我当时的数值说明used_memoryRedis 分配器分配的总字节数15.2G包含数据、开销、碎片used_memory_rss进程实际占用的物理内存15.8GRSS 比 used_memory 大说明有碎片used_memory_dataset数据集大小10.1G纯粹的数据部分mem_fragmentation_ratioused_memory_rss / used_memory1.04超过 1.5 才需要关注碎片used_memory_overhead数据之外的开销5.1G包含 Key、指针、过期信息等这个账本让我第一次清晰地看到有接近三分之一的内存是开销而不是数据本身。数据集 10.1G但额外开销高达 5.1G。开销来自哪里主要是 Key 本身、Redis 全局哈希表的指针、过期时间戳、底层链表节点的 prev/next 指针等等。这也是压缩的第一个方向能不能把开销降下来1.3 定位大 Key 的完整命令组合除了--bigkeys我还用了几个比较细致的定位命令这里一并分享# 扫描全库大 Key注意会在线上产生一定开销建议低峰期执行 redis-cli -h 127.0.0.1 -p 6379 --bigkeys # 查看某个 Key 的编码方式 redis-cli -h 127.0.0.1 -p 6379 OBJECT ENCODING user:profile:123 # 查看某个 Key 占用内存的估算值 redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE user:profile:123MEMORY USAGE是一个很容易被忽略的命令它能估算出单个 Key 加 Value 在 Redis 内部的真实占用。我拿它验证过一个看似只有 1KB 的 JSON 字符串在 Redis 里实际占用可能接近 2.5KB——里面包含了 SDK 层、redisObject 结构体、SDSSimple Dynamic String头部的额外分配。了解这一步后面做序列化改造时你才会有明确的收益预期。2. Redis 内存都花在哪里先看懂内存账本再谈压缩2.1 Redis 的每一条数据比你想象中更重在动任何优化之前必须建立一个认知Redis 存一条数据除了 Value 本身还包含许多你看不见的额外结构。每个 Key 和 Value 都被包装在一个redisObject里这个结构体包含类型、编码、引用计数、LRU 时钟等字段哪怕只存一个数字也要付出结构体的固定开销Key 是 SDS 类型SDS 头部会记录长度、已用空间、分配空间等元信息Redis 有一个全局哈希表哈希表的每个桶里放着指向 dictEntry 的指针dictEntry 又包含 pre、next 指针用于链地址法解决哈希冲突如果设置了过期时间每个 Key 还会额外挂到过期字典上各类数据结构内部还有指针开销比如链表每个节点至少有两个指针。这些开销加起来在 Key 数量很大的时候非常可观。5.1G 的开销就是这么一点一点攒出来的。2.2 五种数据类型的内存模型差异先通过一张表看清不同数据类型的底层内存机制数据类型底层编码旧版底层编码新版内存特点Stringint / embstr / rawint / embstr / raw最简单数据多长就占多长但 Key 开销逃不掉Hashziplist / hashtablelistpack / hashtable小字段可以用紧凑列表节省指针开销Listziplist / linkedlist / quicklistquicklist新版统一用 quicklist节点内存有折中Setintset / hashtableintset / hashtable纯整数集合用 intset 非常省ZSetziplist / skiplistlistpack / skiplist分数成员组合ziplist 比例控制要结合读写比新旧版本编码方式有所不同Redis 3.2 之前的 List 有 ziplist 和 linkedlist3.2 之后改为 quicklistRedis 7.0 之后 ziplist 逐步被 listpack 取代。不过核心思想一致——元素少、值小的时候用连续紧凑的内存布局代替分散的节点结构。2.3 一条 String 到底能藏多少水分我举个实际例子。假设你需要缓存一个用户信息 JSON内容是{id:123456,name:zhangsan,age:30}大概 42 字节。存进 Redis 后它占用多少实际接近 100 字节。多出来的部分包括redisObject 结构体约 16 字节SDS 头部根据长度不同约 21 字节全局哈希表的 dictEntry约 24 字节内存分配器的对齐填充jemalloc 按 8 字节、16 字节、32 字节档位对齐。如果同样的 Key 存 3000 万条光是 Key 本身的字符内容平均假设 15 字节就接近 450MB加上哈希表指针和 dictEntry开销能到 GB 级别。所以压缩的第一步永远不是换算法而是搞清楚占内存的是 Value 的 10G还是整个实例的所有隐藏叠加。3. 第一刀数据编码优化让 Redis 自己瘦身3.1 编码方式不选对内存写多少撒多少Redis 最强的内存压缩能力其实藏在编码层。_它有一套自动降级机制当数据规模较小、元素较小时会用连续内存布局的 compact 编码当数据规模超过阈值时才转为普通哈希表/跳表结构。_但默认阈值比较保守很多场景下我们完全可以多压一压。先看一段配置这是 Redis 6.0 以后的默认行为# Hash 字段数 128 且字段值长度 64 字节时使用 listpack/ziplist hash-max-listpack-entries 128 hash-max-listpack-value 64 # Set 整数元素数量 512 时使用 intset set-max-intset-entries 512 # ZSet 元素数 128 且成员长度 64 字节时使用 listpack/ziplist zset-max-listpack-entries 128 zset-max-listpack-value 64 # List 每个 quicklist 节点内压缩列表的最大大小8KB或元素数-2 表示按大小 list-max-listpack-size -23.2 Hash 的保存姿势字段少时用压缩列表别急着上哈希表我们线上有一类运营配置数据每个 Key 存了 20 到 200 个字段字段值都是短字符串。最初写入时用的是 Hash看起来没毛病但实际编码已经变成了 hashtable——为什么因为默认的hash-max-listpack-entries只在 128 以内有效部分超大配置项超过了这个阈值。这里有个反向优化空间既然底层编码在超过阈值后会变成 hashtable那我们可以通过控制字段数量或者拆分 Key 来保持压缩编码。我们实际做的改造把单个 Hash 拆成多个子 Hash每个子 Hash 字段数控制在 100 以内并配合调大hash-max-listpack-entries到 256。改造后这些配置项的内存开销降低了大约 45%。有人可能会问字段数控制住了但字段值很长怎么办答案是控制hash-max-listpack-value把超过 64 字节的大字段拆出去单独存成 String。比如一个用户备注字段原来直接放在 Hash 里如果达到 100 字节整个 Hash 就可能会退化编码。拆出去之后大字符串只占一次 SDS 空间而 Hash 内部继续保持紧凑的 listpack 结构。3.3 Set 和 ZSet 的入门优化Set 在成员全部是整数时会用 intset 编码这是一个紧凑整数数组没有指针、没有节点头内存效率极高。我们的场景里恰好有一个已读用户集合成员是用户 ID全是整数。原本由于超过 512 个已经退化成 hashtable导致一个 10 万成员的 Set 占用接近 8MB。后来我们分片处理每片控制在 500 个以内加上调大了set-max-intset-entries到 1024单个分片依然保持 intset 编码整体内存下降了近 70%。ZSet 类似。如果成员数量少、分数字段小保持 listpack/ziplist 编码会省很多但只要超过阈值就会退化成哈希表 跳表跳表每个节点都有多层指针数组内存开销非常大。线上如果确实需要存储大 ZSet建议在业务层面做分片避免单 Key 元素超过几千个。3.4 List 与 quicklist 的现实权衡新版 Redis 的 List 使用 quicklist本质是一个双向链表 每个节点内嵌一个压缩列表listpack。list-max-listpack-size -2表示每个节点最大 8KB。这里的策略是节点太大内存紧但更新开销高节点太小内存分散指针开销高。我们线上有个消息队列场景每天凌晨批量写入百万级消息之后逐条消费。List 的quicklist表现中规中矩。我做过对比测试_把 list-max-listpack-size 从 -28KB调到 -316KB这个场景下内存降低了大约 12%操作延迟基本无感。_但换到其他频繁插入的场景这个调整就会因为节点过大反复重分配而增加 CPU。所以这个参数没有绝对的最优值必须结合你真实的读写模式去压测。3.5 一个完整的编码改造案例我举一个线上的实际改造过程。某个功能缓存了商品标签每个商品有 30~300 个标签标签内容短则 3 字节、长则 80 字节。原先实现是直接用 String 存整个 JSON一个商品一个 Key5s 过期。第一步我把 JSON 拆成了 Hash 字段product:tag:{id}字段名是标签分组名字段值是短字符串拼接。第二步通过控制分组字段在 100 个以内让底层编码尽量停留在 listpackhash-max-listpack-entries 256 hash-max-listpack-value 128第三步用OBJECT ENCODING验证redis-cli OBJECT ENCODING product:tag:123456 # listpack改造之后这类数据的内存从原来的 2.1G 降到了 800MB。字段多了 2 个左右的 Key但对整体性能几乎没有影响。核心经验就一句话能用紧凑编码就不要让数据退化到通用结构每个 Key 的字段数、成员数、单个字符串长度都要在写入前心里有数。4. 第二刀序列化改造压掉最容易忽视的水分4.1 默认 JDK 序列化是内存杀手如果你的服务是 Java 技术栈并且用 Spring Data Redis那么你大概率踩过一个坑不指定序列化器时默认用的是JDK 序列化。JDK 序列化出的字节流里包含了完整的类描述信息、继承体系、字段元数据一个很简单的对象也能序列化出 500 字节甚至几 KB 的内容。我们在排查时发现很多缓存 Value 的字节长度居然比业务 JSON 长了 3 倍还多这就是默认 JDK 序列化捣的鬼。这种序列化不仅是内存浪费而且跨语言解析困难主从切换和故障恢复时还可能因为类结构不一致而反序列化失败。4.2 几种主流序列化方案的实测对比我整理了我们在相同业务对象上的实测对比数据来自 10 万个相同对象的序列化结果序列化方式平均大小序列化耗时相对反序列化耗时相对跨语言JDK 原生820 字节1.01.0不友好JSONJackson320 字节0.40.5是FastJSON2310 字节0.30.4是MessagePack190 字节0.50.6是Kryo150 字节0.80.7需要注册ProtoStuff140 字节0.70.6需生成 schema在内存压缩的目标下Kryo 和 ProtoStuff 优势明显体积能比 JSON 再小一半左右。但它们的代价是要提前注册类或者生成 schema。考虑到我们团队没有跨语言需求且引入新依赖的成本可接受最终选择了 Kryo将普通业务对象的缓存体积从 320 字节压到了 150 字节左右。这里建议如果只是几天内能上线的快速优化先把 JSON 序列化安排上如果追求极致内存收益再考虑 Kryo。关键是别像我之前那样放任 JDK 序列化继续吃内存。4.3 在序列化之上叠加压缩算法序列化压缩的下一步是叠加通用压缩算法。我测试过三种LZ4、Snappy、Zstd。结论是小对象 1KB上不要用 GzipCPU 开销大且压缩率不如 LZ4 明显大对象 10KB上用 Zstd 收益最大。我做了两组对比实验压缩算法1KB 对象压缩后压缩耗时100KB 对象压缩后压缩耗时不压缩1KB-100KB-LZ4800 字节1ms 以内30KB3msSnappy820 字节1ms 以内31KB3msZstd(-3)750 字节1.5ms18KB8msGzip(-6)720 字节5ms15KB25ms对于用户画像这种 20KB 以上的大 Value我们采用了 Zstd 压缩压缩比能达到 4:1 到 5:1。一开始我也担心 CPU但实测发现单条 20KB 数据的 Zstd 压缩耗时约 1.8ms在 Redis 客户端层面完全可接受。不过要注意压缩一定要在客户端完成不要把压缩命令放到 Redis 里用 Lua 做否则会拖垮 Redis 单线程的 CPU。这一轮改造后包含用户画像、商品描述等大 Value 的缓存整体内存下降了约 55%。4.4 不要让 RDB 和 AOF 里的压缩和业务压缩重复叠加做序列化压缩时有一个特别容易忽略的问题RDB 持久化本身也支持压缩如果你的 Value 在业务层已经压缩过了RDB 再压一遍基本压不动却仍要白白付出 CPU。在配套调整时我把rdbcompression保持为yes但对于已经做过业务压缩的 KeyRedis 会天然地在 RDB 里发现压无可压实际 CPU 开销并不会成倍增加。真正要小心的是主从复制场景主库在生成 RDB 传送到从库时会做压缩如果客户端侧已经大幅压缩过数据RDB 传送时 CPU 负荷不大不用过度担心。5. 第三刀RDB 与 AOF 持久化的瘦身思路5.1 Redis 的持久化文件也能反向影响内存提到内存压缩很多人的第一反应是只关注内存中的数据但我发现持久化策略对内存也有直接影响主要体现在两个方面RDB 文件生成时需要fork()子进程fork之后父子进程会共享内存为了 COW写入时复制机制操作系统会在内存层面维护页表。如果实例内存越大fork 的耗时越长瞬间卡顿风险越高AOF rewrite 时要重写全部数据到临时文件如果 Redis 里全是超大 Key 和海量小 Key重写时瞬时内存和 CPU 会飙升。所以持久化瘦身不只是磁盘问题本质上也是内存问题。5.2 RDB 压缩和 AOF 重写参数调整先检查你的 redis.conf 里的持久化配置# RDB 文件是否压缩建议保持 yes rdbcompression yes # 关闭 RDB 校验和可以略微降低 CPU但不建议 rdbchecksum no # AOF 自动重写阈值当 AOF 文件大小是上次重写时的 100% 时触发 auto-aof-rewrite-percentage 100 # AOF 文件最小重写大小 auto-aof-rewrite-min-size 64mb # 如果开启了混合持久化RDB 头部 AOF 增量尾部重写效率会更高 aof-use-rdb-preamble yes我们线上的调整把auto-aof-rewrite-percentage从默认的 100% 调到 200%降低 AOF 重写频率避免频繁重写带来的瞬时内存抖动。配合错峰重写我把AOF rewrite手动调度到了业务低峰效果明显——之前每周会有两三次慢日志与重写时间重叠调整后几乎消失。5.3 是否关闭持久化网上有不少声音说Redis 只做缓存关了持久化最省内存。从纯内存角度看确实如此因为关掉 RDB 后Redis 不需要 fork 时预留内存页表开销关掉 AOF 后也不需要为 rewrite 临时腾出额外内存。但持久化承担着故障恢复的职责尤其在主从架构中从库如果崩了要从主库全量同步RDB 机制不可或缺。所以我的建议是纯缓存场景可接受丢失全部缓存关闭 AOFRDB 保留但调低触发频率有从库且能接受从库重新全量同步主库关闭 RDB 也没太大影响但一般不建议因为如果所有实例同时崩溃缓存就全空了混合持久化aof-use-rdb-preamble yes在 Redis 4.0 是相对均衡的选择。我们最终保留了 RDB 混合持久化把 AOF 重写频率降低既保证了恢复能力也没有让持久化成为内存压力的根源。6. 内存碎片与被逐出策略压缩之外的第二战场6.1 内存碎片比你想象中更能藏空间数据压缩做得差不多了内存碎片问题就浮出水面。Redis 使用 jemalloc 作为内存分配器频繁的写入、过期、淘汰会导致内存空间碎片化。mem_fragmentation_ratio超过 1.5 时就需要处理。我遇到的情况是经过大 Key 拆分和过期清理后used_memory从 10G 降到 5G但used_memory_rss还在 8G。整整 3G 被碎片吃掉了。处理手段有两个开启自动碎片清理activedefrag yes active-defrag-threshold-lower 10 active-defrag-threshold-upper 100 active-defrag-ignore-bytes 100mb active-defrag-cycle-min 5 active-defrag-cycle-max 75手动重启主从切换让内存重新分配。自动清理的逻辑是当碎片率超过threshold-lower时开始尝试移动内存超过threshold-upper时全力清理但要控制 CPU 占用在cycle-min和cycle-max之间。开启后我用了一周时间RSS 降了 2G 左右期间线上没有出现明显延迟。6.2 maxmemory-policy 选择背后的内存逻辑别忘了为实例设置maxmemory和maxmemory-policy。即使内存压缩做得再好也要防止突发流量瞬间打满内存导致 Redis 直接 OOM。合理的策略策略适用场景内存影响allkeys-lru缓存热点数据会淘汰不常用的 Key减少整体内存压力volatile-lru只淘汰设置过过期时间的 Key不设置过期时间的核心 Key 不会被误杀allkeys-random所有 Key 随机淘汰适合命中率不重要的冷数据缓存volatile-ttl优先淘汰剩余 TTL 最短的 Key适合数据有明确时效性的系统noeviction不淘汰写满直接报错不适合缓存场景我们选择的是allkeys-lru这样不论缓存容量如何变化Redis 总会在内存接近上限时自动挤出非热点数据不至于直接拒绝服务。但这里有个容易踩的坑如果很多 Key 只写一次就不再访问LRU 会逐渐把它们全部淘汰掉对写了就要长期存在的配置类数据不友好需要给这类 Key 加访问或改用 volatile-lru。6.3 用监控把内存问题挡在门外压缩和整理都是事后补救更好的做法是提前监控。我给 Redis 加了三层监控INFO memory每 30 秒采集一次记录used_memory、used_memory_rss、mem_fragmentation_ratio每天凌晨跑一次--bigkeys把大 Key 列表写入日志对used_memory超过 maxmemory 的 70% 和 90% 分别设置 warning 和 critical 告警。数据说明问题上线监控后我们成功提前发现过两次因版本发布引入的缓存雪崩式增长都在内存被打满前做了干预。7. 复盘压缩效果、CPU 代价与踩坑记录7.1 压缩前后的数据对比这一轮内存压缩做完我把效果做了一个统计表覆盖了线上两个核心实例主从架构的写主读从和 Redis Cluster 环境指标优化前优化后变化实例总内存15.2G5.8G下降 62%used_memory_dataset10.1G4.2G下降 58%used_memory_overhead5.1G1.6G下降 68%Key 平均大小480 字节130 字节下降 73%INFO 命令平均耗时0.8ms0.6ms基本持平P99 延迟3.2ms3.5ms略微上升可以看到内存压缩不是拿性能换内存——延迟几乎没有明显劣化唯一有感知的是在 Kryo 序列化叠加 Zstd 压缩的极少数超大 Value 场景下P99 略有上升但仍然远低于业务容忍阈值。7.2 CPU 代价到底有多大这是大家最关心的问题。我做了 AB 对比同一批数据在同样 QPS 情况下压缩优化前后的 CPU 使用率上升了大约 15%。这 15% 主要来自序列化和压缩客户端侧以及 Redis 端列表编码的额外计算。但因为 Redis 侧的 CPU 基准本身很低高配机器上不到 20%所以最终服务 CPU 仍然非常健康。如果你的服务 CPU 已经常年 60% 以上再做 Kryo Zstd 这类压缩方案就要格外谨慎建议先做压测确认新增 CPU 不拖垮核心请求路径再上线。7.3 踩过的坑逐个列给你坑一原生字符串序列化混用。我们改造时先对一部分 Key 用了 Kryo另一部分还是 JSON。结果反序列化时因为网关配置错误新老格式混读直接触发了一波数据解析异常。教训是序列化方案切换时必须做双读双写灰度先保证新格式写入、旧格式可读确认稳定后再下线旧逻辑。坑二bigkey 拆分后 Key 数量爆炸。拆分大 Hash 后Key 数量从 3000 万涨到了 8000 万。虽然每个 Key 都小了但全局哈希表本身也占内存甚至如果 Key 名太长内存不降反升。优化时要注意拆分后单个 Key 的 Redis Key 名字别太长必要的话用短编码代替。坑三listpack/ziplist 参数调整后大字段导致 CPU 飙升。我们把hash-max-listpack-value从 64 调到 128结果字段值超过 100 字节的 Hash 在做更新时每次插入都需要移动内存CPU 和延迟都有上升。这个参数一定要谨慎调不是越大越好要配合实际字段长度分布来定。坑四内存碎片清理引发主从延迟。开启activedefrag后主库碎片清理占用的 CPU 偶尔会让主从复制出现几十毫秒的延迟。解决办法是把active-defrag-cycle-max从 75 降到 50并把大对象清理的时间窗口错开业务高峰。7.4 现在的我会怎么规划这套方案经历过这次实战我个人的习惯已经固定成了下面这套四步方法论也可以作为你接手一个陌生 Redis 实例的排查清单先体检INFO memory--bigkeysMEMORY USAGE搞清楚内存花在哪里后调结构根据数据类型分布调整编码阈值、拆分大 Key、优化 Hash/Set/ZSet 的底层结构再压序列化确定序列化方案和压缩算法先在压测环境验证大小和 CPU 收益最后管碎片与容量开启自动碎片整理设定 maxmemory-policy挂上监控和告警。整个过程没有一步是玄学每一项都能用命令和数据验证。Redis 本身是一个内存敏感型的组件它对内存的使用效率直接决定了你的成本上限。通过编码优化、序列化压缩、持久化策略调整、碎片清理这四板斧绝大多数实例都能在不减数据的前提下压缩掉 50% 以上的内存。如果你的 Redis 也在膨胀建议立刻打开INFO memory对照着这个思路走一遍效果不会差。