返回文章列表

Hash 与 List

从对象字段、购物车、配置集合、计数器、栈、FIFO 队列和阻塞消费出发,理解 Redis Hash 与 List 的命令语义、版本差异、底层编码和 Go 使用边界。

第 5 章:Hash 与 List

技术基线:Redis Open Source 8.x;兼顾 Redis 6、7、8 的版本差异。Go 示例统一使用 github.com/redis/go-redis/v9

1. 本章定位

Hash 与 List 都属于 Redis 的复合数据类型,但解决的问题不同:

  • Hash 适合表达“一个 Redis Key 下的一组字段”,常用于对象属性、购物车商品数量、配置项和分组计数器。
  • List 适合表达“有顺序、可从两端操作的一组元素”,常用于栈、FIFO 队列、有限时间线和阻塞消费。

本章的重点不是记忆命令,而是建立三层判断:

  1. 业务模型是否真的匹配 Hash 或 List;
  2. 命令是否会形成 BigKey、HotKey、阻塞连接或消息丢失;
  3. 底层编码与 Redis 版本变化会怎样影响内存、复杂度和面试回答。

截至 2026 年 6 月,Redis 官方下载页列出的 Redis Open Source 当前版本为 8.8。本章以 8.x 行为为基线,同时明确 Redis 6.2、7.0、7.2、7.4 和 8.0 的关键差异。

2. 学习目标

完成本章后,应能够:

  1. 使用 Hash 建模对象、购物车、配置与计数集合,并解释其适用边界。
  2. 正确使用 HSETHGETHMGETHINCRBYHSCAN,区分缺失字段与真正异常。
  3. 比较一个 Hash、多个 String Key 和单个 JSON String 的内存、TTL、并发及集群权衡。
  4. 说明 Redis 7.4+ Hash Field TTL 与 Redis 8.0+ HSETEXHGETEX 的版本差异。
  5. 使用 List 构造栈、FIFO 队列、时间线和阻塞队列,并能正确选择左右端方向。
  6. 解释为什么 BLPOP/BRPOP 可能丢任务,以及如何用 BLMOVE、处理中队列、ACK、回收和幂等改进。
  7. 解释 Hash 的 listpack/hashtable 编码,以及 List、quicklist、listpack 的现代关系。
  8. 在 Go 中正确设置 Context、超时和连接池,避免阻塞命令耗尽连接。

2.1 本章边界与跳转

本章是 Hash 与 List 基础类型的主章节,重点是对象字段建模、List 双端操作和阻塞队列原语。更完整的消息通信、消费者组、PEL、死信和至少一次投递见[第 8 章](/blog/tech/Redis/08.PubSub、List 队列与 Streams/);Hash/List 的底层结构与编码转换见[第 11 章](/blog/tech/Redis/11.Redis 核心对象与底层数据结构/);Cluster 中多 Key 与同槽限制见[第 17 章](/blog/tech/Redis/17.Redis Cluster 分片与高可用/);重复消费后的业务幂等见第 19 章

3. 核心概念

3.1 Hash:一个 Key 下的字段集合

Redis Hash 可以抽象为:

key -> {
    field1: value1,
    field2: value2,
    ...
}

例如用户资料:

user:1001:profile -> {
    name: "Alice",
    city: "Tokyo",
    login_count: "37"
}

Hash 的 field 和 value 最终都以字符串语义存储。它适合以下模型:

  • 对象属性:只读取或更新部分字段,不必反序列化整个 JSON。
  • 购物车:field 为商品 ID,value 为数量,使用 HINCRBY 原子增减。
  • 配置集合:同一业务域的多个配置项集中在一个 Key 下。
  • 计数器集合:同一实体的多个指标放在一个 Hash 中。

不适合直接塞进 Hash 的内容包括深层嵌套对象、需要复杂查询的文档,以及字段数量或单字段值可能无界增长的对象。复杂结构可在 field value 中序列化,但这会失去子字段级操作能力;需要文档查询时应考虑 Redis JSON/Search 或外部数据库。

3.2 Hash 与多个 String Key 的核心差异

假设要存用户的 namecitylogin_count

方案 A:user:1001:profile -> Hash
方案 B:user:1001:name、user:1001:city、user:1001:login_count -> 多个 String
方案 C:user:1001:profile -> JSON String

Hash 的主要收益是减少顶层 Key 数量、支持字段级读写和批量读取,并且小 Hash 有紧凑编码优势。代价是整个 Hash 仍然是一个顶层 Key:它位于一个 Cluster Slot、可能成为 HotKey,maxmemory 淘汰通常以整个 Key 为单位,而不是只淘汰一个 field。

Redis 7.4 引入字段级 TTL 后,Hash 缺乏独立过期能力这一历史短板有所缓解,但并未把每个 field 变成独立顶层 Key。字段仍不能独立分片,整个 Hash 仍共享 Key 级访问热点和淘汰命运。

3.3 Hash Field TTL 的版本差异

Redis 版本字段级 TTL 能力设计含义
Redis 7.2 及更早不支持 field 独立 TTL,只能给整个 Hash Key 设置 EXPIRE需要独立过期时,拆成多个 String Key,或维护额外过期索引与清理逻辑
Redis 7.4+新增 HEXPIREHPEXPIREHEXPIREATHPEXPIREATHTTLHPTTLHPERSIST可以让同一 Hash 内不同 field 独立过期
Redis 8.0+新增 HSETEXHGETEXHGETDEL支持“设置字段并设置 TTL”“读取并刷新/移除 TTL”“读取后删除”等更原子的组合操作

必须区分两类 TTL:

  • EXPIRE hash-key 3600 设置的是整个 Hash Key 的 TTL。
  • HEXPIRE hash-key 3600 FIELDS 1 field-a 设置的是某个 field 的 TTL。

还有两个容易答错的细节:

  1. 普通 HSET 覆盖某个 field 的内容时,会清除该 field 原有的字段级 TTL;但不会因此清除整个 Hash Key 的 Key 级 TTL。
  2. HINCRBY 这类在原值上修改而非替换字段内容的操作,会保留字段 TTL。Redis 8.0+ 需要原子写入并保留旧字段 TTL 时,可评估 HSETEX ... KEEPTTL

因此,升级到 Redis 7.4+ 之后也不能机械地把所有 String Key 合并为一个 Hash。还要评估热点、淘汰、分片、迁移和客户端兼容性。

3.4 List:双端有序序列

Redis List 是有顺序的字符串序列,支持从左端和右端插入、弹出:

左端(head) <- [a, b, c, d] -> 右端(tail)

常见组合:

