2. Redis核心数据结构选型

Redis 之所以能同时承担缓存、计数器、排行榜、消息队列等多种角色,根本原因在于它提供了六种语义完全不同的核心数据结构。选型错误往往不会立刻报错,却会在数据量增长后表现为内存暴涨、命令复杂度升高、甚至阻塞主线程。因此,理解每种结构的“数据形状”和“内存代价”,是缓存平台设计的第一道关口。

一、六种结构的语义定位

String 是最基础的键值类型,值可以是文本、JSON、序列化对象或二进制,单键最大 512MB。它适合整存整取的场景,例如用户会话、页面片段、配置项、计数器(配合 INCR)。Hash 是字段-值映射,适合存储对象的多个属性,可以只读写其中某个字段,避免整体序列化开销。List 是有序、可重复的双向链表(底层为 quicklist),支持两端推入弹出,天然适合最新列表、简单队列。Set 是无序、去重的集合,支持交并差运算,适合标签、去重、共同好友。ZSet 在 Set 基础上为每个成员绑定一个 score,按 score 排序,是排行榜、延时队列的核心。Stream 是 Redis 5.0 引入的追加日志结构,带消息 ID、消费者组和 ACK 机制,用于可靠消息流。

六种核心结构:数据形状 · 典型场景 · 内存特征 String 形状:单键 → 单值(文本/二进制) 场景:会话、配置、页面片段、INCR 计数 内存:SDS 头 + 数据,小值有 embstr 优化 注意:整存整取,改一个字段要全量重写 Hash 形状:键 → {field: value, ...} 场景:对象属性、购物车、部分字段更新 内存:ziplist/listpack 小对象紧凑存储 注意:字段过多会转 hashtable,内存上升 List 形状:有序、可重复、两端操作 场景:最新动态、简单队列、时间线 内存:quicklist 节点分片,避免大块连续 注意:中间插入 O(n),不适合随机访问 Set 形状:无序、去重、集合运算 场景:标签、去重、共同关注、抽奖 内存:intset 整数集合 / hashtable 注意:元素多且非整数时内存开销较大 ZSet 形状:成员 + score,按 score 排序 场景:排行榜、延时队列、范围查询 内存:skiplist + dict,双结构冗余 注意:比 Set 多存 score 和跳表指针 Stream 形状:追加日志 + 消息 ID + 消费者组 场景:可靠消息、事件溯源、多消费组 内存:radix tree 存储,支持 XTRIM 裁剪 注意:需配合 MAXLEN 控制长度,否则持续增长 内存开销相对量级(同数量元素、同数据规模下的经验排序) String 最低 Hash 较低 List 中等 Set 中等 ZSet 较高 Stream 较高 选型口诀:整存整取用 String;对象字段用 Hash;有序两端用 List;去重集合用 Set; 带权排序用 ZSet;可靠消息用 Stream。内存敏感时优先考虑 Hash 的 listpack 编码。

二、内存开销的关键影响因素

Redis 的内存开销并不只等于“数据本身大小”,还包括键对象、值对象、过期字典、编码转换后的额外结构。以 String 为例,一个短字符串会使用 embstr 编码,把 RedisObject 和 SDS 分配在同一块内存中,减少一次内存分配;当长度超过 44 字节后转为 raw 编码,需要两次分配。这意味着同样存 100 万个短值,embstr 与 raw 的内存差距可能达到 20% 以上。

Hash 的内存表现高度依赖编码。当字段数不超过 128 且每个字段值不超过 64 字节时,Hash 使用 listpack(旧版本为 ziplist)编码,数据紧凑、无指针开销;一旦超过阈值,转为 hashtable,每个字段都要维护哈希桶和指针,内存可能翻倍。因此,把一个大对象拆成 Hash 存储时,要控制字段数量,避免无意中触发编码转换。

ZSet 是内存开销较高的结构,因为它同时维护一个 dict(成员到 score 的映射)和一个 skiplist(按 score 排序)。每个成员至少被存储两次,再加上跳表的多层指针,内存通常是同规模 Set 的 1.5~2 倍。如果只需要去重而不需要排序,应优先用 Set;如果只需要排序而不需要成员去重,可以考虑用 List 加外部排序。

Stream 的内存与消息保留策略直接相关。默认情况下消息会一直保留,必须显式使用 XADD 的 MAXLEN 或 MINID 参数进行裁剪。例如 XADD mystream MAXLEN ~ 10000 * field value 表示大致保留最近 1 万条,~ 允许 Redis 在节点边界处裁剪,效率更高。如果不加限制,Stream 会持续增长,最终触发 maxmemory 淘汰或写入失败。

三、业务场景选型对照

业务场景推荐结构关键理由需规避的做法
用户会话 tokenString整存整取,可设 TTL拆成多个键导致键数量膨胀
商品属性缓存Hash可单独更新价格、库存字段数超过 128 触发 hashtable
最新 100 条评论ListLPUSH + LTRIM 天然截断用 LRANGE 全量读取大 List
文章标签去重SetSADD 自动去重,支持交集元素为非整数且量大时内存高
销量排行榜ZSetZINCRBY + ZREVRANGE 高效用 List 手动排序,复杂度高
订单事件流Stream消费者组 + ACK 保证可靠不设 MAXLEN 导致无限增长

四、一个可执行的选型判断过程

假设要为一个内容社区设计缓存,需求是:缓存文章详情、维护文章点赞用户集合、生成热度排行榜、记录用户浏览历史、投递站内通知。可以按以下顺序判断:

  1. 文章详情:字段固定且需要单独更新阅读数,选择 Hash,键为 article:{id},字段包括 title、author、content、read_count。若 content 很大,可单独用 String 存 article:content:{id},避免 Hash 整体膨胀。
  2. 点赞用户集合:需要去重和判断某用户是否点赞,选择 Set,键为 article:likes:{id},用 SADD 添加、SISMEMBER 判断、SCARD 计数。
  3. 热度排行榜:需要按热度排序并取 Top N,选择 ZSet,键为 rank:hot,用 ZINCRBY 更新分数,ZREVRANGE 取前 100。
  4. 用户浏览历史:需要保留最近 N 条且有序,选择 List,键为 history:{userId},LPUSH 写入后 LTRIM 保留 50 条。
  5. 站内通知:需要可靠投递、支持多消费者,选择 Stream,键为 notify:stream,用 XADD 写入并设置 MAXLEN,消费者组用 XREADGROUP 读取、XACK 确认。
选型时先问三个问题:数据是单值还是多字段?是否需要去重或排序?是否需要可靠消费?答案会直接指向 String/Hash、Set/ZSet、Stream。再结合元素数量和字段大小判断是否会触发编码转换,就能在功能与内存之间取得平衡。
不要因为“Hash 更省内存”就把所有数据都塞进 Hash。Hash 的 listpack 编码有字段数和字段值长度上限,超过后转为 hashtable,内存优势消失,且大 Hash 的 HGETALL 会阻塞主线程。同理,ZSet 虽然功能强,但双结构冗余使其内存开销明显高于 Set,能用 Set 解决的去重需求不要用 ZSet。

最后需要强调,内存开销对比不是绝对的数值排名,而是随元素数量、值长度、编码方式动态变化的。实际部署中应通过 MEMORY USAGE key 观察单个键的真实占用,用 INFO memory 跟踪整体使用量,再结合业务增长趋势决定是否拆分键、调整编码阈值或引入淘汰策略。选型的终点不是记住结论,而是建立“数据形状 → 结构语义 → 内存代价 → 业务约束”的推理链条。