Skip to content
← 返回Redis列表
数据与原子性

Redis Hash(哈希) · 3. 底层编码与内存至8. Go 客户端与最佳实践 ​

返回分篇目录

本文按 Redis 8.2.10 核对编码与配置默认值;命令的引入版本另行标注。生产配置可能不同,可通过 CONFIG GET 查询。示例面向独立实验服务,运行结果和内存数字未在本次实测。

3. 底层编码与内存 ​

普通小 Hash 使用 listpack,超过字段数或字段/值长度阈值后使用 hashtable。8.2.10 对带字段过期信息的紧凑 Hash 还实现了 listpackex,不能只列两种编码并忽略字段过期的附加开销。

8.2.10 配置默认 hash-max-listpack-entries 512、hash-max-listpack-value 64(字节)。这些是紧凑表示条件,不是 Hash 容量上限;字段名和字段值都要考虑。

编码与内存怎么测 ​

在独立实验实例准备教学键,再读实际状态;下列是测量方法,本次未运行或填写测量数字:

text
HSET learn:types:user name Ada level 3
OBJECT ENCODING learn:types:user
MEMORY USAGE learn:types:user

固定键名、元素字节长度、Redis 版本和分配器,分别记录 1、10、100 个元素或不同值长度时的内存,再跨越编码阈值观察。单元素的增量开销可用相邻两次内存差估计,编码转换时不能套用线性平均;String/Bitmap 的横轴应使用字节数或最高位偏移。比较“一键多元素”和“多个独立键”时,把键名、对象、字典项和 TTL 管理开销一起计入。

MEMORY USAGE 默认对嵌套值采样,SAMPLES 0 会遍历所有嵌套值,仅在小型隔离实验中使用。其结果不等同于进程 RSS;大量键还会增加全局字典、分配器碎片和复制缓冲等成本,需结合 INFO memory。没有适用于所有版本的固定“每 key 几字节”。

依据:8.2.10 配置、对应类型实现、MEMORY USAGE。

4. 性能与阻塞风险 ​

HGETALL、HKEYS、HVALS 会遍历并返回大量数据。只需部分字段就 HMGET,后台遍历优先 HSCAN;扫描不提供一致快照,可能重复,COUNT 不严格限制返回数。紧凑编码在小集合中以线性查找换空间,阈值过大可能放大延迟。

常规命令执行主要占用主执行路径,耗时计算会拖慢同实例其他请求;网络 I/O、后台持久化和释放内存有其他线程/进程参与。命令复杂度不包含所有网络、分配、编码转换与客户端成本。

5. 原子性与并发 ​

HINCRBY 只保证本次加减原子,不保证库存非负。先 HGET 检查再扣减存在竞态;用 Lua 将检查和更新合并,或 WATCH 整个 Hash 再提交事务。Hash 中不相关字段变动也可能使 WATCH 失败;抽卡扣费、保底重置与发奖还需权威业务事务。

MULTI/EXEC 保证排队命令连续执行,但执行时单条报错不会回滚其他已成功命令;WATCH 是乐观冲突检测,需要有界重试。Lua 也不会自动回滚运行错误前的写入,应先校验参数与类型,再修改状态。

6. 过期、持久化与生命周期 ​

EXPIRE 影响整个 Hash;7.4+ 可通过 HEXPIRE/HPEXPIRE 管理字段期限,整个键先到期则字段也消失。HSET 覆盖字段会清除该字段原有过期时间,HINCRBY 修改字段值会保留字段 TTL;它们通常不改变整个键的 TTL。使用 HTTL 分别检查,不把键 TTL 与字段 TTL 混用。

大键会增加 RDB/AOF 重写、复制全量同步和恢复时的处理量,fork 写时复制还可能增加峰值内存。单次同步删除聚合对象可能阻塞;UNLINK 先摘除键,把主要回收工作移到后台,但总回收工作和内存压力仍存在,不能替代限长和拆分。

完成教学实验后,只清理自己创建的键,例如 UNLINK learn:types:user。

7. 集群与扩展 ​

一个玩家的 Hash 在单槽内,字段不会自动分片;不要把全部玩家塞进一个键。需要拆分时按玩家或独立生命周期分键。涉及两个 Hash 的脚本需同槽,但共同 tag 也把流量集中到该槽。读副本的库存可能落后,不能用于扣减授权。

一个键只在一个分片中,hash tag 控制相关键进入同一槽,不会把一个大键切开。副本读取通常是最终一致观察;操作后立即读到本次写入的需求应选主节点或明确的协调策略。

8. Go 客户端与最佳实践 ​

下面函数使用 github.com/redis/go-redis/v9,调用方提供 context.Context 和已配置认证、超时的 *redis.Client。

go
type RedisPlayer struct {
    Name string `redis:"name"`
    Level int64 `redis:"level"`
}

func HashExample(ctx context.Context, rdb *redis.Client) (RedisPlayer, error) {
    if err := rdb.HSet(ctx, "learn:go:user", "name", "Ada", "level", 3).Err(); err != nil {
        return RedisPlayer{}, err
    }
    var player RedisPlayer
    err := rdb.HGetAll(ctx, "learn:go:user").Scan(&player)
    return player, err
}

go-redis 可将 map[string]string 按 redis 标签扫描到 struct,但不是任意嵌套对象的自动序列化。字段缺失、数字解析失败要分别处理;上例只适合小对象,全量 HGetAll 不适合巨型 Hash。可 Pipeline 独立字段操作,但检查后扣减不能靠普通 Pipeline 保证。

普通 Pipeline 只是批量发送,不能充当事务;批次过大也会增加响应缓冲与延迟。对超时后的写入重试,需要根据幂等性判断,不能把“没收到响应”当成“没执行”。

继续阅读 ​

数据类型总览 · 游戏与通用后端实践 · 原子性基础。

参考:Redis 8.2.10 命令定义、go-redis、集群规格。

继续阅读