模型生产消费顺序
LPUSHLPOP后进先出 LIFO
FIFO 队列LPUSHRPOP / BRPOP先进先出
FIFO 队列RPUSHLPOP / BLPOP先进先出
最新时间线LPUSHLRANGE 0 N-1最新元素在前
可靠性改进队列LPUSH readyBLMOVE ready processing RIGHT LEFT原子移入处理中队列

方向本身没有唯一标准,关键是生产者与消费者约定一致,并在 Key 名称和代码中明确。

3.5 阻塞队列不等于可靠消息队列

BLPOPBRPOP 在队列为空时会挂起客户端连接;新元素到来或超时后返回。Redis 并不是让命令执行线程忙等,而是登记阻塞客户端,条件满足后再唤醒。

BLPOP/BRPOP破坏性弹出:元素一旦返回给消费者,就已经从 List 删除。若消费者随后崩溃,任务不会自动出现。因此它更接近“最多一次倾向”的简单任务分发,而不是带 ACK、重试次数和 Pending 列表的完整消息系统。

Redis 6.2+ 提供 LMOVE 和阻塞版本 BLMOVE,可原子地把元素从 ready List 移到 processing List。消费者成功后使用 LREM processing 1 payload 作为 ACK;若消费者崩溃,任务仍留在 processing 中,回收程序可重新入队。

这个模式只是可靠性改进,不是完整保证:

  • 成功处理后 ACK 失败,任务可能被再次投递,因此处理器必须幂等。
  • 必须有超时回收、毒消息处理和监控,否则 processing 会无限增长。
  • LREM 是线性扫描,处理中任务很多时延迟会升高。
  • List 本身没有消费者组、Pending Entry、投递次数、Claim 等原生元数据。

可靠性要求较高时,通常优先选择 Redis Streams 或专业消息队列。

4. 命令与 Go 使用方法

4.1 Hash 的 redis-cli 示例

# 创建或更新多个字段;Redis 4.0+ 的 HSET 支持多组 field/value
HSET user:1001:profile name Alice city Tokyo login_count 0

# 读取单字段
HGET user:1001:profile name

# 一次读取指定字段;不存在的字段返回 nil 占位
HMGET user:1001:profile name city age

# 原子增加字段值;字段不存在时按 0 创建
HINCRBY user:1001:profile login_count 1

# 游标式遍历,COUNT 是提示值而非严格批量大小
HSCAN user:1001:profile 0 MATCH "*count*" COUNT 100

# Redis 7.4+:为指定 field 设置 30 分钟 TTL
HEXPIRE user:1001:profile 1800 FIELDS 1 city
HTTL user:1001:profile FIELDS 1 city

# Redis 8.0+:设置字段并同时设置字段 TTL
HSETEX session:1001 EX 1800 FIELDS 2 access_token abc refresh_token xyz

HGET 查询不存在的 Key 或 field 时返回 nil。HMGET 则返回与请求 field 数量相同的数组,每个缺失位置为 nil;不要因为其中一个 field 缺失就把整个请求当成 Redis 故障。

4.2 Go:Hash 对象、计数与扫描

package redisexample

