3. Redis 核心数据结构复习:String、List、Set、Sorted Set、Hash 的使用场景与时间复杂度

在深入 Go 接口缓存实战之前,必须牢固掌握 Redis 的五种核心数据结构。它们是缓存策略的“积木”,选错结构会导致性能下降、内存浪费甚至业务逻辑错误。本节从时间复杂度、底层编码、典型场景三个维度进行系统复习。

3.1 数据结构总览与时间复杂度速查

下表汇总了五种结构的关键操作及其时间复杂度,是后续设计缓存 Key 和选择数据结构的决策依据。

数据结构 核心操作 时间复杂度 底层编码(Redis 7)
String GET / SET O(1) int / embstr / raw
MGET / MSET O(N)
INCR / DECR O(1)
List LPUSH / RPUSH / LPOP / RPOP O(1) quicklist
LINDEX / LSET O(N)
LRANGE O(S+N)
Set SADD / SREM / SISMEMBER O(1) intset / hashtable
SMEMBERS O(N)
SINTER / SUNION / SDIFF O(N*M)
Sorted Set ZADD / ZREM O(log N) ziplist / skiplist
ZSCORE / ZCARD O(1)
ZRANK / ZRANGE O(log N + M)
Hash HSET / HGET / HDEL O(1) ziplist / hashtable
HGETALL / HKEYS / HVALS O(N)
HMGET / HMSET O(N)
实战要点: 在 Go 接口缓存中,MGET 和 HMGET 是批量查询的利器,能将 N 次网络 RTT 降为 1 次。但要注意 N 不宜过大(建议 ≤ 100),否则会阻塞 Redis 单线程。

3.2 String:最通用的缓存单元

String 是 Redis 最基础的结构,value 最大 512MB。在缓存场景中,它通常直接存储序列化后的对象(JSON/Protobuf)或计数器值。

  • 典型场景: 用户 Session、配置项、页面静态片段、计数器(点赞数、访问量)。
  • Go 缓存模式: SET key value EX 3600 配合 GET key,实现 TTL 自动过期。
  • 避坑: 大 Key(value > 10KB)会导致读写延迟抖动,建议拆分或使用 Hash。
// Go 示例:String 缓存用户信息
func GetUserCache(ctx context.Context, uid string) (*User, error) {
    key := "user:" + uid
    data, err := rdb.Get(ctx, key).Bytes()
    if err == redis.Nil {
        return nil, nil // 缓存未命中
    } else if err != nil {
        return nil, err
    }
    user := &User{}
    if err := json.Unmarshal(data, user); err != nil {
        return nil, err
    }
    return user, nil
}

3.3 List:消息队列与时间轴

List 基于双向链表实现(quicklist),头尾操作 O(1),但索引访问 O(N)。在缓存中常用于实现简单的消息队列或最新动态列表。

  • 典型场景: 异步任务队列(LPUSH + BRPOP)、最新评论/文章列表(LPUSH + LTRIM)。
  • 时间复杂度陷阱: LRANGE key 0 -1 会返回整个列表,O(N) 操作在列表很长时极危险,必须配合 LTRIM 控制长度。
  • 与 Go 结合: 使用 BLPop 实现阻塞消费,避免空轮询。
性能警告: 不要用 List 存储超过 5000 个元素的“全量缓存”。如果业务需要分页查询,优先考虑 Sorted Set 或直接使用数据库分页。

3.4 Set:标签系统与去重

Set 是无序、不重复的字符串集合。它的 O(1) 成员判断和集合运算能力,使其成为标签、关注关系、抽奖系统的首选。

  • 典型场景: 用户标签(SADD)、共同好友(SINTER)、每日活跃用户(SADD + SCARD)、抽奖去重。
  • 集合运算: SINTER(交集)、SUNION(并集)、SDIFF(差集)在社交推荐中非常高效。
  • 内存优化: 当元素全为整数且数量较少时,Redis 自动使用 intset 编码,内存节省显著。
// Go 示例:使用 Set 实现用户标签交集查询
func GetCommonTags(ctx context.Context, uid1, uid2 string) ([]string, error) {
    key1 := "user:tags:" + uid1
    key2 := "user:tags:" + uid2
    return rdb.SInter(ctx, key1, key2).Result()
}

3.5 Sorted Set:排行榜与延时队列

Sorted Set 在 Set 基础上为每个元素关联一个 double 类型的 score,按 score 排序。跳表 + 哈希表的组合使其在插入、更新、范围查询上都有出色表现。

  • 典型场景: 实时排行榜(ZINCRBY + ZREVRANGE)、带权重的任务队列(ZADD + ZRANGEBYSCORE)、滑动窗口限流。
  • 核心操作: ZADD O(log N) 插入/更新;ZRANK O(log N) 获取排名;ZRANGE O(log N + M) 分页获取。
  • Go 实战: 使用 ZAddArgs 批量添加,ZRangeArgs 带分数返回。
