Redis 哈希把多个字段名及其字节值放在一个键下,适合按属性独立读取或修改的对象,例如拥有姓名和积分的用户资料。HGET 与 HSET 各自都只需要一条命令,并不会因为多了一层字段组织,就天然比字符串多一次网络往返。类型选择的依据是实际访问粒度,而不是把内部结构想象成额外的远程查询。

只读取和更新目标字段

HGET 获取一个字段,HMGET 按请求顺序获取指定字段,HGETALL 则返回所有字段和值。Redis 不会自动把某个字段值解释为嵌套对象,不会把带点的字段名当作对象路径,也不会自动为任意属性组合建立查询索引。字段如何命名、值如何序列化,仍然由应用负责。

HSET 可以在一条命令里写多个字段,未指定的字段不会被替换。它的整数返回值统计新增字段数,而不是所有发生变化的值:更新已有字段即使成功,也可以返回零。HDEL 返回实际删除的字段数;最后一个字段删除后,整个哈希键也会消失。因此,不能用一个没有字段的哈希作为长期存在的占位对象。

字段缺失和空值是两种状态。redis-py 关闭自动解码时,HMGET 对缺失字段返回 None,对已存在但内容为空的字段返回空字节串。直接用真假值判断会混淆两者,应按明确的返回值区分。HGETALL 对小对象很方便,但处理量和响应大小会随整个哈希增长;调用方只需要几个属性时,应该直接读取这些字段,避免每次搬运全部数据。

过期时间分为两层

键级过期会删除整个哈希,HSET 会保留该键已有的截止时间。从 Redis 7.4 开始,还可以通过 HEXPIRE 和 HPEXPIRE 给单个字段设置期限。普通 HSET 覆盖字段时会清除这个字段自己的过期时间,HINCRBY 则保留被修改字段的已有期限。字段 TTL 返回 -1,只表示它没有独立的字段期限,不代表它能逃过父键到期后整体删除。

Redis 8.0 及更新版本提供 HSETEX,可以把字段值与字段期限放在同一条命令里写入;其 KEEPTTL 选项能在替换字段值时保留已有字段截止时间。先 HSET、再 HPEXPIRE 仍然是两条操作,中间失败会产生值已经变更、期限尚未附加的窗口。应根据希望保留、重置还是新建期限来选命令,并在采用字段过期能力前核对实际部署版本,不能把键级与字段级期限混为一谈。

坏掉的样子

局部更新可以避免无意覆盖其他属性,却不能让 HGET、本地计算和 HSET 自动合并成一个原子操作。两个客户端可能先读到相同积分,各自加一后写回同一个结果,于是实际少计了一次。HINCRBY 把整数变更放在一条命令内完成,并拒绝非法整数内容。如果业务要求多个字段共同满足某个条件,仍需明确的原子操作协议,单纯把数据放入哈希并不能提供这种保证。

序列化的 JSON 字符串仍是字符串,对它执行 HGET 会报 WRONGTYPE。另一方面,单字段很大或字段数量不受控,也会增加传输和操作成本。一个哈希仍然是一个 Redis 键,不会自动成为跨节点拆分的数据方案。应用始终整块交换载荷时,可以使用不透明字符串;只有确实按属性独立访问,哈希带来的局部读写能力才有实际价值。

快速验证

原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

在同一个终端使用 "$redis_lab/venv/bin/python" 执行下面的 Python 代码。临时 Redis 仅通过 /tmp 下的私有 Unix socket 通信,不开放 TCP,不安排快照,也不启用追加日志。程序退出时关闭自己的客户端,终止自己创建的进程,再移除临时运行目录。下载依赖需要网络,实验不会访问默认本机 Redis,也不会清理其他实例的数据。

from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance():
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", "noeviction",
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()



