2. Redis 核心数据结构:String、Hash、List、Set、Sorted Set 的使用场景与底层编码
Redis 之所以能成为缓存与读写分离架构中的核心组件,其根本原因在于它提供了丰富且高效的数据结构。理解这五种基础数据结构(String、Hash、List、Set、Sorted Set)的使用场景与底层编码(底层实现),是设计高性能缓存策略、避免数据一致性陷阱的前提。
核心认知: Redis 的每种数据结构都对应着多种底层编码(如 int、embstr、ziplist、skiplist 等)。Redis 会根据存储数据的大小、元素数量等条件,在运行时自动切换编码,以在内存占用与操作性能之间取得最优平衡。
2.1 数据结构全景对比
下表从使用场景、底层编码、读写分离注意事项三个维度,对这五种数据结构进行横向对比,帮助你快速建立全局认知。
| 数据结构 | 核心使用场景 | 底层编码(内部实现) | 读写分离注意事项 |
|---|---|---|---|
| String | 缓存对象、计数器、分布式锁、共享 Session | int、embstr、raw | 主从延迟可能导致计数器读取不一致;大 Value 需关注序列化开销 |
| Hash | 对象属性缓存(如用户信息)、购物车、短数据聚合 | ziplist、hashtable | 部分字段更新时,从库可能读到旧字段组合;适合做细粒度缓存 |
| List | 消息队列、时间轴、最新动态、生产者-消费者模式 | quicklist(ziplist + linkedlist 混合) | BRPOPLPUSH 等阻塞操作在主从切换时可能丢失消息;需配合队列确认机制 |
| Set | 标签系统、好友关系、去重统计、抽奖 | intset、hashtable | SINTER/SDIFF 等集合运算在主从延迟下结果可能不准确;建议在从库做只读运算 |
| Sorted Set | 排行榜、延时队列、优先级任务、范围查询 | ziplist、skiplist + hashtable | ZRANGEBYSCORE 等范围查询在从库执行时,需确认数据已同步;写操作集中在主库 |
2.2 String:最基础的缓存单元
String 是 Redis 中最简单的数据结构,但也是使用最频繁的。在读写分离架构中,String 通常承载着热点数据的直接缓存。
2.2.1 使用场景
- 缓存对象: 将 JSON 序列化后的对象直接存入,如
set user:1001 '{"name":"alice","age":30}'。 - 计数器: 使用
INCR、DECR实现点赞数、访问量、库存扣减。 - 分布式锁: 利用
SETNX和SET key value NX PX 30000实现互斥锁。 - 共享 Session: 在微服务架构中,将用户 Session 信息以 String 形式集中存储。
2.2.2 底层编码
String 的底层编码根据存储值的不同,分为三种:
- int: 当存储的字符串可以表示为 64 位有符号整数时,Redis 直接使用整型存储,节省内存且运算极快。
- embstr: 当字符串长度小于等于 44 字节(Redis 3.2+)时,使用 embstr 编码。它将 RedisObject 和 SDS(Simple Dynamic String)分配在同一块连续内存中,减少内存碎片。
- raw: 当字符串长度超过 44 字节时,使用 raw 编码。RedisObject 和 SDS 分别分配在不同内存区域。
读写分离提示: 对于大 Value(如超过 10KB 的 String),主从同步时会产生较大的网络带宽消耗,且从库在同步完成前可能返回旧数据。建议对大 Value 进行拆分或使用 Hash 结构。
2.3 Hash:对象级缓存的利器
Hash 结构允许你对一个对象的多个字段进行独立操作,非常适合存储和更新结构化数据,在读写分离场景下能显著减少网络开销。
2.3.1 使用场景
- 用户信息缓存:
HSET user:1001 name "alice" age 30 city "beijing",更新某个字段时无需传输整个对象。 - 购物车: 以用户 ID 为 key,商品 ID 为 field,商品数量为 value。
- 短数据聚合: 存储配置项、设备状态等字段数量固定且较少的对象。
2.3.2 底层编码
- ziplist(压缩列表): 当 Hash 中的字段数量少于 512 个且每个字段的 value 长度小于 64 字节时,使用 ziplist。它将所有键值对按顺序紧凑排列,内存占用极低。
- hashtable(哈希表): 当不满足 ziplist 条件时,自动升级为 hashtable。此时读写时间复杂度为 O(1),但内存开销更大。
一致性陷阱: 在读写分离架构中,如果主库更新了 Hash 中的某个字段,而从库尚未同步完成,此时读取整个 Hash 对象可能会得到“新旧混合”的数据。建议业务层对关键字段做版本号校验,或优先读取主库。
2.4 List:有序队列的基石
List 是一个有序的字符串列表,支持从两端插入和弹出。在消息队列和流式处理中扮演重要角色。
2.4.1 使用场景
- 消息队列: 生产者使用
LPUSH,消费者使用BRPOP实现阻塞式消费。 - 时间轴/最新动态:
LPUSH插入最新内容,LRANGE 0 9获取前 10 条。 - 生产者-消费者模式: 结合
BRPOPLPUSH实现可靠队列,确保消息不丢失。
2.4.2 底层编码
- quicklist(快速列表): 从 Redis 3.2 开始,List 的底层统一使用 quicklist。它是 ziplist 和双向链表的混合体:将数据分段存储在多个 ziplist 中,再用双向链表将这些 ziplist 连接起来。这种设计既保证了内存紧凑,又支持高效的头部和尾部操作。
// 示例:使用 List 实现简单的任务队列
// 生产者
LPUSH task_queue "task:001"
LPUSH task_queue "task:002"
// 消费者(阻塞式获取)
BRPOP task_queue 0
// 返回: 1) "task_queue" 2) "task:001"
2.5 Set:无序集合与去重
Set 是一个无序的、元素唯一的字符串集合。它提供了丰富的集合运算,适合做标签、关系和去重处理。
2.5.1 使用场景
- 标签系统: 为文章或用户打标签,如
SADD article:1:tags "redis" "cache" "nosql"。 - 好友关系: 使用
SINTER计算共同好友,SDIFF计算可能认识的人。 - 去重统计: 记录某活动的独立访客 IP,
SADD unique_visitors "192.168.1.1"。 - 抽奖:
SRANDMEMBER随机抽取,SPOP弹出并移除。
2.5.2 底层编码
- intset(整数集合): 当 Set 中的所有元素都是整数,且元素数量少于 512 个时,使用 intset。它按升序排列,使用二分查找,内存效率极高。
- hashtable(哈希表): 当不满足 intset 条件时,升级为 hashtable,保证 O(1) 的增删查操作。
2.6 Sorted Set:带权重的有序集合
Sorted Set 在 Set 的基础上为每个元素关联了一个 double 类型的分数(score),并按照分数排序。它是实现排行榜和延时队列的核心数据结构。
2.6.1 使用场景
- 排行榜:
ZADD leaderboard 100 "player1",ZREVRANGE leaderboard 0 9 WITHSCORES获取前十名。 - 延时队列: 将任务执行时间戳作为 score,使用
ZRANGEBYSCORE获取到期的任务。 - 优先级任务: 将优先级数值作为 score,高优先级任务自动排在前面。
- 范围查询: 按分数范围快速定位数据,如查询某个时间段内的订单。
2.6.2 底层编码
- ziplist(压缩列表): 当 Sorted Set 的元素数量少于 128 个且每个元素的 value 长度小于 64 字节时,使用 ziplist。元素按 score 升序排列。
- skiplist + hashtable(跳跃表 + 哈希表): 当不满足 ziplist 条件时,使用 skiplist 作为有序结构,同时配合 hashtable 实现 O(1) 的元素查找。跳跃表是一种多层链表结构,支持 O(log N) 的插入、删除和范围查询。
性能关键点: Sorted Set 的 skiplist 编码在写入大量数据时性能稳定,但内存占用较高。在读写分离架构中,建议将排行榜的写操作集中在主库,从库只负责读取,避免从库因同步大量 ZADD 操作而产生延迟。
2.7 数据结构选择决策树
在实际项目中,面对一个缓存需求,你可以通过以下决策树快速选择最合适的数据结构:
- 是否需要持久化单个值? → 是 → String(如缓存 JSON、计数器)
- 是否需要操作对象的多个字段? → 是 → Hash(如用户信息、配置项)
- 是否需要维护有序列表?
- 仅需两端操作 → List(如消息队列、时间轴)
- 需要按分数排序 → Sorted Set(如排行榜、延时队列)
- 是否需要去重和集合运算? → 是 → Set(如标签、好友关系)
实战建议: 在读写分离架构中,优先使用 Hash 替代 String 来缓存对象。例如,缓存用户信息时,使用
HSET user:1001 name "alice" age 30 而非 SET user:1001 '{"name":"alice","age":30}'。这样在更新单个字段时,主从同步的数据量更小,从库读取时也能避免反序列化整个对象的开销。