架构决策: 当需要“按时间排序”且“支持分页”时,Sorted Set 比 List 更优。因为 List 的 LRANGE 无法跳过中间元素,而 Sorted Set 的 ZRANGE 可以高效实现跳页。

3.6 Hash:对象字段级缓存

Hash 是 Redis 中唯一支持“字段级”操作的结构,适合存储对象的部分字段更新,避免 String 序列化整个对象的开销。

  • 典型场景: 用户资料(仅更新头像字段)、商品详情(仅更新库存)、配置中心。
  • 优势: HGET / HSET O(1) 操作单个字段;HGETALL O(N) 慎用。
  • 编码优化: 当字段数小于 hash-max-ziplist-entries(默认 512)且字段值较小时,使用 ziplist 编码,内存极省。
// Go 示例:Hash 缓存商品信息,只更新价格字段
func UpdateProductPrice(ctx context.Context, pid string, price float64) error {
    key := "product:" + pid
    return rdb.HSet(ctx, key, "price", price).Err()
}

3.7 数据结构选型决策流程

下面的 SVG 图展示了从业务需求到 Redis 数据结构的快速决策路径,帮助你在 Go 接口设计时做出正确选择。

Redis 数据结构选型决策流程 业务缓存需求 是否需要 TTL 自动过期? 数据是简单值还是复杂对象? 简单值 String 复杂对象 Hash 需要排序或去重? List(队列) Set(去重) Sorted Set 选型口诀 • 简单值 + 过期 → String • 对象字段 → Hash • 去重判断 → Set • 排序分页 → Sorted Set • 消息队列 → List

3.8 时间复杂度对比:为什么 O(log N) 比 O(N) 重要

在高并发缓存场景下,时间复杂度直接影响接口的 P99 延迟。假设 Redis 单实例 QPS 为 10 万,当 Key 数量达到 100 万时:

  • O(1) 操作: 无论数据量多大,耗时稳定在微秒级(< 10μs)。
  • O(log N) 操作: 100 万数据时约 20 次比较,耗时约 20-30μs,仍可接受。
  • O(N) 操作: 遍历 100 万元素可能耗时 100ms+,直接导致接口超时。
Go 接口优化原则: 在缓存查询路径中,严格避免 O(N) 操作。如果业务必须使用 SMEMBERS 或 HGETALL,务必限制集合大小(如 SSCAN 分批迭代),或提前在业务层做数据分片。

3.9 编码与内存:看不见的性能杀手

Redis 会根据数据特征自动选择底层编码,理解这一点能帮你写出更省内存的缓存代码。

数据结构 编码类型 触发条件 内存优势
String int 值可用 64 位有符号整数表示 直接存储整数,无额外开销
String embstr 值长度 ≤ 44 字节 一次内存分配,对象和字符串连续
String raw 值长度 > 44 字节 两次内存分配,适合大文本
Hash / Sorted Set ziplist 元素少且值小(默认 ≤ 512 个或 ≤ 64 字节) 连续内存,节省指针开销
Set intset 所有元素为整数且数量 ≤ 512 有序数组,内存紧凑
Go 编码建议: 缓存小对象(如用户 ID 列表)时,优先使用 []int64 序列化为二进制,而不是 JSON 字符串。这样能触发 Redis 的 int 编码或更紧凑的存储,减少内存占用约 30%-50%。

3.10 实战案例:用 Sorted Set 实现排行榜缓存

以下 Go 代码展示了如何使用 Sorted Set 实现一个带过期时间的实时排行榜,同时保证读写性能。

// 添加用户分数(若用户已存在则更新)
func AddScore(ctx context.Context, leaderboard string, userID string, score float64) error {
    key := "rank:" + leaderboard
    // ZADD 同时支持新增和更新,O(log N)
    return rdb.ZAdd(ctx, key, redis.Z{Score: score, Member: userID}).Err()
}

// 获取 Top N 排行榜(带分数)
func GetTopN(ctx context.Context, leaderboard string, n int64) ([]redis.Z, error) {
    key := "rank:" + leaderboard
    // ZRevRangeWithScores 按分数从高到低返回,O(log N + M)
    return rdb.ZRevRangeWithScores(ctx, key, 0, n-1).Result()
}

// 获取用户排名(从 0 开始)
func GetUserRank(ctx context.Context, leaderboard string, userID string) (int64, error) {
    key := "rank:" + leaderboard
    // ZRevRank 返回逆序排名,O(log N)
    return rdb.ZRevRank(ctx, key, userID).Result()
}

// 设置排行榜过期时间(7 天)
func SetLeaderboardTTL(ctx context.Context, leaderboard string) error {
    key := "rank:" + leaderboard
    return rdb.Expire(ctx, key, 7*24*time.Hour).Err()
}