
1. 这不是教科书是我在 Redis 里泡了三年后撕开的底层切片Redis 不是“会用 set、get 就算入门”的玩具它是一套精密运转的内存引擎——你敲下LPUSH list1 a b c的瞬间背后至少触发了三次关键决策数据该存进哪种编码结构当前长度是否触发压缩阈值如果这是集群模式key 落在哪个槽位上这些决策没有日志可查不报错也不提示但一旦选错你的缓存命中率会从 99% 滑到 72%慢查询数量翻四倍而监控面板上只显示一行模糊的latency spikes。我最早接触 Redis 是在某电商大促压测现场一个本该毫秒级返回的商品详情接口突然卡在 300ms。排查三天最后发现是团队把用户购物车用String存储 JSON每次增删商品都要反序列化整个大对象再序列化写回——而其实Hash结构天然支持字段级原子操作。这件事让我彻底放弃“API 文档即真理”的思维开始扒源码、看内存布局、做压测对比。这篇总结不讲怎么安装、不列所有命令只聚焦两件事每种基本数据类型背后真实存在的底层结构体是什么它们在什么条件下会自动切换这种切换对你的业务性能产生哪些肉眼可见的影响适合已经能写 Lua 脚本、配置过哨兵、部署过集群但看到redis-cli --bigkeys输出里list类型占用内存远超预期时仍会皱眉的开发者。如果你刚学完HSET语法建议先去跑通一个带缓存的 Spring Boot Demo如果你正为线上某个 key 内存暴涨发愁现在就可以往下看了。2. 数据类型与底层结构的映射关系不是静态绑定而是动态协商2.1 为什么 Redis 要设计多层编码结构先说结论Redis 的每种数据类型string、list、hash、set、zset都不是单一结构实现的而是由多种底层编码encoding按需组合而成。这个设计核心动机只有一个——空间与时间的实时博弈。举个最直白的例子一个只有 3 个元素的 list用双向链表linkedlist存储光指针就占掉 16 字节64 位系统而用紧凑数组ziplist只需 3×元素长度 8 字节元信息。但当元素涨到 5000 个ziplist 的连续内存特性会导致每次插入都触发 reallocO(n) 时间复杂度直接拖垮性能。所以 Redis 在启动时就埋下规则小数据用省内存的结构大数据用高性能结构中间还留了缓冲区防止频繁切换。提示这个“小”和“大”的分界线不是固定值而是通过redis.conf中的list-max-ziplist-size、hash-max-ziplist-entries等参数控制。很多人改了配置却没效果是因为没理解这些参数作用的是“新创建的结构”已存在的数据不会自动重编码——这点后面实操环节会重点验证。2.2 五种基本类型的底层结构全景图我们按实际内存布局顺序拆解不罗列源码只说你能观察到的现象数据类型可能的底层编码触发条件默认配置关键特征你能感知到的性能信号Stringembstr长度 ≤ 44 字节一块内存同时存redisObject头 sds字符串不可修改SET key hello后OBJECT ENCODING key返回embstr修改后自动转rawraw长度 44 字节 或embstr被修改redisObject和sds分开分配内存GETRANGE大字符串时raw编码比embstr多一次指针跳转但差异微乎其微Listziplist元素数 ≤ 512 且 单个元素长度 ≤ 64 字节连续内存块无指针遍历快但插入慢LLENO(1)但LINSERTO(n)redis-cli --bigkeys显示list内存占用异常高时大概率是 ziplist 膨胀linkedlist超出 ziplist 限制标准双向链表每个节点含 prev/next 指针LPUSH/RPUSHO(1)但内存开销大每个节点额外 16 字节quicklistRedis 3.2 默认替代 linkedlist由多个 ziplist 组成的双向链表单个 ziplist 长度可控平衡内存与性能LINDEX随机访问比纯 ziplist 快比 linkedlist 省内存Hashziplist字段数 ≤ 512 且 单个字段/值长度 ≤ 64 字节键值对紧挨着存无哈希表冲突HGETALL返回有序插入顺序HGET查找需遍历O(n)hashtable超出 ziplist 限制标准哈希表支持 O(1) 查找HGET极快但HGETALL返回顺序随机内存占用比 ziplist 高 3~5 倍Setintset所有元素为整数且元素数 ≤ 512紧凑整数数组支持二分查找SISMEMBERO(log n)内存极致节省一旦存入字符串1立即转hashtablehashtable含非整数元素 或 元素数 512标准哈希表SADD/SREMO(1)但SMEMBERS返回无序ZSetziplist元素数 ≤ 128 且 单个成员长度 ≤ 64 字节成员与 score 交替存储按 score 排序ZRANGEO(n)但内存极省ZSCORE需遍历O(n)skiplist超出 ziplist 限制跳表 哈希表双结构兼顾排序与查找ZRANGE/ZREVRANGEO(log n m)ZSCOREO(1)内存开销最大注意intset和ziplist是 Redis 特有的紧凑结构不是通用术语。intset只存整数且会根据最大值自动选择int16_t/int32_t/int64_t存储这点常被忽略——当你用SADD myset 1 2 3 10000000000它仍是intset但加一个100000000000超 int32 范围整个集合立刻升级为hashtable内存翻倍。2.3 编码切换的隐性成本你以为的“自动优化”可能正在拖垮服务很多开发者认为“Redis 自动选最优结构”是银弹但现实是编码切换本身有开销且切换时机未必符合你的业务节奏。我们以Hash为例实测场景某用户中心服务用Hash存用户基础信息uid:1001→{name:A,age:25,city:BJ}初始 3 个字段HGETALL返回快。问题运营同学临时加了个tags字段存 JSON 字符串[vip,new]长度 15 字节仍在 ziplist 限制内。恶化一周后tags字段被频繁更新内容变成[vip,new,active,premium,2024q3]长度达 42 字节仍满足单字段 ≤ 64 字节。爆发某天批量导入 500 个新标签字段tag_001到tag_500HGETALL响应时间从 0.2ms 涨到 12msCPU 使用率飙升。原因Hash的 ziplist 编码要求字段数 ≤ 512500 个字段看似安全但 Redis 的字段计数包含所有HSET操作的历史记录——即使你HDEL tag_001ziplist 中的对应 entry 不会被立即清除只是标记为“已删除”直到下次ziplist重组。而重组触发条件是ziplist 内存使用率低于某个阈值默认 10%或执行BGREWRITEAOF。这意味着你删掉的字段内存还在那里占着且每次HGET都要跳过这些“幽灵字段”。注意OBJECT ENCODING key只能告诉你当前编码DEBUG OBJECT key才能看到更详细的内存布局包括serializedlength序列化后长度、lru最近访问时间戳。但生产环境禁用DEBUG命令所以必须靠redis-cli --bigkeysMEMORY USAGE key组合诊断。3. 实操验证亲手触发编码切换看清内存与性能的真实变化3.1 准备工作搭建可观察的测试环境别用 Docker 一键拉起的 Redis那是个黑盒。我们需要能精确控制参数、查看内部状态的实例# 1. 下载 Redis 源码以 7.2.5 为例 wget https://download.redis.io/releases/redis-7.2.5.tar.gz tar xzf redis-7.2.5.tar.gz cd redis-7.2.5 # 2. 修改 src/redis.conf关键参数调至极端值便于观察 # 注释掉所有 include确保配置纯净 # 设置最小化 ziplist 限制让切换更容易触发 list-max-ziplist-size 2 # list 超过 2 个元素就切 linkedlist hash-max-ziplist-entries 2 # hash 超过 2 个字段就切 hashtable hash-max-ziplist-value 10 # 字段值超 10 字节就切 set-max-intset-entries 2 # intset 超 2 个整数就切 hashtable zset-max-ziplist-entries 2 # zset 超 2 个元素就切 skiplist # 3. 编译并启动关闭 AOF/RDB避免干扰 make src/redis-server ./redis.conf --appendonly no --save 启动后用redis-cli -p 6379连接。此时所有“小数据”结构都会在极低阈值下触发切换方便我们肉眼验证。3.2 String 类型embstr到raw的临界点实验# 步骤 1创建一个刚好 44 字节的字符串注意sds 头部占 8 字节实际内容 36 字节 127.0.0.1:6379 SET key1 abcdefghijklmnopqrstuvwxyz123456 OK 127.0.0.1:6379 OBJECT ENCODING key1 embstr # 步骤 2追加 1 字节突破 44 字节总长 127.0.0.1:6379 APPEND key1 x (integer) 45 127.0.0.1:6379 OBJECT ENCODING key1 raw # 步骤 3验证内存变化embstr 更省内存 127.0.0.1:6379 MEMORY USAGE key1 (integer) 57 # raw 编码redisObject(16)sds(41)57字节 # 对比重新创建 44 字节字符串 127.0.0.1:6379 SET key2 abcdefghijklmnopqrstuvwxyz12345 OK 127.0.0.1:6379 MEMORY USAGE key2 (integer) 53 # embstr 编码redisObjectsds 合并在一块内存总 53 字节实操心得APPEND命令是触发embstr→raw最典型的场景。如果你的业务用 String 存日志片段、拼接 URL 参数务必预估好最大长度。超过 44 字节后每次APPEND都会 realloc 内存而embstr的内存是只读的无法追加——这是 Redis 强制切换的底层逻辑。3.3 List 类型ziplist 与 quicklist 的切换实录# 步骤 1用极小阈值创建 listlist-max-ziplist-size 2 127.0.0.1:6379 RPUSH mylist a b (integer) 2 127.0.0.1:6379 OBJECT ENCODING mylist ziplist 127.0.0.1:6379 LLEN mylist (integer) 2 # 步骤 2再 push 一个触发切换因阈值2 127.0.0.1:6379 RPUSH mylist c (integer) 3 127.0.0.1:6379 OBJECT ENCODING mylist quicklist # 注意Redis 3.2 默认用 quicklist不是 linkedlist # 步骤 3查看 quicklist 内部结构需用 DEBUG OBJECT仅限测试 127.0.0.1:6379 DEBUG OBJECT mylist Value at:0x7f8b4c005a00 refcount:1 encoding:quicklist serializedlength:42 lru:1222222222222 lru_seconds_idle:123 # 步骤 4压测对比用 redis-benchmark # 测试 ziplist2 元素的 LPUSH 性能 $ src/redis-benchmark -n 100000 -q -r 100000 -p 6379 LPUSH mylist __rand_int__ LPUSH: 84503.39 requests per second # 测试 quicklist3 元素的 LPUSH 性能 $ src/redis-benchmark -n 100000 -q -r 100000 -p 6379 LPUSH mylist __rand_int__ LPUSH: 79215.64 requests per second关键发现虽然 quicklist 比 ziplist 内存开销略大但性能下降仅 6%远好于旧版 linkedlist 的 30% 下降。这证明 Redis 团队用 quicklist 替代 linkedlist 是成功的——它把 ziplist 的内存优势和链表的扩展性结合了。但要注意quicklist的每个 ziplist 节点大小也受list-max-ziplist-size控制负数表示按字节算如-2表示每个 ziplist 不超过 8KB这个参数直接影响LINDEX的随机访问延迟。3.4 Hash 类型ziplist 中的“幽灵字段”陷阱复现# 步骤 1创建一个 2 字段的 hash因 hash-max-ziplist-entries 2 127.0.0.1:6379 HSET user:1001 name Alice age 25 (integer) 2 127.0.0.1:6379 OBJECT ENCODING user:1001 ziplist # 步骤 2添加第 3 个字段触发切换 127.0.0.1:6379 HSET user:1001 city Beijing (integer) 1 127.0.0.1:6379 OBJECT ENCODING user:1001 hashtable # 步骤 3现在删掉 city再加回——但这次用长字符串触发 ziplist 重建失败 127.0.0.1:6379 HDEL user:1001 city (integer) 1 127.0.0.1:6379 HSET user:1001 city This is a very long city name that exceeds the ziplist value limit of 10 bytes (integer) 1 # 此时 encoding 仍是 hashtable因为已切换不会退回 # 步骤 4关键模拟“幽灵字段”大量 HSET/HDEL 后的内存残留 # 创建新 hash反复操作 127.0.0.1:6379 HSET temp:hash f1 v1 f2 v2 (integer) 2 127.0.0.1:6379 OBJECT ENCODING temp:hash ziplist # 执行 100 次 HSET/HDEL 循环脚本化 for i in {1..100}; do redis-cli HSET temp:hash tmp$i val$i /dev/null redis-cli HDEL temp:hash tmp$i /dev/null done # 查看结果字段数显示 2但内存已膨胀 127.0.0.1:6379 HLEN temp:hash (integer) 2 127.0.0.1:6379 MEMORY USAGE temp:hash (integer) 245 # 远高于初始的 ~60 字节避坑技巧生产环境遇到HGETALL变慢不要只看字段数。用HSCAN游标遍历统计实际有效字段数与HLEN的差值。如果差值大说明存在大量“幽灵字段”解决方案只有两个1HGETALL后用客户端过滤2RENAME temp:hash temp:hash_bakHSET重建新 hash。后者虽有短暂不可用但比长期忍受高延迟更可靠。4. 底层结构对业务设计的硬约束避开雷区的 7 条军规4.1 String别把大 JSON 当 String 用错误示范# 用户资料全塞一个 String SET user:1001 {name:A,age:25,prefs:{theme:dark,lang:zh},tags:[vip]}问题每次更新prefs.theme都要GET→ JSON 解析 → 修改 → JSON 序列化 →SET网络CPU 双重开销。且user:1001的内存占用是 JSON 字符串长度 redisObject头无法利用 Hash 的字段级压缩。正确姿势# 改用 Hash字段名即 JSON key HSET user:1001 name A age 25 prefs:theme dark prefs:lang zh SADD user:1001:tags vip优势HSET user:1001 prefs:theme light原子执行无需客户端解析HGET user:1001 age只取需要的字段user:1001:tags用 Set 独立存储支持SISMEMBER快速判断。实测数据某社交 App 将用户资料从 String 迁移到 Hash 后缓存层平均响应时间下降 40%GC 压力减少 25%。4.2 List用LPUSH/LRANGE做消息队列先看长度List作为轻量队列很常见但LRANGE的时间复杂度是 O(SN)S 是起始索引N 是返回数量。如果你的队列积压了 10 万条消息LRANGE queue 0 99要遍历前 100 个节点没问题但LRANGE queue 99900 99999就要跳过 99900 个节点耗时陡增。解决方案短队列 1000 条List完全胜任LPOP/RPUSH原子性好。长队列 1000 条改用StreamXREAD支持按 ID 读取O(log N) 复杂度且有消费者组、ACK 机制。折中方案用List但配合LTRIM定期截断如LTRIM queue 0 999保留最新 1000 条。4.3 Hash字段名别带业务逻辑前缀错误示范# 字段名冗余 HSET order:1001 user_id u123 user_name Alice item_id i456 item_price 99.9问题user_id/user_name这类前缀在每个 Hash 中重复出现ziplist 编码下每个字段名都存一遍内存浪费严重。且HGETALL返回的 key 名含前缀客户端解析麻烦。正确姿势# 字段名精简语义清晰 HSET order:1001 uid u123 uname Alice iid i456 price 99.9 # 或更进一步用不同 key 分离关注点 HMSET order:1001:base uid u123 iid i456 price 99.9 HMSET order:1001:user name Alice level vip4.4 Set整数集合intset是性能加速器但也是脆弱点intset的SADD是 O(log n)比hashtable的 O(1) 慢但它省内存、缓存友好。然而只要插入一个非整数整个集合立刻转hashtable且不可逆。避坑口诀存数字就存纯数字123是字符串123是整数。用SADD myset 123别用SADD myset 123。混合类型需求用 ZSetZADD myzset 123 user_123score 存整数member 存字符串ZRANGEBYSCORE仍能 O(log n) 查找。4.5 ZSetscore 别用时间戳做唯一标识常见错误# 用毫秒时间戳作 score但同一毫秒可能有多个事件 ZADD events 1717023456789 event_a ZADD events 1717023456789 event_b # score 冲突event_b 覆盖 event_a后果数据丢失。ZSet 的 score 相同member 按字典序覆盖。正确方案score member 组合唯一ZADD events 1717023456789:001 event_a用:分隔时间戳与序列号。用XPENDINGXREAD替代Stream 天然支持毫秒级精确排序与去重。4.6 内存优化MEMORY DOCTOR是你的第一道防线Redis 4.0 内置内存分析工具比INFO memory直观得多127.0.0.1:6379 MEMORY DOCTOR Hi Sam! Im the Redis memory doctor and Ill help you to fix your memory problems. Lets start with some basic information: * Peak memory usage: 1.23G * Total system memory: 16.00G * Memory used by Redis: 1.10G (89.5% of peak) * Memory allocator: jemalloc * Maxmemory policy: allkeys-lru * Used memory RSS: 1.15G (1.10G 95.6% of used memory) * Process memory: 1.15G (1.10G 95.6% of process memory) Possible issues: * The memory usage may be high because of many small keys, or large values. * The memory fragmentation ratio is high (1.25), which means theres a lot of memory fragmentation. Recommendations: * Use redis-cli --bigkeys to find big keys. * Use redis-cli --hotkeys to find hot keys. * Consider using MEMORY PURGE if using jemalloc.实操心得MEMORY DOCTOR的输出要逐行读。比如它提示Memory fragmentation ratio is high就要立刻执行MEMORY MALLOC-STATS看 jemalloc 的碎片详情提示many small keys就用--bigkeys定位而不是盲目CONFIG SET maxmemory。4.7 集群模式下的结构陷阱key 的 hash tag 决定命运Redis Cluster 把 key 按 CRC16(key) % 16384 分配到 16384 个槽slot。但Hash、List、ZSet等结构的操作要求所有相关 key 在同一个 slot否则报(error) CROSSSLOT Keys in request dont hash to the same slot。错误示范# 想用 Hash 存用户所有订单但 key 没加 hash tag HSET user:1001:order:202401 status paid HSET user:1001:order:202402 status shipped # user:1001:order:202401 和 user:1001:order:202402 可能分到不同 slot正确姿势用{}包裹 hash tag只对花括号内字符串计算 hash# 所有订单强制落在同一 slot HSET {user:1001}:order:202401 status paid HSET {user:1001}:order:202402 status shipped # {user:1001} 计算出的 slot 相同安全注意{user:1001}中的:是合法字符不影响 hash 计算。这是集群环境下保证多 key 操作原子性的唯一标准方式。5. 常见问题与排查技巧实录那些让我凌晨三点改配置的故障5.1 故障现象redis-cli --bigkeys显示某个list占用 200MB但LLEN只有 5000排查思路先确认编码OBJECT ENCODING key_name—— 如果是ziplist问题大概率在此。查看实际内存MEMORY USAGE key_name对比LLEN * avg_element_size估算值。若实际值远大于估算说明 ziplist 内存碎片化严重。检查元素长度分布用LRANGE key_name 0 99看前 100 个元素是否有超长字符串如 Base64 图片ziplist 对长元素极度不友好。根因定位ziplist 的内存是连续的插入一个长元素如 10KB 日志会导致整个 ziplist realloc而旧内存块未被及时回收形成碎片。5000 个元素中只要有一个 10KB整个 ziplist 可能膨胀到 200MB。解决方案立即LPOP/RPOP清空列表或RENAME后重建。长期禁止往 List 存大对象改用Stream存日志或用StringINCR做分片log:202401:001,log:202401:002。5.2 故障现象HGETALL响应时间从 0.5ms 涨到 80msHLEN显示字段数正常排查思路DEBUG OBJECT key_name测试环境看serializedlength是否异常大。用HSCAN key_name 0 COUNT 1000分批遍历统计返回的 field 数量。若HSCAN返回 1000 个 field但HLEN是 5说明存在大量“幽灵字段”。检查业务代码是否有高频HSET/HDEL循环尤其是用HDEL删除不存在的字段Redis 会静默忽略但 ziplist 中仍留占位。根因定位“幽灵字段”导致 ziplist 遍历路径变长。HGETALL需扫描整个 ziplist每遇到一个已删除 entry 就跳过5000 次跳过操作累积成 80ms。解决方案紧急HGETALL后在客户端过滤空字段或改用HSCANCOUNT 100分页。彻底HGETALL返回后用HMSET重建新 HashHMSET new_key ...再RENAME原子切换。5.3 故障现象SISMEMBER延迟突增SCARD显示集合很大但SMEMBERS返回很快排查思路OBJECT ENCODING key_name—— 如果是intset但SCARD 512说明已切换hashtableSISMEMBER应该是 O(1)延迟不应突增。MEMORY USAGE key_name—— 若内存占用巨大如 500MB可能是hashtable的 bucket 数过多负载因子过低。INFO stats查看evicted_keys、expired_keys是否突增关联 GC 压力。根因定位hashtable的扩容/缩容是渐进式 rehash但SISMEMBER查询时若恰好遇到 rehash 中的 bucket需同时查新旧两张表延迟翻倍。而SMEMBERS是遍历不受 rehash 影响。解决方案CONFIG SET hash-max-ziplist-entries 1000调高阈值减少切换频率。避免在高峰期执行BGREWRITEAOF或BGSAVE这些操作会触发 rehash。5.4 故障现象ZADD后ZRANGE返回顺序错乱ZCARD与ZCOUNT结果不符排查思路OBJECT ENCODING key_name—— 如果是ziplistZRANGE按 score 排序但ZSCORE查找是 O(n)易出错。ZSCAN key_name 0 COUNT 1000分页检查看是否有重复 score 的 member。ZREVRANGE key_name 0 -1 WITHSCORES对比正序确认是否真乱序。根因定位ziplist编码下ZADD插入相同 score 的多个 member会按插入顺序存储但ZRANGE遍历时相同 score 的 member 顺序不保证稳定底层是线性扫描。而hashtable编码下相同 score 的 member 无序。解决方案强制用hashtableZADD key 1000000000000000001 member1用超大 score 避免冲突。或改用Stream天然支持多事件同时间戳。5.5 故障现象redis-cli --hotkeys显示某个stringkey QPS 极高但业务逻辑很简单排查思路OBJECT ENCODING key_name—— 如果是embstr说明 key 小且只读QPS 高合理。MEMORY USAGE key_name—— 若内存很小 100 字节但 QPS 10w检查是否被恶意刷如爬虫、攻击。CLIENT LIST看连接来源 IP 和cmd字段确认是否GET主导。根因定位embstr编码的 String 是只读的Redis 会做特殊优化如共享redisObject头所以高 Q