with instance() as client:
    assert client.hset("profile", mapping={
        "name": b"Ada", "score": b"0", "empty": b"",
    }) == 3
    assert client.hset("profile", "name", b"Ada L") == 0
    assert client.hmget("profile", ["name", "empty", "missing"]) == [
        b"Ada L", b"", None,
    ]
    assert client.hlen("profile") == 3
    print("HSET reports added fields, not updates; empty bytes differ from None")

    assert client.pexpire("profile", 60000)
    key_deadline = client.pexpiretime("profile")
    assert client.hpexpire("profile", 30000, "name", "score") == [1, 1]
    field_deadlines = client.hpexpiretime("profile", "name", "score")
    assert all(value > 0 for value in field_deadlines)
    assert client.hset("profile", "name", b"Grace") == 0
    assert client.pexpiretime("profile") == key_deadline
    assert client.hpttl("profile", "name") == [-1]
    assert client.hincrby("profile", "score", 1) == 1
    assert client.hpexpiretime("profile", "score") == [field_deadlines[1]]
    print("HSET keeps key TTL but clears its field TTL; HINCRBY keeps field TTL")

    assert client.hsetex("session", mapping={"token": b"A"}, px=60000) == 1
    token_deadline = client.hpexpiretime("session", "token")
    assert token_deadline[0] > 0
    assert client.hsetex("session", mapping={"token": b"B"}, keepttl=True) == 1
    assert client.hget("session", "token") == b"B"
    assert client.hpexpiretime("session", "token") == token_deadline
    print("HSETEX writes field value and TTL together; KEEPTTL preserves its deadline")

    client.hset("race", "score", 0)
    a = int(client.hget("race", "score"))
    b = int(client.hget("race", "score"))
    client.hset("race", "score", a + 1)
    client.hset("race", "score", b + 1)
    assert client.hget("race", "score") == b"1"
    client.hset("race", "score", 0)
    assert client.hincrby("race", "score", 1) == 1
    assert client.hincrby("race", "score", 1) == 2
    print("controlled stale-read schedule loses an update; two HINCRBY calls yield 2")

    client.hset("bad", "score", b"3.5")
    try:
        client.hincrby("bad", "score", 1)
    except redis.ResponseError:
        pass
    else:
        raise AssertionError("HINCRBY requires an integer")
    assert client.hget("bad", "score") == b"3.5"
    client.set("blob", b'{"name": "Ada"}')
    try:
        client.hget("blob", "name")
    except redis.ResponseError as error:
        assert "WRONGTYPE" in str(error)
    else:
        raise AssertionError("a serialized string is not a Redis hash")
    print("invalid counters and wrong key types are rejected")

    client.hset("single", "field", b"value")
    assert client.hdel("single", "missing") == 0
    assert client.hdel("single", "field") == 1
    assert client.exists("single") == 0
    print("HDEL of the last field removes the key")

第一次 HSET 新增三个字段,返回三;第二次成功替换姓名,返回零。HMGET 的结果保留了空字节串与缺失值的区别。比较绝对截止时间可以看到,修改姓名保留了整键期限,却移除了姓名字段的独立期限;积分自增仍保留原字段期限。HSETEX 则验证了字段值和期限一次写入,以及随后在替换值时保留相同截止时间。

丢失更新部分刻意安排了两个假想客户端的读写交错,是可确定复现的操作顺序,不是多线程并发压测。两次先读后写最终只得到一,两条 HINCRBY 得到二。非法计数值和错误键类型均被拒绝;删除唯一字段后,对应键不再存在。这些断言证明的是命令行为,不是内存效率、吞吐量、集群扩展效果,也不能保证网络失败后重试不会重复计数。

来源

  • Redis 哈希,字段式数据建模与命令行为,已核验 2026-09-08。
  • Redis HSET,局部更新与新增字段计数,已核验 2026-09-08。
  • Redis HEXPIRE,字段期限与覆盖行为,已核验 2026-09-08。
  • Redis HSETEX,字段值和过期时间的原子更新及 KEEPTTL,已核验 2026-09-08。
  • Redis HDEL,删除计数及最后一个字段的清理,已核验 2026-09-08。
  • Redis 8.10.1 构建说明,只编译核心及分配器选择,已核验 2026-09-08。