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 队列、有限时间线和阻塞消费。
本章的重点不是记忆命令,而是建立三层判断:
- 业务模型是否真的匹配 Hash 或 List;
- 命令是否会形成 BigKey、HotKey、阻塞连接或消息丢失;
- 底层编码与 Redis 版本变化会怎样影响内存、复杂度和面试回答。
截至 2026 年 6 月,Redis 官方下载页列出的 Redis Open Source 当前版本为 8.8。本章以 8.x 行为为基线,同时明确 Redis 6.2、7.0、7.2、7.4 和 8.0 的关键差异。
2. 学习目标
完成本章后,应能够:
- 使用 Hash 建模对象、购物车、配置与计数集合,并解释其适用边界。
- 正确使用
HSET、HGET、HMGET、HINCRBY、HSCAN,区分缺失字段与真正异常。 - 比较一个 Hash、多个 String Key 和单个 JSON String 的内存、TTL、并发及集群权衡。
- 说明 Redis 7.4+ Hash Field TTL 与 Redis 8.0+
HSETEX、HGETEX的版本差异。 - 使用 List 构造栈、FIFO 队列、时间线和阻塞队列,并能正确选择左右端方向。
- 解释为什么
BLPOP/BRPOP可能丢任务,以及如何用BLMOVE、处理中队列、ACK、回收和幂等改进。 - 解释 Hash 的 listpack/hashtable 编码,以及 List、quicklist、listpack 的现代关系。
- 在 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 的核心差异
假设要存用户的 name、city、login_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+ | 新增 HEXPIRE、HPEXPIRE、HEXPIREAT、HPEXPIREAT、HTTL、HPTTL、HPERSIST 等 | 可以让同一 Hash 内不同 field 独立过期 |
| Redis 8.0+ | 新增 HSETEX、HGETEX、HGETDEL | 支持“设置字段并设置 TTL”“读取并刷新/移除 TTL”“读取后删除”等更原子的组合操作 |
必须区分两类 TTL:
EXPIRE hash-key 3600设置的是整个 Hash Key 的 TTL。HEXPIRE hash-key 3600 FIELDS 1 field-a设置的是某个 field 的 TTL。
还有两个容易答错的细节:
- 普通
HSET覆盖某个 field 的内容时,会清除该 field 原有的字段级 TTL;但不会因此清除整个 Hash Key 的 Key 级 TTL。 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)
常见组合:
| 模型 | 生产 | 消费 | 顺序 |
|---|---|---|---|
| 栈 | LPUSH | LPOP | 后进先出 LIFO |
| FIFO 队列 | LPUSH | RPOP / BRPOP | 先进先出 |
| FIFO 队列 | RPUSH | LPOP / BLPOP | 先进先出 |
| 最新时间线 | LPUSH | LRANGE 0 N-1 | 最新元素在前 |
| 可靠性改进队列 | LPUSH ready | BLMOVE ready processing RIGHT LEFT | 原子移入处理中队列 |
方向本身没有唯一标准,关键是生产者与消费者约定一致,并在 Key 名称和代码中明确。
3.5 阻塞队列不等于可靠消息队列
BLPOP、BRPOP 在队列为空时会挂起客户端连接;新元素到来或超时后返回。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
}
生产设计还应补齐:
Task.ID全局唯一,业务侧以该 ID 做幂等记录或唯一约束。- 监控
LLEN ready、LLEN processing、最老任务等待时间和失败率。 - 建立超时回收器与死信队列,限制最大重试次数。
- 控制
processing长度,因为LREM的成本随列表长度增长。 - Cluster 中使用同一 Hash Tag,例如
queue:{mail}:ready与queue:{mail}:processing。 - 高可靠需求优先使用 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-entries、hash-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 可能有两种对象级编码:
- 小型 List 直接使用 listpack。
- 超过
list-max-listpack-size等限制后转换为 quicklist。
quicklist 不是“普通双向链表的每个节点只存一个元素”,而是:
quicklist = 双向链表 + 每个节点内部的一段 listpack
这种折中设计同时解决两个极端的问题:
- 纯链表每个元素都带前后指针和独立分配,内存碎片与指针开销高。
- 单个巨大连续压缩块虽然省内存,但中间修改可能搬移大量数据。
quicklist 把元素分块:头尾操作只影响少量节点;每个节点内部仍用 listpack 紧凑存储。根据 list-compress-depth,内部节点还可使用 LZF 压缩,以内存换取解压 CPU;靠近两端的节点通常保持可快速访问。
List 编码的关键版本变化
| 版本 | 典型实现描述 |
|---|---|
| Redis 6.2 | List 对象主要是 quicklist,quicklist 节点使用 ziplist 时代的布局与配置命名 |
| Redis 7.0 | quicklist 节点已使用 listpack,但 List 对象通常仍以 quicklist 为主 |
| Redis 7.2+ | 小 List 可直接使用 listpack,变大后再转 quicklist;Redis 8.x 延续这一思路 |
因此,“List 的底层永远是 quicklist”在现代版本中不够准确;更好的回答是“小 List 可直接 listpack,大 List 使用由多个 listpack 节点组成的 quicklist”。
6.3 命令复杂度与真实成本
| 命令 | 文档级复杂度 | 需要补充的条件 |
|---|---|---|
HGET | O(1) | hashtable 下平均 O(1);小 Hash 的 listpack 内部会线性扫描,但受紧凑编码阈值约束 |
HSET | 每个 field/value 平均 O(1) | 可能触发编码转换、内存扩容或字典 rehash,单次延迟会波动 |
HMGET | O(N) | N 为请求 field 数;响应体和网络成本也随 N 增长 |
HINCRBY | O(1) | 仅保证单字段单命令原子;数值解析失败会报错 |
HSCAN | 单次调用通常 O(1),完整遍历 O(N) | COUNT 是提示;遍历期间修改时不是一致性快照 |
HGETALL | O(N) | N 为全部字段;Big Hash 会同时放大主线程、内存和网络延迟 |
LPUSH/RPUSH | 单元素 O(1) | 一次推入 M 个元素为 O(M),还可能触发 listpack 到 quicklist 转换 |
LPOP/RPOP | 单元素 O(1) | 带 count 返回多个元素时成本随返回数量增长 |
BLPOP/BRPOP | O(K) | K 为检查的 Key 数;阻塞等待本身不忙等,但占用一个客户端连接 |
LMOVE/BLMOVE | O(1) | 两个 Key 的原子移动;Cluster 中必须满足 Slot 约束 |
LRANGE | O(S+N) | S 为到起始位置的遍历距离,N 为返回元素数;深分页和大范围读取昂贵 |
LREM | O(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 高并发
原子性边界
- 单个
HINCRBY、LPUSH、RPOP、LMOVE命令在 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 复制通常是异步的。主节点确认 LPUSH 或 BLMOVE 后,如果数据尚未复制到新主节点就发生故障转移,已经确认的写入仍可能丢失。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、命令统计、
HLEN、MEMORY 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。 - 根因:
ready和processingKey 的 Hash Tag 不同,落在不同 Slot。 - 排查方法:使用
CLUSTER KEYSLOT检查两个 Key。 - 修复方案:改为
queue:{mail}:ready、queue:{mail}:processing等同一 Hash Tag。 - 如何预防:多 Key 命令在 Key 设计阶段就定义 Slot 规则,并写自动化测试。
9. 方案选型与权衡
9.1 对象存储方案比较
| 维度 | Hash | 多个 String Key | 单个 JSON String |
|---|---|---|---|
| 局部字段读写 | 强,HGET/HSET/HMGET | 强,每字段一个 Key | 弱,通常需读改写整个对象 |
| 顶层 Key 开销 | 少 | 多 | 少 |
| 独立 TTL | Redis 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 + BRPOP | List + BLMOVE | Redis 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 开始支持 HEXPIRE、HTTL 等字段级过期命令;Redis 8.0 又增加 HSETEX、HGETEX、HGETDEL。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,处理器必须幂等。
面试官追问
回收器怎样知道任务超时?为什么说这仍不是完整消息队列?
常见错误回答
只说 RPOPLPUSH 或 BLMOVE,却没有 ACK、超时回收、死信和幂等。
评分点
- 初级:知道 ready/processing 两个 List。
- 中级:补齐 ACK 和回收。
- 高级:指出
LREMO(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}:ready 和 queue:{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 适合一个实体下的扁平字段,例如用户对象、购物车和分组计数器,常用 HSET、HGET、HMGET、HINCRBY 和 HSCAN。与多个 String Key 相比,它能减少顶层 Key 并支持局部字段操作,但整个 Hash 仍是一个 Key、一个 Cluster Slot,也可能形成 BigKey 或 HotKey。Redis 7.4 才引入 Hash Field TTL,Redis 8.0 又增加 HSETEX、HGETEX 等组合命令;普通 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. 本章总结
- Hash 的价值在于“一个实体下的字段级操作”,而不是简单地把 Key 数量压到最少。
- Redis 7.4+ 的 field TTL 改善了字段独立过期能力,但没有改变 Hash 作为一个顶层 Key 的分片、热点和淘汰边界。
- 小 Hash 的 listpack 用紧凑内存换取线性扫描;大 Hash 的 hashtable 用更多空间换取平均 O(1) 访问。
- List 的左右端组合可以实现栈、FIFO 和时间线,但方向必须在团队内统一。
BLPOP/BRPOP解决的是等待与分发,不自动提供 ACK、崩溃恢复和端到端可靠性。BLMOVE + processing + ACK + 回收 + 幂等能改进可靠性,但复杂度和LREM线性成本决定了它只适合有限规模。- Redis 8.x 的 List 不能再简单概括为“永远是 quicklist”:小 List 可直接 listpack,大 List 才使用 quicklist。
- 高可用并不意味着无数据丢失。复制、持久化、故障转移、客户端重试和业务幂等必须整体设计。
13. 自测清单
- 为什么一个 Hash 的 field 再多,也不能在 Redis Cluster 中自动分散到多个 Slot?
- Redis 7.4 与 Redis 8.0 在 Hash Field TTL 能力上分别增加了什么?
- 普通
HSET、HINCRBY对已有 field TTL 的影响有什么不同? HMGET中字段缺失与HGET返回redis.Nil在 Go 中应如何分别处理?- 为什么
HSCAN不应被当成严格一致快照? - listpack 与 hashtable 分别适合什么规模的 Hash,转换的根本原因是什么?
- 如何用 List 分别实现 LIFO 栈和 FIFO 队列?
- 为什么
BRPOP消费者崩溃后会丢任务? - 一个完整的
BLMOVE可靠性改进方案至少需要哪些配套机制? - 为什么 go-redis Client 并发安全仍不代表阻塞消费者不会耗尽连接池?
14. 官方资料
- Redis Open Source 下载与当前版本
- Redis Hashes 数据类型
- Redis Lists 数据类型
- HSET 命令
- HGET 命令
- HMGET 命令
- HINCRBY 命令
- HSCAN 命令
- HEXPIRE 命令(Redis 7.4+)
- HSETEX 命令(Redis 8.0+)
- HGETEX 命令(Redis 8.0+)
- BLPOP 命令
- BRPOP 命令
- LMOVE 命令
- BLMOVE 命令
- Redis 8.8 Hash 源码:t_hash.c
- Redis 8.8 List 源码:t_list.c
- Redis 8.8 quicklist 源码:quicklist.c
- Redis 6.2 List 源码:t_list.c
- Redis 7.0 List 源码:t_list.c
- Redis 7.2 List 源码:t_list.c
- go-redis Hash 命令实现
- go-redis List 命令实现
- go-redis Client 并发安全说明