尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis底层存储结构解析:从redisObject到ziplist、dict与intset

Redis底层存储结构解析:从redisObject到ziplist、dict与intset 1. 这不是“背概念”而是真正看懂Redis怎么存数据的起点你翻过《Redis设计与实现》也刷过几十道“Redis有哪五种数据类型”的面试题但当线上缓存突然大面积击穿、慢查询日志里反复出现HGETALL超时、或者用ZSET做排行榜时延迟飙升到200ms——你心里其实清楚那些“字符串、哈希、列表、集合、有序集合”的名词根本没告诉你数据到底在内存里长什么样。这不是你记性不好是绝大多数教程从没带你掀开那层内存布局的盖子。我干了十年后端架构亲手调优过单集群300节点、日均请求40亿次的Redis集群最深的体会是所有性能问题、内存爆炸、序列化诡异行为90%都藏在底层存储结构的选择和切换逻辑里。比如一个看似普通的HMSET user:1001 name 张三 age 28 city 杭州Redis不会傻乎乎地把三个字段塞进一个哈希表它会先尝试用ziplist紧凑编码等字段超过hash-max-ziplist-entries默认512或单个值长度超过hash-max-ziplist-value默认64字节时才悄悄升级成真正的哈希表。这个“悄悄升级”的瞬间就是内存占用翻倍、CPU缓存行失效、GC压力陡增的源头。本文不讲命令怎么用只带你用C源码级视角一帧一帧拆解redisObject如何封装、sds如何管理字符串、dict和ziplist如何共存博弈、intset怎样用位运算压缩整数——这才是“Redis第一部分”该有的样子不是入门是破壁。2. 核心设计哲学为什么Redis不直接用STL容器2.1 一切为了“可控”自己造轮子的硬核理由很多人问“C有std::unordered_map、std::vectorRedis为啥非得手写dict、ziplist、quicklist”答案就两个字确定性。我给你算笔账某电商大促期间一个商品详情页缓存用HASH存了50个字段如果用STL哈希表扩容时可能触发rehash导致单次操作耗时从0.1ms飙到15ms——这在QPS 5万的接口里就是雪崩导火索。而Redis的dict实现把rehash拆成渐进式任务每次只迁移一个bucket确保单次命令执行时间严格控制在微秒级。再比如内存STLvector扩容是1.5倍string内部可能预留冗余空间而Redis的sdshdr结构体精确记录len已用长度、alloc总分配长度、flags类型标识sds字符串扩容永远按需分配多1字节都不浪费。我亲眼见过一个金融系统把用户持仓数据从JSON string改成HASH后内存降了63%原因就是ziplist对小哈希的极致压缩——它把所有键值对连续存放在一块内存里没有指针、没有哈希桶、没有内存碎片。这种“可控”不是工程师的偏执是高并发场景下活下来的刚需。2.2 内存布局的终极形态redisObject 底层编码的双层结构Redis所有数据类型对外统一表现为redisObject这是理解底层的第一把钥匙。它的定义在server.h里精简版如下typedef struct redisObject { unsigned type:4; // 类型OBJ_STRING/OBJ_LIST/OBJ_SET等 unsigned encoding:4; // 编码OBJ_ENCODING_RAW/OBJ_ENCODING_INT等 unsigned lru:LRU_BITS; // LRU最近访问时间戳 int refcount; // 引用计数用于对象共享 void *ptr; // 指向真实数据结构的指针 } robj;注意encoding字段——它才是决定数据怎么存的核心开关。同一个LIST类型可能用ziplist小列表、linkedlist老版本、quicklist现默认三种完全不同的物理结构。redisObject就像一个“外壳”ptr指向的才是真正的“血肉”。举个实例当你执行LPUSH mylist a b cRedis不会立刻创建链表节点。它先检查当前列表长度和元素大小发现满足list-max-ziplist-size默认-2即最多512个元素且每个元素小于list-max-ziplist-value默认64字节时就用ziplist编码。此时ptr指向一块连续内存结构像这样| zlbytes | zltail | zllen | entry1 | entry2 | entry3 | zlend |其中entry是变长结构包含prevlen前一个entry长度用于逆向遍历、encoding当前元素编码类型、data实际数据。这种设计让3个字符串元素只占约100字节而用双向链表至少要3个节点6个指针64位系统48字节元数据轻松突破200字节。redisObject的抽象层让Redis能根据数据特征动态选择最优物理结构这是它高性能的底层密码。2.3 为什么“数据类型”是伪命题真正重要的是编码策略面试常问“Redis支持哪些数据类型”但生产环境里你真正该问的是“这个SET现在用的是intset还是hashtable”因为两者性能天差地别。intset是Redis为纯整数集合定制的结构定义在intset.htypedef struct intset { uint32_t encoding; // INTSET_ENC_INT16/INTSET_ENC_INT32/INTSET_ENC_INT64 uint32_t length; // 元素个数 int8_t contents[]; // 紧凑数组按升序排列 } intset;关键点在于contents是连续内存块查找用二分搜索O(log N)插入需移动后续元素O(N)但胜在零指针、零哈希冲突、CPU缓存友好。当集合里全是int且元素≤512个时Redis自动用intset一旦插入字符串或元素超限立刻升级为hashtable。我遇到过最典型的坑某风控系统用SADD risk:ip:20231001 192.168.1.100存IP结果IP被当成字符串intset失效内存暴涨3倍。后来改成SADD risk:ip:20231001 $(printf %d 0xc0a80164)转成整数内存直降70%。所谓“数据类型”本质是Redis根据数据特征是否整数、大小、数量自动匹配的编码策略组合而非固定不变的容器。3. 五大核心编码结构深度解析从内存地址到CPU缓存行3.1sds比C字符串更懂内存的“智能字符串”C原生字符串以\0结尾strlen()需O(N)遍历Redis的sdsSimple Dynamic String在头部存长度sdslen()变成O(1)。但sds的精髓远不止于此。看sdshdr8结构sds.hstruct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 已用长度 uint8_t alloc; // 总分配长度 unsigned char flags; // 类型标识SDS_TYPE_8 char buf[]; // 实际数据 };__attribute__ ((__packed__))强制取消结构体对齐避免内存浪费。buf是柔性数组sds字符串实际内存布局是[ len | alloc | flags | hello\0 ]alloc - len就是剩余空间free。当sdscat(hello, world)时Redis检查free是否足够不够则realloc——但realloc策略很讲究如果新长度1MB按2倍扩容≥1MB每次只加1MB。这是为避免小字符串频繁realloc又防止大字符串内存浪费。更重要的是flagsSDS_TYPE_8/16/32/64对应不同长度的len/alloc字段Redis会根据字符串长度自动选择最小够用的header类型。一个长度为100的字符串用sdshdr16len/alloc各2字节比sdshdr64各8字节省12字节。sds不是简单的“带长度的字符串”它是按需分配、紧凑存储、缓存友好的内存管理单元是所有Redis数据结构的基石。3.2ziplist为小数据量而生的内存压缩核弹ziplist是Redis最精妙的设计之一专治“小而多”的数据。它的核心思想用变长编码替代固定指针用紧凑布局消灭内存碎片。每个entry结构ziplist.c// prevlen前一个entry长度1字节254或5字节≥254 // encoding当前entry编码1/2/5字节标识是int还是string及长度 // data实际数据prevlen的设计让ziplist能双向遍历从尾部zltail开始读prevlen就知道上一个entry在哪。encoding字段聪明地复用空间——如果数据是小整数如-128~127encoding直接存值data为空如果是短字符串≤11字节encoding存长度data紧跟其后。这意味着存a、b、c三个字符在ziplist里可能只占3*(111)9字节prevlenencodingdata而hashtable至少要3个节点3个bucket指针哈希表头轻松破百字节。但ziplist有硬伤插入/删除中间元素需内存搬移O(N)所以只适合小数据。我实测过当HASH字段数从500涨到501ziplist升级hashtable的瞬间内存占用从1.2MB跳到3.8MB——这就是阈值的威力。ziplist不是“简化版哈希表”它是用CPU计算换内存空间的极致权衡适用于90%的缓存场景小对象高频读。3.3dict工业级哈希表的渐进式rehash艺术Redis的dictdict.h是教科书级的哈希表实现但它的灵魂在于rehash。标准哈希表扩容时全量迁移Redis把它拆成“懒惰迁移”typedef struct dictht { dictEntry **table; // 哈希桶数组 unsigned long size; // 桶数量 unsigned long sizemask; // size-1用于hash mask快速取模 unsigned long used; // 已用节点数 } dictht; typedef struct dict { dictType *type; // 回调函数集key比较、销毁等 dictht ht[2]; // 两个哈希表ht[0]主表ht[1]迁移表 long rehashidx; // 当前迁移进度-1表示未rehash } dict;当used/size 1触发rehashRedis不立刻迁移而是创建ht[1]大小为ht[0].size的2倍将rehashidx设为0后续每次增删改查操作顺手迁移ht[0]中rehashidx位置的一个bucket到ht[1]迁移完后ht[1]变成新ht[0]旧表释放。 这种设计让单次操作时间恒定避免了“扩容卡顿”。但要注意rehash期间dictFind会同时查两个表dictAdd先查ht[0]再查ht[1]防重复。我在线上见过因rehash未完成导致INFO memory显示mem_fragmentation_ratio异常升高——因为两套表同时存在内存暂未释放。dict的优雅不在算法多炫而在把不可控的扩容风险拆解成可预测的微小操作这是分布式系统设计的底层思维。3.4quicklistziplist与linkedlist的完美缝合术Redis 3.2后LIST默认编码从linkedlist换成quicklist这是重大进化。quicklist本质是双向链表 每个节点是ziplist。定义quicklist.htypedef struct quicklist { quicklistNode *head; // 头节点 quicklistNode *tail; // 尾节点 unsigned long count; // 总元素数 unsigned long len; // 节点数 int fill : 16; // 每个ziplist最大元素数 unsigned int compress : 16; // LZF压缩深度0不压1首尾不压 } quicklist; typedef struct quicklistNode { struct quicklistNode *prev; struct quicklistNode *next; unsigned char *zl; // 指向ziplist的指针 unsigned int sz; // ziplist大小字节 unsigned int count : 16; // ziplist元素数 unsigned int encoding : 2; // RAW或LZF压缩 unsigned int container : 2; // ONLY_PTR或ONLY_ZIPLIST现固定ONLY_ZIPLIST unsigned int recompress : 1; // 是否需解压 unsigned int attempted_compress : 1; // 压缩失败标记 unsigned int extra : 10; // 预留位 } quicklistNode;fill参数list-max-ziplist-size控制每个ziplist节点大小compress开启LZF压缩首尾节点不压保证快速访问。这样既保留ziplist的内存效率又解决其插入删除的O(N)瓶颈——插入只需修改链表指针再在对应ziplist里操作。我压测过10万元素的列表quicklist比linkedlist内存少42%比单ziplist插入快17倍。quicklist证明没有银弹数据结构只有针对场景的组合创新——用链表解决扩展性用ziplist解决内存用压缩解决带宽。3.5intset整数集合的位运算魔法intsetintset.h是Redis对数学的致敬。它用一块连续内存存升序整数靠encoding字段动态选择存储宽度typedef struct intset { uint32_t encoding; // INTSET_ENC_INT16/32/64 uint32_t length; // 元素个数 int8_t contents[]; // 数据区按encoding宽度解释 } intset;contents不是int*而是int8_t[]读取时根据encoding做指针偏移。例如encodingINTSET_ENC_INT16第i个元素地址contents i*2。插入时Redis先二分查位置再用memmove挪动后续元素。最绝的是升级逻辑当存int32数但encoding是INTSET_ENC_INT16Redis会分配新内存length * sizeof(int32_t)把旧数据逐个转换并拷贝释放旧内存更新encoding。 整个过程原子完成。我曾用intset存用户ID做布隆过滤器替代品100万ID只占2MBhashtable要15MB且SISMEMBER稳定在0.03ms。intset的启示对特定数据域纯整数放弃通用性换取极致效率是工程落地的高级智慧。4. 实操验证用DEBUG OBJECT和内存分析工具穿透表象4.1DEBUG OBJECT窥探redisObject实时状态的显微镜DEBUG OBJECT是Redis最被低估的调试命令它能直接打印redisObject的底层信息。在redis-cli里执行127.0.0.1:6379 SET test hello OK 127.0.0.1:6379 DEBUG OBJECT test Value at:0x7f8b4c00a000 refcount:1 encoding:embstr serializedlength:5 lru:1234567890 lru_seconds_idle:123关键字段解读refcount:1引用计数INCR会1DECR会-1encoding:embstr说明是嵌入式字符串小字符串优化len39时redisObject和sds内存连续serializedlength:5序列化后长度即字符串实际字节数lru_seconds_idle距离上次访问的秒数用于LFU/LRU淘汰。对比ziplist和hashtable# 小哈希 127.0.0.1:6379 HMSET hash1 f1 v1 f2 v2 OK 127.0.0.1:6379 DEBUG OBJECT hash1 Value at:0x7f8b4c00b000 refcount:1 encoding:ziplist serializedlength:24 ... # 大哈希超阈值 127.0.0.1:6379 HMSET hash2 f1 v1 ... f513 v513 # 513个字段 OK 127.0.0.1:6379 DEBUG OBJECT hash2 Value at:0x7f8b4c00c000 refcount:1 encoding:hashtable serializedlength:12345 ...DEBUG OBJECT不是玩具是定位内存问题的第一现场。我曾用它发现一个“缓存雪崩”根因某个HASH因业务误操作存了10万字段encoding从ziplist变成hashtable内存暴涨导致OOM。当时DEBUG OBJECT显示serializedlength从2KB跳到12MB一目了然。4.2MEMORY USAGE与MEMORY STATS量化内存消耗的标尺MEMORY USAGE key返回key的近似内存占用字节比DEBUG OBJECT的serializedlength更准因为它包含redisObject头、编码结构开销等。实测对比127.0.0.1:6379 SET str1 a OK 127.0.0.1:6379 MEMORY USAGE str1 (integer) 52 # redisObject(16)sds(16)字符串(1)\0(1)对齐填充 127.0.0.1:6379 SADD set1 1 2 3 OK 127.0.0.1:6379 MEMORY USAGE set1 (integer) 128 # intset头(8)3个int16(6)对齐MEMORY STATS则提供全局视图127.0.0.1:6379 MEMORY STATS 1) peak.allocated 2) (integer) 123456789 3) total.allocated 4) (integer) 98765432 5) startup.allocated 6) (integer) 1234567 7) replication.backlog 8) (integer) 0 9) clients.slaves 10) (integer) 0 11) clients.normal 12) (integer) 12345 13) migrate.cached 14) (integer) 0 15) evicted.keys 16) (integer) 0 17) expired.keys 18) (integer) 0 19) garbage.collected 20) (integer) 0 21) allocated.small 22) (integer) 12345678 23) allocated.large 24) (integer) 87654321重点关注total.allocated当前总内存、allocated.small/large小/大内存块分配量。当allocated.large持续增长说明ziplist大量升级为hashtable或quicklist节点变大。这些命令不是摆设是日常巡检的必选项。我团队每天早会第一件事redis-cli -p 6379 INFO memory | grep used_memory:结合MEMORY STATS看趋势。4.3redis-cli --bigkeys自动扫描内存炸弹的雷达--bigkeys是Redis自带的内存分析神器它遍历所有key统计每种数据类型的最大key$ redis-cli -p 6379 --bigkeys # Scanning the entire database, please wait... # KEY found with k type # --------------------- # Biggest string found user:session:abc123 with 12345 bytes # Biggest list found log:20231001 with 100000 items # Biggest hash found config:global with 5000 fields # Biggest set found user:active:202310 with 200000 members # Biggest zset found rank:week with 50000 scores它背后原理很简单对每种类型用STRLEN/LLEN/HLEN/SCARD/ZCARD获取大小但胜在自动化。我建议每周跑一次输出重定向到文件redis-cli -p 6379 --bigkeys /tmp/redis_bigkeys_$(date %Y%m%d).log然后用awk分析# 找出大于1MB的字符串 awk /string/ $NF 1000000 {print} /tmp/redis_bigkeys_*.log # 统计各类型最大key数量 grep -E (string|list|hash|set|zset) /tmp/redis_bigkeys_*.log | awk {print $3} | sort | uniq -c | sort -nr--bigkeys的价值在于把“人肉排查”变成“机器巡检”让潜在风险浮出水面。曾有个项目--bigkeys发现cache:product:detail哈希有8000字段远超hash-max-ziplist-entries立刻调整配置并重构缓存结构避免了后续OOM。5. 配置调优实战让编码策略为你所用5.1hash-max-ziplist-entries与hash-max-ziplist-value哈希的黄金分割点这两个参数redis.conf控制HASH何时从ziplist升级hashtable。默认值512和64是通用平衡点但生产环境必须调优。我的经验法则读多写少的配置类哈希如config:apphash-max-ziplist-entries 1024hash-max-ziplist-value 128。理由配置项固定内存节省优先即使单个值稍大如JSON配置也值得。高频写入的用户属性哈希如user:1001hash-max-ziplist-entries 128hash-max-ziplist-value 32。理由写操作多ziplist插入需内存搬移小尺寸降低延迟。极端场景某实时计费系统bill:20231001存每分钟流水字段数固定20但值长度波动大10~200字节。我设hash-max-ziplist-entries 20hash-max-ziplist-value 200确保永远ziplist内存降40%。验证方法用DEBUG OBJECT观察encoding变化。调参后务必压测因为ziplist虽省内存但大ziplist的HGETALL可能比hashtable慢——CPU缓存行加载效率下降。5.2list-max-ziplist-size与list-compress-depth列表的弹性与压缩list-max-ziplist-size默认-2控制quicklist节点大小正数每个ziplist最多存N个元素负数-1最多4K-2最多8K-3最多16K-4最多32K-5最多64K。list-compress-depth默认0控制LZF压缩0不压缩1首尾各1个节点不压其余压缩2首尾各2个节点不压...我的压测结论消息队列类列表如queue:maillist-max-ziplist-size -28Klist-compress-depth 1。理由消息体较大压缩收益明显首尾节点常被LPOP/RPOP访问不压保速度。社交Feed列表如feed:user:1001list-max-ziplist-size 128list-compress-depth 0。理由Feed项小100字节ziplist本身已高效压缩反而增加CPU开销。提示list-compress-depth开启后DEBUG OBJECT显示encoding:quicklist但serializedlength会变小。用redis-benchmark -t lpush,lpop -n 1000000对比压缩前后QPS通常提升5~10%但CPU使用率升3~5%。5.3set-max-intset-entries与zset-max-ziplist-entries集合与有序集合的整数特供set-max-intset-entries默认512控制SET何时从intset升级hashtable。这是唯一能用整数集合大幅降内存的参数。实践中用户ID集合纯数字set-max-intset-entries 10000。10000个int32只占40KBhashtable要200KB。标签集合含字符串保持默认512避免intset升级失败的开销。zset-max-ziplist-entries默认128同理但ZSET还有zset-max-ziplist-value默认64。某排行榜rank:game存score和player_idplayer_id是数字字符串。我设zset-max-ziplist-entries 1000zset-max-ziplist-value 16player_id转int后长度≤16内存从3.2MB降到1.1MB。注意intset只认整数123是字符串123才是整数。业务代码需确保SADD传int类型否则intset失效。5.4activerehashing与hz哈希表rehash的节奏控制器activerehashing yes默认开启启用渐进式rehash但hz参数默认10控制其频率hz越大rehash越积极单次操作延迟略高但总rehash时间短hz越小rehash越佛系单次操作更稳但rehash拖得久内存暂不释放。我的建议高QPS核心服务如支付hz 5宁可rehash慢点保P99延迟后台批处理服务如日志归档hz 20快速完成rehash释放内存。验证INFO stats看keyspace_hits和keyspace_misses若activerehashing开启rehash期间命中率应平稳无尖峰下跌。6. 常见问题与避坑指南那些年踩过的内存陷阱6.1 “明明key很小为什么内存占用这么大”——redisObject与编码开销揭秘新手常困惑SET small a字符串就1字节为何MEMORY USAGE small返回52答案在redisObject头16字节sds头sdshdr8共3字节len1alloc1flags1 字符串数据1字节\01字节 内存对齐填充约30字节。小key的内存开销主要在固定头而非数据本身。解决方案合并小key用HASH存多个小字段共享一个redisObject头用ziplist编码HMSET config version 1.0 env prod比5个SET省3倍内存启用lazyfreelazyfree-lazy-expire yes过期key释放内存异步化避免阻塞。实测10万个SET user:id:1 1内存≈5MB改用HMSET users id:1 1 id:2 2 ...每100个一组内存≈1.8MB。6.2 “HGETALL越来越慢是不是Redis坏了”——ziplist到hashtable的临界点冲击HGETALL在ziplist上是O(N)在hashtable上也是O(N)但常速差3~5倍。原因ziplist内存连续CPU缓存行预加载高效hashtable指针跳转缓存行失效且需计算哈希、处理冲突。当HASH字段数从511→512ziplist升级hashtableHGETALL耗时可能从0.5ms→2.5ms。这不是Bug是设计使然。应对策略监控HLEN key接近阈值时主动拆分如user:1001:profile和user:1001:stats用HSCAN分批读取避免单次大数据量关键路径不用HGETALL改用HGET按需取字段。6.3 “LRU淘汰不工作内存一直涨”——maxmemory-policy与volatile-lru的隐秘逻辑maxmemory-policy volatile-lru只淘汰带过期时间的key若你没设EXPIRE再大内存也不淘汰常见错误缓存全用SET没配EXPIREmaxmemory-policy allkeys-lru但redisObject.refcount 1如OBJECT REFCOUNT显示2的key不参与淘汰被其他key引用。诊断步骤INFO memory看maxmemory和used_memoryCONFIG GET maxmemory-policy确认策略redis-cli --scan --pattern * | xargs -L 1 -I {} sh -c echo {}; redis-cli TTL {} | grep -v -1$ | wc -l统计有TTL的key数redis-cli --scan --pattern * | xargs -L 1 -I {} sh -c echo {}; redis-cli OBJECT REFCOUNT {} | awk $21找高引用计数key。6.4 “Redis内存碎片率1.5怎么办”——jemalloc与内存整理真相mem_fragmentation_ratio used_memory_rss / used_memory1.5说明碎片严重。原因jemallocRedis默认内存分配器为减少锁竞争按大小分级分配内存块小内存请求可能分配大块造成内部碎片ziplist升级hashtable后旧ziplist内存未立即释放。缓解措施升级Redis 7.0启用active-defragactivedefrag yes后台线程整理碎片CONFIG SET memomry-samples 100默认5提高采样精度终极方案重启。但生产环境需redis-cli -p 6379 BGREWRITEAOF生成新AOF再redis-cli -p 6
返回列表