
聊Redis的数据结构大多数人的认知停在命令层String存缓存Hash存对象ZSet做排行榜用起来都挺顺手。但我要说一句可能不太好听的话如果只停留在“知道怎么用”这一层做架构选型就是在掷骰子。真正逼我把底层原理啃透的是一次线上事故——同样是一亿条用户画像数据方案A用Hash按用户维度存储方案B图省事把JSON序列化后整体塞进String结果方案B的内存占用比方案A多了将近一半。扩容账单送到老板桌上那一刻我的CPU和内心同时飙到100%。这篇文章顺着这个思路把Redis五种核心数据结构的底层编码、内存成本、选型逻辑一条条掰开算清楚。不管你是正在做技术选型的架构师还是单纯想搞明白“为什么这个结构省内存”的开发者或者面试时想把底层原理讲得比别人深一层都值得看完。Redis的地板是数据结构天花板是成本这两件事从来都是同一件事。1. Redis核心数据结构底层实现五种类型到底怎么存1.1 RedisObject每个value都套着的一层“包装盒”先说一个容易被忽略的事实你在Redis里存的任何一个值哪怕就是字符串“hello”它在内存里不是单独存在的。Redis内部对每个值都包了一层RedisObject这层结构体自带type类型、encoding编码方式、lru最近访问时间、refcount引用计数和ptr指针在64位系统上光这层壳就要占16字节。这16字节就是快递包装盒。你买的东西哪怕只有一元钱快递盒、气泡膜、面单一样不少。数据也一样value本身越小包装盒占总成本的比重就越高。这也是为什么小字符串在Redis里的性价比极低——你把一个只有5字节的内容塞进Redis实际占的内存可能超过40字节大头全在包装盒上。理解RedisObject的意义在于当你做技术选型时第一步不是问“哪种数据结构方便”而是问“每个key和value的包装成本能不能摊薄”。对象越大摊薄效果越好对象越小包装成本越致命。这就是为什么后面我们会看到Hash这种“一个key装多个字段”的结构在小批量数据场景下特别省钱本质是把一个key的包装成本摊到了多个业务字段上。1.2 String不是简单的字符串SDS与int/embstr/raw三种编码String看似简单底层却有三种编码形态。如果value存的是整数Redis会用int编码直接存8字节的值不做字符串转换如果字符串长度小于等于44字节用embstr编码让RedisObject和字符串内存连续分配一次分配搞定超过44字节才用raw编码分两次分配内存。44字节这个数字不是拍脑袋定的64字节Jemalloc常见分配单元减去16字节的RedisObject减去SDS头部的3字节len、alloc、flags再减去末尾的结束符1字节正好44。也就是说Redis想尽办法让短字符串在小内存池里一次分配、不产生碎片。超过44字节Redis被迫走两次分配内存碎片和分配开销同步上升。SDSSimple Dynamic String是Redis自定义的动态字符串和C语言裸字符串不一样它记录了长度所以获取字符串长度的复杂度是O(1)而且二进制安全。这些细节平时命令操作感受不到但关系到你对内存碎片和分配效率的判断。比如你批量写入几千万个小字符串理解embstr的分配逻辑你就能解释为什么内存碎片率会在写入后明显上涨。1.3 Hash、List、Set、ZSet各自藏在底下的编码逻辑Hash、List、Set、ZSet这四种集合类型每一种都有“紧凑型”和“散列型”两套底层实现。骨架没变只是肉不一样。Hash默认用ziplist压缩列表编码元素少且单个元素长度小时所有字段挤在一段连续内存里当哈希的元素数量超过512个或某个字段的key/value长度超过64字节就转为hashtable每个字段变成独立的dictEntry散列存储。Redis 7.0之后ziplist在大部分场景被listpack替代listpack解决了ziplist的级联更新问题但紧凑存储的思想一脉相承。List用的是quicklist本质是“双向链表多个ziplist/listpack节点”的组合默认每个节点最多8KB。这是一个非常工程化的设计纯链表每个节点要维护一堆指针内存利用率低纯数组插入删除要搬移数据性能差quicklist用链表串起一段段紧凑数组兼具两者的优点。Set如果全是整数且数量不超过512用intset整数集合紧凑存储否则变成hashtablevalue存空指针。ZSet在元素不超过128且member长度不超过64字节时用ziplist否则改用dictskiplist的组合dict负责O(1)查分skiplist负责有序范围查询。这些编码转换阈值都在redis.conf里可以调整套默认值对应的是“大部分业务场景”的经验值。理解这层逻辑最大的价值在于你可以预判“我的数据大概在什么量级会触发转换”从而提前做容量规划和成本估算而不是等内存告警了再去查为什么一夜之间内存涨了30%。2. 数据结构编码与内存成本量化分析Redis的内存账单2.1 结构性开销到底占了多少现在我们把账算细一点。一个String类型的key真实内存开销至少有这几块dictEntry占24字节key指针和value指针都挂在这里key本身的SDS包括头部和字符串内容RedisObject占16字节value的SDS头部加内容。我实测过一个长度为10字节的key、50字节的valueMEMORY USAGE显示大约占用103字节。你自己拆开算dictEntry的24字节key的SDS、value的RedisObject 16字节、value的SDS加上键值SDS各自的头部差不多就是这个量级。也就是说你存1000万条这样的数据光“结构性开销”就要好几GB。用生活话讲你看到的业务数据只有60字节但Redis为它找地方住、登记、挂锁额外花了40多字节。这部分比例在小value时占大头很多人做容量规划时只算业务数据体积不算包装开销结果上线第二周就发现内存水位远超预期。还有一点容易被忽略dictEntry里存的是指针指针指向真实的key和value对象。哈希表本身还有负载因子的概念Redis的哈希表在扩容时会有短暂的“双表”状态瞬间内存开销接近翻倍。这也是为什么批量导入大量String key时内存会出现一个明显的“台阶”增长。2.2 ziplist为什么在小数据场景里血赚Hash在小场景下的省钱逻辑就在ziplist。ziplist把所有字段和值按顺序排列在一块连续内存中省去了每个字段的dictEntry24字节、RedisObject16字节和独立分配的内存碎片。我用一个真实场景算过一个用户信息Hash包含用户名、年龄、手机号等8个字段每个字段平均20字节。如果每个字段单独存成String key单看结构开销就要8个dictEntry、8个RedisObject、8个key的SDS粗算200多字节起步还要算上8次独立内存分配带来的碎片。用Hash存在同一个key下ziplist把8个字段按紧凑格式连续存放所有entry挤在一起总体能省一半以上。这就是为什么“对象型数据用Hash存”会成为Redis最佳实践的根本原因。不是Hash花哨而是ziplist在元素少、值小的场景下内存性价比碾压散列结构。当然一旦元素数量超过阈值Hash内部转成hashtable每个字段重新变回独立的dictEntry省钱的逻辑就不成立了。所以“Hash一定省内存”是个错误结论必须在“小对象”前提下才成立。2.3 用MEMORY USAGE做一次三种方案对比你可以打开任意一个Redis实例执行MEMORY USAGE命令看单个key的真实内存。我用同一个用户对象做过三种存储方案对比数据是用户ID加8个字段总长度大约180字节存储方案结构描述实测内存说明方案AJSON字符串塞进String key230字节左右单key单value包装成本全摊在一条数据上方案BHash8个字段单独存取150字节左右ziplist紧凑存储结构性开销被极大摊薄方案CHash整个对象序列化为一个字段190字节左右字段少但单个value变大ziplist的entry开销仍在实测结果很明显数据量小时String的额外包装最重Hash多字段方案内存最省序列化成一个字段的Hash介于中间但在大value场景下更可控。所以不存在“哪个结构绝对省内存”要看字段拆分的粒度与数据规模。实操时要注意MEMORY USAGE对集合类型默认只采样部分元素如果追求精确可以用带SAMPLES参数的写法代价是耗时变长。我一般会抽1000个key做均值再乘总量级估算这个办法比纯公式靠谱得多因为生产数据很难像理论模型那么规整。3. 技术选型实战不同业务场景到底该用哪种Redis数据结构3.1 排行榜与分布式锁ZSet和String的硬场景ZSet的skiplist结构让它在“按分数范围取前N名”这个场景下天然匹配。命令很简单ZADD加数据ZREVRANGE取排名ZINCRBY加分。但架构师的眼光不是盯着命令而是看数据规模。当ZSet的元素超过128个且member长度超过64字节时编码从ziplist切换为skiplist内存会有一次明显跳升但查询性能反而更稳定。排行榜这种场景数据量注定会超过128所以你要默认它会走skiplist提前按skiplist的内存模型估算容量。分布式锁这个需求也是Redis的高频场景底层用的是String的SET NX EX命令配合Lua脚本保证释放锁的原子性。有人觉得锁的value随便设个1就行但其实如果锁value存的是复杂对象每次加锁释放都要走序列化和反序列化高并发下这成本会被放大。锁这种key强制存短字符串既省内存又省CPU。顺便提一下“Redis做中间件”的场景List和Stream可以当轻量级消息队列用BRPOP、BLPOP这类阻塞命令能实现简单的消费者模型。List底层是quicklist阻塞操作的效率取决于链表节点到队首的访问路径设计上够用但如果你需要消费组、消息回溯、死信队列这类能力还是建议直接用专业的消息队列中间件别硬用Redis扛。3.2 计数、去重与活跃统计bitmap和int编码的价值计数场景比如文章的点赞数、商品的库存、直播间的在线人数String配合INCR、DECR、INCRBY等原子命令是最合适的选择。底层是int编码数字直接以长整型存储省掉字符串分配和转换开销。这时候如果你傻傻地把数字先转成字符串再存Redis就不得不走embstr或raw编码不仅内存多占INCR时还得先解析字符串白白浪费CPU。日常活跃用户统计、在线状态标记这类场景bitmap是最优解。bitmap不是独立的数据结构它本质上是String类型的位操作命令是SETBIT、GETBIT、BITCOUNT。一个用户ID占用1个bit一亿用户只需要12.5MB内存。如果你用String去存一亿个在线状态的key光是结构性开销就几个GB。这个对比极其直观同一种业务需求底层数据结构选对了内存成本可以差两个数量级。说白了架构师的价值有时候不在于写多少代码而在于面对“一亿个布尔值”这样的需求时能清醒地算出12.5MB和几GB之间的差距。3.3 对象缓存与关系数据Hash和Set的取舍用户详情、商品详情这类“对象型缓存”首推Hash。原因前面算过字段级拆分省内存还能做局部字段更新不用每次全量覆盖整个对象。比如用户头像变了直接HSET user:1001 avatar newURL只更新一个字段如果用String存整个JSON你得先GET、反序列化、改字段、再序列化、SET既费内存又费CPU。但要注意边界如果对象的字段数量可能膨胀到上千或者字段值普遍超过64字节Hash会触发hashtable编码内存优势不再明显。这时候你要重新评估是继续用Hash硬扛还是退回去用String存序列化后的整个对象。没有绝对正确的方案只有基于数据规模的合理判断。好友关系、关注列表、标签系统这类关系型数据Set天然适合。底层从intset到hashtable的切换阈值是512个元素全整数的小规模集合用intset极省内存。比如一个用户关注了50个兴趣标签这些标签ID全是整数用intset存储内存开销非常低等到关注数超过512自动切换为hashtable性能依然稳定只是内存成本上了一个台阶。3.4 缓存治理中的数据结构视角穿透、击穿、雪崩缓存穿透是指查询一个必然不存在的数据Redis里查不到请求直接打到数据库。常见解法是缓存空值把空结果也缓存起来或布隆过滤器。布隆过滤器在Redis里常用bitmap实现——这又绕回String的位操作了可见底层原理是贯穿所有实践的。缓存击穿是指一个热点key过期瞬间大量请求穿透到数据库多线程重建缓存要加锁。你可以用前面提到的分布式锁也可以把热点key设置为逻辑永不过期后台异步更新。缓存雪崩是大面积key同时过期解决方案是过期时间加随机值。这些治理手段不直接是数据结构问题但选型决定了后续治理的复杂度。比如你用一条String存整个商品详情过期后重建成本高用Hash字段级缓存部分字段过期时间可以分开控制治理起来更灵活。我个人的习惯是设计缓存时先问一个问题——“这个数据过期后重建它贵不贵”如果重建要查很多表、做很多计算那就别用一条String承载全部信息拆成Hash字段级缓存会让重建成本可控很多。这是数据结构和业务韧性之间很微妙又很实在的联动。4. 成本决策视角Redis数据结构选择背后的隐形开销4.1 内存成本估算公式与扩容预判一个常见问题100万条缓存数据到底需要多大内存我的估算法是先估key的平均长度和value的平均长度加上结构性开销乘1.2到1.3的碎片率和RSS膨胀系数得到实例最低内存需求。一个实操经验公式基于常见实践估算单条String数据内存大约等于dictEntry的24字节加keySDSkey长度加3字节头部加RedisObject的16字节加valueSDSvalue长度加3字节头部。集合类型则要按ziplist节点逐个累加每个entry除了数据还有1到3字节的头部。最稳的做法还是用MEMORY USAGE采样1000个key求均值再乘以总量级。我见过太多人用“业务数据体积”直接当内存需求结果线上Redis的used_memory比预期高了50%以上。扩容不是不行但如果你在技术方案评审阶段就能把成本算准给老板的预算报告会好看很多后续的容量告警也会少很多。4.2 序列化方案对内存账单的直接影响序列化方式对成本的影响比很多架构师想象得大。Java的JDK默认序列化性能差、膨胀率高换用Protostuff、Kryo或者Hessian序列化后体积通常能减半。同样一份业务对象JSON序列化可能需要200字节用紧凑的二进制序列化可能只有80字节。1000万条数据光序列化选错一个方案就多出1GB内存。这不是底层数据结构能解决的问题但它是成本决策链条上不可跳过的一环。选型时要把“Redis存储的数据在内存里长什么样”考虑进去而不是只盯着Redis本身。4.3 持久化、IO模型与命令复杂度的联动持久化本身不直接增加每个key的成本但它决定了内存的副本压力。RDB做fork生成子进程时虽然依赖操作系统的COW写时复制机制但写操作多的实例在fork瞬间内存可能暴涨AOF重写同理。大数据量频繁写大key会让fork时的内存成本非常难看这是很多团队在促销大流量期间必须提前预案的点。IO模型方面Redis 6.0之后引入多线程IO处理网络读写但命令执行仍是单线程这带来一个选型结论网络性能早已不是瓶颈单条命令的复杂度才是关键。如果你用错了数据结构比如把一个本来O(1)的Hash操作写成了全量HGETALL然后本地扫描或者在一个元素上百万的Set上执行SMEMBERS全量拉取崩溃就只是时间问题。两条铁律命令级复杂度要控制在常数或对数级全量取出大集合再本地处理是绝对的禁忌。4.4 淘汰策略和过期策略让每一字节都有生命周期成本决策不能只看存量数据还要看增量数据的生命周期。Redis的内存淘汰策略noeviction、allkeys-lru、allkeys-lfu、volatile-lru等直接影响在高水位下谁先被淘汰。如果你的业务数据没有设置过期时间还用noeviction那内存到达上限后写入直接报错——等于花钱买了一座只能装到90%就拒绝服务的仓库。我个人的习惯是缓存类数据一律设置相对合理的TTL并选择allkeys-lfu或allkeys-lru对不能丢的业务数据单独放实例或用持久化存储不要和缓存数据混用一个Redis。否则一次缓存雪崩级别的写入高峰可能把关键业务数据挤出内存。5. 常见问题与排查技巧实录5.1 大key是怎么产生的如何定位大key是Redis成本和质量的头号杀手。业界常用的经验阈值String类型的value超过10KB集合类型的元素数量超过5000个或一个集合的总大小超过10KB。大key的危害在于网络传输时阻塞IO、删除时阻塞主线程、内存分配不均导致碎片、fork时内存翻倍。定位大key比较简单Redis自带的redis-cli --bigkeys命令能扫描整个实例找出较大的key。我强烈建议在大促前跑一遍这个命令把大key提前拆掉或压缩。线上确实见过因为删除一个几十万元素的Set导致Redis卡顿数秒的事故删除操作本身不是O(N)但释放内存和更新指针链路在单线程模型下是真的会卡。5.2 编码转换带来的性能悬崖编码转换是另一个容易被忽略的性能悬崖。一个Hash本来好好的ziplist随着字段越来越多超过512个或value超过64字节后突然变成hashtable内存立刻跳涨但这不是崩溃只是内存和性能的“换挡”。怕的是你不知道它换了。排查方式OBJECT ENCODING key一眼看出当前编码。生产环境我建议对关键业务的数据规模做提前压测确定它在哪种编码下运行避免突发转换引发的容量告警。别等到线上内存突增再去猜OBJECT ENCODING不会说谎。5.3 内存碎片率异常碎片率怎么看INFO memory里的mem_fragmentation_ratio等于used_memory_rss除以used_memory。接近1说明内存利用很好长期大于1.5说明碎片严重。碎片来源往往是大量小对象的创建和删除以及jemalloc的分配策略。解决方式重启实例能暂时解决碎片生产环境可以等低峰期主动重启或者使用ACTIVE DEFRAG需要开启activerefrag yes。但如果数据设计本身就是海量小key根治还得回到数据结构选型——用ziplist、用Hash批量装载减少小对象数量这才是源头治理。5.4 排查命令速查表最后整理一个小表日常排查够用。配合一个可视化客户端比如RedisInsight或Another Redis Desktop Manager能直观看到key数量和内存分布比纯命令行省心很多。命令或工具用途备注TYPE key查看key的类型快速判断结构OBJECT ENCODING key查看底层编码判断是否发生编码转换MEMORY USAGE key查看单个key的内存占用集合类型可用SAMPLES参数提高精度INFO memory查看整体内存信息和碎片率关注mem_fragmentation_ratioredis-cli --bigkeys扫描大key大促前必跑SLOWLOG GET查看慢查询日志结合命令复杂度分析根因写了这么多最后分享一个我自己的真实习惯任何新业务依赖Redis之前我都会先做一次数据建模用脚本批量写入模拟数据跑MEMORY USAGE和INFO memory把每个key的平均成本记录下来作为后续扩容的基准线。有多少架构师栽在“预估不足”和“选型随意”上这一张表就是我的护身符。Redis的底层原理不是书架上落灰的理论每一次容量评估、每一次性能抖动、每一次账单核对它都会跳出来跟你算账。把这些账算明白选型和成本决策就不是玄学而是算术。