import (
    "context"
    "errors"
    "fmt"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

var ErrProfileNotFound = errors.New("profile not found")

type Profile struct {
    Name       string
    City       string
    LoginCount int64
}

func SaveProfile(ctx context.Context, rdb *redis.Client, userID string, p Profile) error {
    cmdCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    key := "user:" + userID + ":profile"
    err := rdb.HSet(cmdCtx, key, map[string]any{
        "name":        p.Name,
        "city":        p.City,
        "login_count": p.LoginCount,
    }).Err()
    if err != nil {
        return fmt.Errorf("hset %s: %w", key, err)
    }
    return nil
}

func LoadProfile(ctx context.Context, rdb *redis.Client, userID string) (Profile, error) {
    cmdCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    key := "user:" + userID + ":profile"
    values, err := rdb.HMGet(cmdCtx, key, "name", "city", "login_count").Result()
    if err != nil {
        return Profile{}, fmt.Errorf("hmget %s: %w", key, err)
    }

    // HMGET 对缺失字段返回 nil,并不返回 redis.Nil。
    if len(values) != 3 || (values[0] == nil && values[1] == nil && values[2] == nil) {
        return Profile{}, ErrProfileNotFound
    }
    if values[0] == nil || values[1] == nil || values[2] == nil {
        return Profile{}, fmt.Errorf("incomplete profile: %v", values)
    }

    name, ok := values[0].(string)
    if !ok {
        return Profile{}, fmt.Errorf("invalid name type %T", values[0])
    }
    city, ok := values[1].(string)
    if !ok {
        return Profile{}, fmt.Errorf("invalid city type %T", values[1])
    }
    countText, ok := values[2].(string)
    if !ok {
        return Profile{}, fmt.Errorf("invalid login_count type %T", values[2])
    }
    count, err := strconv.ParseInt(countText, 10, 64)
    if err != nil {
        return Profile{}, fmt.Errorf("parse login_count %q: %w", countText, err)
    }

    return Profile{Name: name, City: city, LoginCount: count}, nil
}

func GetProfileName(ctx context.Context, rdb *redis.Client, userID string) (string, error) {
    cmdCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    value, err := rdb.HGet(cmdCtx, "user:"+userID+":profile", "name").Result()
    switch {
    case errors.Is(err, redis.Nil):
        return "", ErrProfileNotFound
    case err != nil:
        return "", fmt.Errorf("hget profile name: %w", err)
    default:
        return value, nil
    }
}

func IncrementLoginCount(ctx context.Context, rdb *redis.Client, userID string) (int64, error) {
    cmdCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    n, err := rdb.HIncrBy(cmdCtx, "user:"+userID+":profile", "login_count", 1).Result()
    if err != nil {
        return 0, fmt.Errorf("hincrby login_count: %w", err)
    }
    return n, nil
}

func ScanProfileFields(
    ctx context.Context,
    rdb *redis.Client,
    key string,
    match string,
    visit func(field, value string) error,
) error {
    scanCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    var cursor uint64
    for {
        items, next, err := rdb.HScan(scanCtx, key, cursor, match, 100).Result()
        if err != nil {
            return fmt.Errorf("hscan %s cursor=%d: %w", key, cursor, err)
        }
        if len(items)%2 != 0 {
            return fmt.Errorf("unexpected hscan result length: %d", len(items))
        }

        for i := 0; i < len(items); i += 2 {
            if err := visit(items[i], items[i+1]); err != nil {
                return err
            }
        }

        cursor = next
        if cursor == 0 {
            return nil
        }
    }
}

// Redis 7.4+。旧服务端会返回 unknown command,而不是 redis.Nil。
func ExpireHashField(
    ctx context.Context,
    rdb *redis.Client,
    key, field string,
    ttl time.Duration,
) error {
    cmdCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    statuses, err := rdb.HExpire(cmdCtx, key, ttl, field).Result()
    if err != nil {
        return fmt.Errorf("hexpire %s[%s]: %w", key, field, err)
    }
    if len(statuses) != 1 || statuses[0] != 1 {
        return fmt.Errorf("field TTL not set, status=%v", statuses)
    }
    return nil
}

关键点:

  • redis.Client 可被多个 goroutine 并发复用,不应为每个请求创建新客户端。
  • HINCRBY 单命令在 Redis 端原子执行;“同时更新多个 field 并保持业务不变量”仍需 Lua、事务或重新设计数据模型。
  • HMGET 缺失字段通过 nil 元素表达;HGET 缺失值通常表现为 redis.Nil
  • HSCAN 不是事务快照。遍历期间 Hash 被修改时,业务回调应可重复执行,并根据需要按 field 去重。
  • 单次短命令可设置数百毫秒级预算;完整扫描的 Context 应按数据量和运维窗口单独设置,不能无限期运行。

4.3 List 的 redis-cli 示例

# 栈:左进左出
LPUSH stack:a task-1 task-2
LPOP stack:a

# FIFO:左进右出
LPUSH queue:mail:ready job-1
BRPOP queue:mail:ready 5

# FIFO:右进左出,语义等价但方向相反
RPUSH queue:sms:ready job-1
BLPOP queue:sms:ready 5

# 时间线:最新内容放左侧,只保留最近 100 条
LPUSH feed:user:1001 event-1
LTRIM feed:user:1001 0 99
LRANGE feed:user:1001 0 19

# Redis 6.2+:原子地从 ready 右端移到 processing 左端
LMOVE queue:{mail}:ready queue:{mail}:processing RIGHT LEFT
BLMOVE queue:{mail}:ready queue:{mail}:processing RIGHT LEFT 5

# 处理成功后的 ACK
LREM queue:{mail}:processing 1 '<完整任务载荷>'

{mail} 是 Cluster Hash Tag。LMOVE/BLMOVE 涉及两个 Key,在 Redis Cluster 中应让源和目标落在同一个 Slot,否则可能得到 CROSSSLOT

4.4 Go:简单阻塞队列

package redisexample

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

var ErrNoTask = errors.New("no task available")

type Task struct {
    ID      string `json:"id"`
    Type    string `json:"type"`
    Payload string `json:"payload"`
}

func Enqueue(ctx context.Context, rdb *redis.Client, key string, task Task) error {
    raw, err := json.Marshal(task)
    if err != nil {
        return fmt.Errorf("marshal task: %w", err)
    }

    cmdCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    // 与 BRPOP 配合:左侧生产,右侧消费,形成 FIFO。
    if err := rdb.LPush(cmdCtx, key, raw).Err(); err != nil {
        return fmt.Errorf("lpush %s: %w", key, err)
    }
    return nil
}

func DequeueOnce(ctx context.Context, rdb *redis.Client, key string) (Task, error) {
    const blockTimeout = 5 * time.Second

    // Context 应略长于 Redis 的阻塞超时,给网络收包和解析留出余量。
    cmdCtx, cancel := context.WithTimeout(ctx, blockTimeout+time.Second)
    defer cancel()

    result, err := rdb.BRPop(cmdCtx, blockTimeout, key).Result()
    switch {
    case errors.Is(err, redis.Nil):
        return Task{}, ErrNoTask // 服务端阻塞超时,队列中没有元素
    case err != nil:
        return Task{}, fmt.Errorf("brpop %s: %w", key, err)
    }
    if len(result) != 2 {
        return Task{}, fmt.Errorf("unexpected brpop result: %v", result)
    }

    var task Task
    if err := json.Unmarshal([]byte(result[1]), &task); err != nil {
        // 注意:此时元素已被删除,解析失败也会丢失任务。
        return Task{}, fmt.Errorf("decode task: %w", err)
    }
    return task, nil
}

该实现简单、延迟低,但只适用于允许少量任务丢失、能从数据库重建或天然可重试的场景。每个阻塞调用在等待期间会占用一个客户端连接,因此消费者并发数必须与连接池容量匹配。

4.5 Go:使用 BLMOVE 改进可靠性

package redisexample

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// 复用上一段代码中的 Task、ErrNoTask 定义。
func ConsumeReliableOnce(
    ctx context.Context,
    rdb *redis.Client,
    readyKey string,
    processingKey string,
    handle func(context.Context, Task) error,
) error {
    const blockTimeout = 5 * time.Second

    moveCtx, cancelMove := context.WithTimeout(ctx, blockTimeout+time.Second)
    raw, err := rdb.BLMove(
        moveCtx,
        readyKey,
        processingKey,
        "RIGHT",
        "LEFT",
        blockTimeout,
    ).Result()
    cancelMove()

    switch {
    case errors.Is(err, redis.Nil):
        return ErrNoTask
    case err != nil:
        return fmt.Errorf("blmove %s -> %s: %w", readyKey, processingKey, err)
    }

    var task Task
    if err := json.Unmarshal([]byte(raw), &task); err != nil {
        // 保留在 processing,交由毒消息处理或人工排查。
        return fmt.Errorf("decode processing task: %w", err)
    }

    // 使用外层 ctx 执行业务,不能沿用只为阻塞命令设置的短 Context。
    if err := handle(ctx, task); err != nil {
        // 不 ACK,让回收器稍后重试;handle 必须使用 Task.ID 做幂等。
        return fmt.Errorf("handle task %s: %w", task.ID, err)
    }

    ackCtx, cancelAck := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancelAck()

    removed, err := rdb.LRem(ackCtx, processingKey, 1, raw).Result()
    if err != nil {
        // 业务可能已成功但 ACK 未确认,后续可能重复投递。
        return fmt.Errorf("ack task %s: %w", task.ID, err)
    }
    if removed != 1 {
        return fmt.Errorf("ack task %s removed=%d", task.ID, removed)
    }
    return nil
}

生产设计还应补齐:

  1. Task.ID 全局唯一,业务侧以该 ID 做幂等记录或唯一约束。
  2. 监控 LLEN readyLLEN processing、最老任务等待时间和失败率。
  3. 建立超时回收器与死信队列,限制最大重试次数。
  4. 控制 processing 长度,因为 LREM 的成本随列表长度增长。
  5. Cluster 中使用同一 Hash Tag,例如 queue:{mail}:readyqueue:{mail}:processing
  6. 高可靠需求优先使用 Streams 或专业消息队列,而不是继续给 List 叠加复杂协议。

5. 典型业务场景

场景适用方式不适用情况数据量要求一致性要求主要风险可替代方案
用户资料缓存一个用户一个 Hash,field 为常用属性深层嵌套、跨字段复杂查询单对象字段数和字段值应受控可接受缓存最终一致;源数据库为准HotKey、字段缺失、整体淘汰JSON String、Redis JSON、数据库直查
购物车field=SKU,value=数量,HINCRBY 增减需要商品级复杂过期、强事务结算单用户商品数有明确上限数量更新可原子;结算仍需数据库事务/幂等超大购物车、脏缓存、负数数量多 String Key、数据库、Lua 校验
配置集合一个业务域一个 Hash,HMGET 批量读取每项都需独立分片、独立淘汰或审计版本字段数量可控通常要求版本化和回源一次误删影响整组、热配置形成 HotKey多 Key、配置中心
分组计数器HINCRBY 更新多个指标指标维度无界、高基数必须限制 field 基数单字段原子,多字段不变量需额外机制Hash 膨胀、扫描成本高String 计数器、时序数据库
最近动态时间线LPUSH + LTRIM + LRANGE全量历史、复杂排序和分页只保留固定最近 N 条通常允许最终一致LRANGE 过大、频繁 trim、单用户热点Sorted Set、数据库、Feed 系统
简单后台任务LPUSH + BRPOP不能容忍任务丢失或需要 ACK/重试轨迹队列长度必须有上限和告警允许少量丢失或可重建消费者崩溃后任务丢失Streams、Kafka、RabbitMQ
可靠性改进队列BLMOVE ready processing + ACK + 回收高吞吐、多消费者组、严格投递审计中小规模,处理中列表受控至少一次倾向,业务必须幂等重复投递、ACK 歧义、LREM O(N)Streams、专业 MQ

6. 底层实现

6.1 Hash:listpack 与 hashtable

Redis 不为所有 Hash 固定使用同一种编码,而是在内存效率和访问效率之间切换。

小 Hash:listpack

listpack 是一段紧凑的连续内存。普通 Hash 可按 field/value 交替存放:

[field1][value1][field2][value2]...

其优势是:

  • 不需要为每个 field/value 单独分配哈希节点和指针,内存开销较小。
  • 连续内存具有较好的 CPU Cache 局部性。
  • 小对象的线性扫描成本有限,往往比维护完整哈希表更划算。

代价是:

  • 查找某个 field 需要在紧凑序列中扫描,内部并非真正 O(1)。
  • 插入、删除或扩大元素可能发生内存移动或重新分配。
  • 数据一大,CPU 和内存搬移成本会迅速上升。

因此 Redis 使用 hash-max-listpack-entrieshash-max-listpack-value 等阈值控制紧凑编码。字段数量、单个 field/value 长度或安全容量超过条件时,会转换为哈希表编码。

Redis 8.8 源码还存在支持字段过期的 listpack_ex 编码:字段、值和过期信息以更适合过期管理的形式组织。这属于实现细节,业务代码不应依赖其内存布局。

大 Hash:hashtable

Hash 转为 hashtable 后,可通过哈希定位 field,平均查找、更新接近 O(1),更适合字段较多或值较大的对象。代价是每个条目的指针、分配器元数据、装载因子和 rehash 都会增加空间与 CPU 成本。

版本差异

  • Redis 6.x 的经典面试材料通常描述“小 Hash 使用 ziplist,大 Hash 使用 hashtable”。
  • Redis 7.x/8.x 中,listpack 已替代 ziplist 成为主要紧凑编码。
  • Redis 7.4+ 引入字段级 TTL,并增加相应的过期元数据与管理机制。

面试时应说“编码由版本和阈值决定”,不要把某个历史版本的 ziplist 结论当作 Redis 8.x 的唯一事实。

6.2 List、quicklist 与 listpack

现代 Redis 8.x 中,List 可能有两种对象级编码:

  1. 小型 List 直接使用 listpack
  2. 超过 list-max-listpack-size 等限制后转换为 quicklist

quicklist 不是“普通双向链表的每个节点只存一个元素”,而是:

quicklist = 双向链表 + 每个节点内部的一段 listpack

这种折中设计同时解决两个极端的问题:

  • 纯链表每个元素都带前后指针和独立分配,内存碎片与指针开销高。
  • 单个巨大连续压缩块虽然省内存,但中间修改可能搬移大量数据。

quicklist 把元素分块:头尾操作只影响少量节点;每个节点内部仍用 listpack 紧凑存储。根据 list-compress-depth,内部节点还可使用 LZF 压缩,以内存换取解压 CPU;靠近两端的节点通常保持可快速访问。

List 编码的关键版本变化

版本典型实现描述
Redis 6.2List 对象主要是 quicklist,quicklist 节点使用 ziplist 时代的布局与配置命名
Redis 7.0quicklist 节点已使用 listpack,但 List 对象通常仍以 quicklist 为主
Redis 7.2+小 List 可直接使用 listpack,变大后再转 quicklist;Redis 8.x 延续这一思路

因此,“List 的底层永远是 quicklist”在现代版本中不够准确;更好的回答是“小 List 可直接 listpack,大 List 使用由多个 listpack 节点组成的 quicklist”。

6.3 命令复杂度与真实成本

命令文档级复杂度需要补充的条件
HGETO(1)hashtable 下平均 O(1);小 Hash 的 listpack 内部会线性扫描,但受紧凑编码阈值约束
HSET每个 field/value 平均 O(1)可能触发编码转换、内存扩容或字典 rehash,单次延迟会波动
HMGETO(N)N 为请求 field 数;响应体和网络成本也随 N 增长
HINCRBYO(1)仅保证单字段单命令原子;数值解析失败会报错
HSCAN单次调用通常 O(1),完整遍历 O(N)COUNT 是提示;遍历期间修改时不是一致性快照
HGETALLO(N)N 为全部字段;Big Hash 会同时放大主线程、内存和网络延迟
LPUSH/RPUSH单元素 O(1)一次推入 M 个元素为 O(M),还可能触发 listpack 到 quicklist 转换
LPOP/RPOP单元素 O(1)带 count 返回多个元素时成本随返回数量增长
BLPOP/BRPOPO(K)K 为检查的 Key 数;阻塞等待本身不忙等,但占用一个客户端连接
LMOVE/BLMOVEO(1)两个 Key 的原子移动;Cluster 中必须满足 Slot 约束
LRANGEO(S+N)S 为到起始位置的遍历距离,N 为返回元素数;深分页和大范围读取昂贵
LREMO(N)N 为 List 长度;处理中队列很长时 ACK 会变慢

复杂度只是 CPU 操作数量的抽象。生产延迟还包括对象解码、内存分配、网络传输、阻塞连接、复制与持久化开销。

7. 高性能、高并发、高可用分析

7.1 高性能

CPU

  • 优先使用 HMGET 读取需要的字段,避免对大 Hash 使用 HGETALL
  • 对时间线只读取固定窗口,例如 LRANGE 0 19,不要把 List 当无限历史表。
  • LREM、深位置 LRANGE、大范围扫描都是线性操作,应限制对象规模。
  • compact encoding 节省内存,但对象接近阈值或频繁变大时可能发生转换与内存搬移,引发延迟尖峰。

内存

  • 小 Hash/List 的 listpack 能减少每元素指针和独立分配开销。
  • 一个巨大 Hash 虽减少了顶层 Key 数,却可能成为 BigKey;不能把“Key 少”误解为“内存一定少”。
  • quicklist 的压缩深度越高,通常越省内存,但访问内部节点时需要解压,增加 CPU。
  • 队列必须设置容量治理,例如生产限速、积压告警、过期或溢出策略;Redis 不会替业务自动限制 List 长度。

网络

  • HSET 多字段、HMGET、一次 LPUSH 多元素可以减少网络往返。
  • 跨多个 Key 的批处理可用 Pipeline,但 Pipeline 只优化 RTT,不提供事务原子性。
  • 大 field/value、大批量 HMGET、大范围 LRANGE 可能让网络和客户端反序列化先成为瓶颈。

磁盘与持久化

Hash/List 命令先修改内存数据。AOF、RDB 和复制会影响尾延迟与数据丢失窗口,但“用了 Redis List”不等于“消息已达到专业 MQ 的持久化保证”。队列可靠性必须结合 AOF 策略、主从复制、故障转移和业务幂等一起评估。

7.2 高并发

原子性边界

  • 单个 HINCRBYLPUSHRPOPLMOVE 命令在 Redis 端原子执行。
  • “扣减库存字段并更新总价字段”“把任务移动并同时写入多个外部状态”不是单命令原子操作,需要 Lua、事务、数据库约束或补偿。
  • 客户端超时不代表命令一定没执行。盲目重试非幂等写入可能造成重复计数或重复任务。

热点与分片

  • 一个大 Hash 无论有多少 field,在 Cluster 中仍是一个 Key、一个 Slot,无法通过 field 自动分片。
  • 一个全局 List 队列同样可能成为单分片热点。高吞吐时可按业务、租户或分区拆队列,但需要接受全局顺序被打散。
  • BLPOP 同时监听很多 Key 会增加检查成本,也会让优先级和公平性更难解释。

阻塞连接与连接池

go-redis 的 Client 可并发复用,但每个正在执行 BLPOP/BRPOP/BLMOVE 的 goroutine 在等待期间需要一个底层连接。若消费者数接近或超过 PoolSize,普通 GET/HGET 请求也可能拿不到连接。

常见做法是:

  • 为阻塞消费者配置独立 Client 或独立连接池。
  • 设置有限的 Redis 阻塞超时,周期性检查 Context 与关闭信号。
  • 让 Context Deadline 略长于命令的 block timeout。
  • 监控连接池等待次数、超时、空闲连接和消费者重连风暴。

7.3 高可用

主从切换与数据窗口

Redis 复制通常是异步的。主节点确认 LPUSHBLMOVE 后,如果数据尚未复制到新主节点就发生故障转移,已经确认的写入仍可能丢失。AOF 也不能消除所有故障窗口。

阻塞命令的客户端行为

主从切换、网络断开或节点重启会打断阻塞连接。消费者必须:

  • 识别网络错误并重新连接、重新发起阻塞命令。
  • 不把“没有收到响应”简单等同于“命令没有执行”。
  • 对已处理任务采用幂等键或数据库唯一约束。

例如 BLMOVE 已在服务端完成,但响应在网络中丢失,客户端会看到错误;任务可能已经位于 processing。这正是需要回收、对账和幂等的原因。

Cluster 约束

LMOVE/BLMOVE 是多 Key 命令。源和目标必须在同一 Slot,推荐使用:

queue:{mail}:ready
queue:{mail}:processing
queue:{mail}:dead

Hash Tag 保证相关 Key 同槽,但也意味着这些 Key 集中到同一分片。可用性和可扩展性之间需要按队列分区做权衡。

8. 常见错误与生产事故

8.1 对大 Hash 执行 HGETALL 导致延迟飙升

  • 现象:Redis P99/P999 突增,网络出口放大,客户端出现超时;慢日志中出现 HGETALL
  • 根因:某 Hash 已有数十万字段,HGETALL 在服务端遍历并返回全部内容,CPU、响应缓冲和网络同时放大。
  • 排查方法:查看 SLOWLOG、命令统计、HLENMEMORY USAGE、网络带宽和客户端响应大小。
  • 修复方案:改为 HMGET 精确取字段;运维遍历使用 HSCAN;拆分 Big Hash。
  • 如何预防:定义单 Hash field 上限,代码评审禁止业务路径无界 HGETALL,建立 BigKey 监控。

8.2 把全站计数塞进一个 Hash,形成 HotKey

  • 现象:Cluster 中一个分片 CPU 很高,其他分片空闲;该 Key 的 QPS 远高于平均值。
  • 根因:虽然 field 很多,但 Redis Cluster 按顶层 Key 分槽,所有 HINCRBY 都命中同一节点。
  • 排查方法:HotKey 采样、按节点命令统计、访问日志聚合、Cluster Slot 定位。
  • 修复方案:按业务维度或 hash bucket 拆成多个 Key,离线汇总;必要时使用本地聚合后批量写入。
  • 如何预防:Key 设计阶段同时评估基数和访问分布,不能只看字段数量。

8.3 把 HSCAN 当作一致性快照

  • 现象:迁移或统计结果偶尔重复、遗漏,总数与 HLEN 对不上。
  • 根因:扫描期间 Hash 仍在变更,游标遍历不提供事务快照;调用方也没有去重或二次校验。
  • 排查方法:确认扫描窗口内是否有写流量,记录 cursor、重复 field 和扫描耗时。
  • 修复方案:让处理幂等;按 field 去重;需要严格快照时在副本、导出快照或业务版本机制上完成。
  • 如何预防:将 HSCAN 定位为增量遍历工具,而不是强一致导出协议。

8.4 升级字段 TTL 后仍被 HSET 意外清除

  • 现象:Redis 7.4+ 中某些 field 原本会过期,更新值后却永久存在;或旧集群报 unknown command HEXPIRE
  • 根因:普通 HSET 覆盖字段会清除字段 TTL;同时不同环境 Redis 版本不一致。
  • 排查方法:执行 INFO server/版本探测、HTTL 检查字段 TTL,审计写入命令。
  • 修复方案:Redis 8.0+ 使用符合语义的 HSETEX/KEEPTTL;Redis 7.4 中写入后重新设置 TTL;旧版本保留多 Key 方案。
  • 如何预防:上线前做能力探测和兼容矩阵,字段写入与 TTL 设置封装在统一数据访问层。

8.5 使用 BRPOP 后消费者崩溃,任务永久丢失

  • 现象:生产端日志显示任务已入队,Redis 中也曾出现,但业务没有执行记录,队列里找不到任务。
  • 根因BRPOP 返回前已把元素删除;消费者在解析或处理过程中崩溃,服务端没有 ACK/Pending 机制。
  • 排查方法:对比生产日志、消费日志和业务幂等表;检查消费者重启时间和错误日志。
  • 修复方案:改用 BLMOVE ready processing,成功后 ACK,并建立超时回收;高要求场景切换 Streams/MQ。
  • 如何预防:明确投递语义,任何不可重建任务都不使用裸 BRPOP

8.6 processing 列表不断增长,ACK 越来越慢

  • 现象:消费者处理成功但 ACK 延迟持续上升,Redis CPU 增高,LLEN processing 不断扩大。
  • 根因:缺少回收和死信;部分 ACK 失败未补偿;LREM 对长 List 是 O(N)。
  • 排查方法:监控 LLEN processing、LREM 延迟、最老任务年龄和 ACK 失败日志。
  • 修复方案:修复 ACK 重试与对账,增加超时回收和死信;限制并发在途量;规模较大时迁移 Streams/MQ。
  • 如何预防:从第一天就把 processing 当作需要 SLO、容量和清理策略的状态表,而非临时垃圾桶。

8.7 阻塞消费者耗尽 go-redis 连接池

  • 现象:队列消费者运行后,普通 GET/HGET 开始报连接池超时;Redis 服务端负载却不高。
  • 根因:每个阻塞命令占用一个连接,消费者 goroutine 数超过池容量;业务请求与阻塞消费者共用同一小连接池。
  • 排查方法:查看 go-redis PoolStats、goroutine 堆栈、阻塞命令数量和连接数。
  • 修复方案:阻塞消费使用独立 Client/Pool;调整并发与 PoolSize;使用有限 block timeout。
  • 如何预防:容量规划时按“并发阻塞消费者 + 普通请求峰值 + 余量”计算连接数。

8.8 BLMOVE 在 Cluster 中出现 CROSSSLOT

  • 现象:单机测试正常,迁移到 Cluster 后 BLMOVE 返回 CROSSSLOT Keys in request don't hash to the same slot
  • 根因readyprocessing Key 的 Hash Tag 不同,落在不同 Slot。
  • 排查方法:使用 CLUSTER KEYSLOT 检查两个 Key。
  • 修复方案:改为 queue:{mail}:readyqueue:{mail}:processing 等同一 Hash Tag。
  • 如何预防:多 Key 命令在 Key 设计阶段就定义 Slot 规则,并写自动化测试。

9. 方案选型与权衡

9.1 对象存储方案比较

维度Hash多个 String Key单个 JSON String
局部字段读写强,HGET/HSET/HMGET强,每字段一个 Key弱,通常需读改写整个对象
顶层 Key 开销
独立 TTLRedis 7.4+ 支持 field TTL;旧版本弱天然支持通常只有整个对象 TTL
独立淘汰/分片弱,整个 Hash 是一个 Key强,可按字段/实体拆分弱,整个 JSON 是一个 Key
多字段原子写单个 HSET 可一次写多个 field需事务/Lua,Cluster 还受 Slot 限制单个 SET 原子替换整个对象
内存效率小 Hash 通常较好顶层 Key 与对象元数据较多与序列化格式、字段名重复有关
复杂结构一般一般较强,但局部更新困难
HotKey 风险整个对象集中可拆散,也可能增加 Key 数整个对象集中
典型选择扁平对象、购物车、计数集合独立 TTL/独立分片优先整体读写、结构较复杂、版本化对象

不存在固定“最佳方案”。对象较小、字段访问明确且希望局部更新时优先 Hash;独立 TTL、独立淘汰和分片更重要时使用多个 Key;始终整对象读写且结构复杂时 JSON String 更简单。

9.2 队列方案比较

维度List + BRPOPList + BLMOVERedis Streams专业消息队列
实现复杂度中,需要 ACK/回收/幂等中,有消费者组和 PEL中到高,需独立基础设施
消费者崩溃恢复弱,弹出后可能丢失可恢复,但需自建超时回收原生 Pending、ACK、Claim通常原生支持
重复投递仍可能发生明显存在,必须幂等至少一次下仍可能重复取决于产品与配置,通常仍需幂等
多消费者组不支持不支持支持通常支持
重试/死信/投递次数自建自建部分需自建,状态更丰富通常能力完整
吞吐与扩展单 List 单 Key 易热点同左,且 ACK O(N)更适合 Redis 内消息流通常更适合大规模消息系统
适用场景可丢、可重建的简单任务中小规模、可接受自建协议Redis 内部事件流、消费者组核心业务异步链路、跨系统消息

10. 高频面试题

10.1 问题:Hash 与多个独立 String Key 应如何选择?

推荐回答

简洁回答:Hash 适合一个实体下的多个扁平字段,能减少顶层 Key 数、支持字段级读写和 HMGET;多个 String Key 更适合独立 TTL、独立淘汰和独立分片。Redis 7.4+ 虽支持 Hash Field TTL,但整个 Hash 仍是一个 Key、一个 Slot,也仍可能成为 HotKey。

展开时还应比较:小 Hash 的紧凑编码、BigKey 风险、Cluster 多 Key 操作、字段级并发更新和源数据库一致性。

面试官追问

Redis 7.4+ 有 field TTL 后是否应全部改成 Hash?Hash 中一个 field 很热会怎样?

常见错误回答

“Hash 一定更省内存”“多个 String 一定性能更差”,忽略对象大小、编码阈值、热点和集群分片。

评分点

  • 初级:能说出 Hash 是 field/value 集合。
  • 中级:能比较 TTL、Key 数、批量读取和 BigKey。
  • 高级:能讨论 field TTL 版本、Cluster Slot、淘汰粒度和热点拆分。

10.2 问题:Hash Field TTL 从哪个版本开始支持?有哪些坑?

推荐回答

Redis 7.4 开始支持 HEXPIREHTTL 等字段级过期命令;Redis 8.0 又增加 HSETEXHGETEXHGETDEL。Key TTL 与 field TTL 是两套层级。普通 HSET 覆盖字段会清除该 field 的 TTL,而 HINCRBY 等原值修改通常保留 TTL。旧版本会对这些命令返回未知命令错误。

面试官追问

字段过期后整个 Hash 会怎样?如何兼容混合版本环境?

常见错误回答

“Hash 一直支持 field TTL”,或把 EXPIRE hash-key 误认为给每个 field 设置独立 TTL。

评分点

  • 初级:知道 Key TTL 与 field TTL 不同。
  • 中级:说出 7.4、8.0 的能力变化。
  • 高级:补充覆盖字段清除 TTL、能力探测和迁移策略。

10.3 问题:Hash 为什么同时需要 listpack 和 hashtable?

推荐回答

小 Hash 使用 listpack,可减少指针和独立分配,连续内存也有较好 Cache 局部性;但查找和修改需要线性扫描或内存搬移,所以只适合受阈值限制的小对象。字段多或值大时转换为 hashtable,用更多内存换取平均 O(1) 查找和更新。

面试官追问

哪些配置影响转换?Redis 6 与 Redis 8 的紧凑编码有什么变化?

常见错误回答

“Hash 底层永远是哈希表”,或仍只回答 ziplist 而不说明版本。

评分点

  • 初级:知道有两类编码。
  • 中级:能解释内存与 CPU 的权衡。
  • 高级:知道 listpack 替代 ziplist、阈值和字段 TTL 相关实现变化。

10.4 问题:HSCAN 与 HGETALL 有什么区别?

推荐回答

HGETALL 一次返回全部字段,复杂度和响应大小都随 Hash 规模增长,适合有明确小规模上限的对象。HSCAN 用游标分批遍历,降低单次阻塞和响应体,但完整遍历仍是 O(N),COUNT 只是提示,而且遍历期间发生修改时不是一致性快照,调用方应允许重复并按需去重。

面试官追问

HSCAN 会不会漏?如何做严格一致导出?

常见错误回答

“HSCAN 每次固定返回 COUNT 个元素”“HSCAN 是快照且绝不重复”。

评分点

  • 初级:知道 HSCAN 分批。
  • 中级:知道 COUNT 是提示、完整遍历 O(N)。
  • 高级:能说明并发修改语义、幂等和快照替代方案。

10.5 问题:HINCRBY 能否保证并发安全?

推荐回答

对同一个 field 的一次 HINCRBY,Redis 以单命令原子执行,因此多个客户端并发自增不会发生客户端侧读改写丢失。但它只保证该命令,不保证多个 field 之间的业务不变量。若要同时更新数量、金额和版本号,需要 Lua、WATCH/MULTI 或把强一致状态放回数据库。

面试官追问

客户端超时后能否直接重试 HINCRBY?如何防止重复计数?

常见错误回答

“Redis 单线程,所以整个业务流程都是原子的”,或对超时命令无条件重试。

评分点

  • 初级:知道 HINCRBY 原子。
  • 中级:能说清单命令边界。
  • 高级:讨论超时歧义、幂等 token、Lua 与数据库约束。

10.6 问题:如何用 List 实现栈和 FIFO 队列?

推荐回答

栈可用 LPUSH + LPOP,同端进出形成 LIFO。FIFO 可用 LPUSH + RPOP/BRPOP,也可用 RPUSH + LPOP/BLPOP。方向没有唯一答案,只要生产和消费约定一致。阻塞版本在队列为空时挂起连接,适合减少轮询。

面试官追问

为什么不建议客户端循环 RPOP 加 sleep?阻塞命令是否阻塞 Redis 主线程?

常见错误回答

把左右方向背反,或认为 BLPOP 会让 Redis 命令执行线程一直忙等。

评分点

  • 初级:能给出正确命令组合。
  • 中级:理解阻塞消费减少轮询。
  • 高级:能解释连接占用、超时和服务端阻塞客户端管理。

10.7 问题:List、quicklist 和 listpack 是什么关系?

推荐回答

Redis 8.x 中,小 List 可以直接使用 listpack;变大后使用 quicklist。quicklist 是由多个 listpack 节点组成的双向链表,可对内部节点做压缩。这样比纯链表节省指针和分配开销,也避免把所有元素放在一个巨大连续块中。Redis 6.2 的经典实现以 quicklist 为主且节点处于 ziplist 时代;Redis 7.0 quicklist 节点转为 listpack,Redis 7.2+ 又支持小 List 直接 listpack。

面试官追问

为什么不只用一个 listpack?压缩深度有什么代价?

常见错误回答

“List 永远就是双向链表”“List 永远是 quicklist”,不说明版本和对象大小。

评分点

  • 初级:知道 quicklist。
  • 中级:知道 quicklist 节点内部是 listpack。
  • 高级:能讲清版本演进、分块设计和压缩的 CPU/内存权衡。

10.8 问题:为什么使用 BRPOP 可能丢任务?

推荐回答

BRPOP 是破坏性弹出,元素返回给消费者时已从 Redis List 删除。消费者在反序列化、执行业务或提交数据库前崩溃,Redis 不会自动恢复该元素,所以任务会丢失。它适合可丢、可重建的简单工作,不适合关键交易任务。

面试官追问

网络断开时任务一定没被弹出吗?AOF 能解决这个问题吗?

常见错误回答

“使用阻塞命令就可靠”“开启 AOF 就不会丢消息”。

评分点

  • 初级:知道弹出即删除。
  • 中级:能描述消费者崩溃窗口。
  • 高级:讨论响应丢失歧义、复制故障转移和持久化边界。

10.9 问题:如何用 List 改进任务消费可靠性?

推荐回答

Redis 6.2+ 可使用 BLMOVE ready processing RIGHT LEFT timeout,原子地把任务从待处理队列移到处理中队列。业务成功后用 LREM ACK;失败或消费者崩溃时,回收器根据超时把任务重新入队,并设置最大重试和死信。任务必须有唯一 ID,处理器必须幂等。

面试官追问

回收器怎样知道任务超时?为什么说这仍不是完整消息队列?

常见错误回答

只说 RPOPLPUSHBLMOVE,却没有 ACK、超时回收、死信和幂等。

评分点

  • 初级:知道 ready/processing 两个 List。
  • 中级:补齐 ACK 和回收。
  • 高级:指出 LREM O(N)、状态元数据不足及 Streams/MQ 的替代边界。

10.10 问题:BLMOVE 方案为什么仍可能重复消费?

推荐回答

至少有两个重复窗口:业务处理成功后 ACK 失败;服务端已完成移动或 ACK,但响应在网络中丢失,客户端无法确定最终状态。回收器之后可能重新投递。因此可靠系统不能依赖“绝不重复”,而应以任务 ID、数据库唯一约束、状态机或幂等结果表保证重复执行无副作用。

面试官追问

幂等键放 Redis 还是数据库?幂等记录本身失败怎么办?

常见错误回答

“Redis 命令原子,所以不会重复”,混淆命令原子性和端到端处理语义。

评分点

  • 初级:知道需要幂等。
  • 中级:能指出处理成功、ACK 失败窗口。
  • 高级:讨论外部事务、唯一约束、状态机和对账补偿。

10.11 问题:在 Redis Cluster 中使用 Hash 和 List 有哪些约束?

推荐回答

一个 Hash 无论多少 field 都属于一个顶层 Key,因此全部位于同一 Slot,field 不能自动分片。LMOVE/BLMOVE 涉及两个 Key,必须同 Slot,可通过相同 Hash Tag 保证,例如 queue:{mail}:readyqueue:{mail}:processing。Hash Tag 保证原子多 Key 操作,但也会把相关负载集中到一个分片。

面试官追问

如何扩展一个热点队列?拆分后如何保持全局顺序?

常见错误回答

“Cluster 会把 Hash 的 field 自动分散到不同节点”,或忽略 CROSSSLOT

评分点

  • 初级:知道 Key 按 Slot 分片。
  • 中级:知道多 Key 同 Slot 和 Hash Tag。
  • 高级:讨论分区队列、顺序牺牲、热点迁移与路由设计。

10.12 问题:go-redis 使用阻塞队列时要注意什么?

推荐回答

redis.Client 本身可被多个 goroutine 并发复用,但每个阻塞命令等待时占用一个池连接。应使用有限的 block timeout,让 Context Deadline 略长于它,并根据消费者并发配置连接池;业务请求和阻塞消费常用独立 Client。BRPOP 超时一般按 redis.Nil 处理,网络错误则不能当成“队列为空”。

面试官追问

Context 比 block timeout 更短会怎样?客户端超时后是否能安全重试?

常见错误回答

“Client 并发安全,所以连接池不会耗尽”,或使用 timeout=0 无限阻塞且没有关闭机制。

评分点

  • 初级:会调用 BRPop 并处理 redis.Nil
  • 中级:理解 Context、block timeout 和连接池。
  • 高级:能分析命令执行歧义、独立池、优雅关闭和故障重连。

11. 一分钟面试回答

Hash 适合一个实体下的扁平字段,例如用户对象、购物车和分组计数器,常用 HSETHGETHMGETHINCRBYHSCAN。与多个 String Key 相比,它能减少顶层 Key 并支持局部字段操作,但整个 Hash 仍是一个 Key、一个 Cluster Slot,也可能形成 BigKey 或 HotKey。Redis 7.4 才引入 Hash Field TTL,Redis 8.0 又增加 HSETEXHGETEX 等组合命令;普通 HSET 覆盖字段会清除字段 TTL。底层上,小 Hash 使用 listpack,大 Hash 转为 hashtable。List 支持双端操作,可用同端进出实现栈,用左右两端配合实现 FIFO,并通过 BLPOP/BRPOP 阻塞消费。裸 pop 在消费者崩溃时会丢任务;Redis 6.2+ 可用 BLMOVE 把任务原子移到 processing,再 ACK、超时回收和幂等,但仍缺少消费者组和原生 Pending 元数据,高可靠场景更适合 Streams 或专业 MQ。现代 Redis 中,小 List 可直接 listpack,大 List 使用由多个 listpack 节点组成的 quicklist。Go 侧要正确处理 redis.Nil、Context 和超时,并为阻塞消费者规划独立连接池。

12. 本章总结

  1. Hash 的价值在于“一个实体下的字段级操作”,而不是简单地把 Key 数量压到最少。
  2. Redis 7.4+ 的 field TTL 改善了字段独立过期能力,但没有改变 Hash 作为一个顶层 Key 的分片、热点和淘汰边界。
  3. 小 Hash 的 listpack 用紧凑内存换取线性扫描;大 Hash 的 hashtable 用更多空间换取平均 O(1) 访问。
  4. List 的左右端组合可以实现栈、FIFO 和时间线,但方向必须在团队内统一。
  5. BLPOP/BRPOP 解决的是等待与分发,不自动提供 ACK、崩溃恢复和端到端可靠性。
  6. BLMOVE + processing + ACK + 回收 + 幂等 能改进可靠性,但复杂度和 LREM 线性成本决定了它只适合有限规模。
  7. Redis 8.x 的 List 不能再简单概括为“永远是 quicklist”:小 List 可直接 listpack,大 List 才使用 quicklist。
  8. 高可用并不意味着无数据丢失。复制、持久化、故障转移、客户端重试和业务幂等必须整体设计。

13. 自测清单

  1. 为什么一个 Hash 的 field 再多,也不能在 Redis Cluster 中自动分散到多个 Slot?
  2. Redis 7.4 与 Redis 8.0 在 Hash Field TTL 能力上分别增加了什么?
  3. 普通 HSETHINCRBY 对已有 field TTL 的影响有什么不同?
  4. HMGET 中字段缺失与 HGET 返回 redis.Nil 在 Go 中应如何分别处理?
  5. 为什么 HSCAN 不应被当成严格一致快照?
  6. listpack 与 hashtable 分别适合什么规模的 Hash,转换的根本原因是什么?
  7. 如何用 List 分别实现 LIFO 栈和 FIFO 队列?
  8. 为什么 BRPOP 消费者崩溃后会丢任务?
  9. 一个完整的 BLMOVE 可靠性改进方案至少需要哪些配套机制?
  10. 为什么 go-redis Client 并发安全仍不代表阻塞消费者不会耗尽连接池?

14. 官方资料

  1. Redis Open Source 下载与当前版本
  2. Redis Hashes 数据类型
  3. Redis Lists 数据类型
  4. HSET 命令
  5. HGET 命令
  6. HMGET 命令
  7. HINCRBY 命令
  8. HSCAN 命令
  9. HEXPIRE 命令(Redis 7.4+)
  10. HSETEX 命令(Redis 8.0+)
  11. HGETEX 命令(Redis 8.0+)
  12. BLPOP 命令
  13. BRPOP 命令
  14. LMOVE 命令
  15. BLMOVE 命令
  16. Redis 8.8 Hash 源码:t_hash.c
  17. Redis 8.8 List 源码:t_list.c
  18. Redis 8.8 quicklist 源码:quicklist.c
  19. Redis 6.2 List 源码:t_list.c
  20. Redis 7.0 List 源码:t_list.c
  21. Redis 7.2 List 源码:t_list.c
  22. go-redis Hash 命令实现
  23. go-redis List 命令实现
  24. go-redis Client 并发安全说明