Redis 字符串是在一个键下保存的一段二进制安全字节序列,可以装文本、序列化文档,也可以装十进制计数器。这里的“基础类型”描述的是对外的数据模型,不代表所有 Redis 数据结构都由字符串拼接实现。哈希、列表等类型有各自的命令、内部表示和更新规则,不能只凭字符串命令推断其他类型的行为。

先确定字节,再解释内容

SET 与 GET 能原样存取字节,包括零字节和不是有效 UTF-8 的内容。STRLEN 返回字节数,GETRANGE 的起止位置也是字节偏移,而且两端都包含在结果中,并不是按人眼看到的字符切片。因此,一个范围完全可能只取到某个多字节字符的一部分。客户端是否自动把返回值解码成文本,是另一层选择;任意二进制数据应关闭自动解码,只对应用明确知道编码的内容进行转换。

把 JSON 文本存进去,不会让这个键自动变成 Redis 哈希,也不会让哈希命令获得按字段访问 JSON 的能力。同样,整数命令是在执行时解释现有字节,并没有给这个键设置永久的数字类型约束。序列化方式、键名约定和读写双方对值类型的理解,都需要应用自己维护。文档给出的单个字符串默认上限是 512 MiB,但上限不是推荐负载大小,更不应该拿它作为每个缓存对象的设计目标。

原子自增有明确边界

INCR 把不存在的键当作零,再对十进制有符号六十四位整数执行加一。不能解释为整数的内容和越界运算都会报错,而不是回绕。INCRBY 用来改变步长;INCRBYFLOAT 遵循另一套浮点语义,如果需要精确的整数序列,就不能把它当作等价替代。

一条 INCR 消除了先 GET、再计算、最后 SET 之间的丢失更新窗口。不过,服务器已经完成自增而响应在途中丢失时,客户端重试仍可能再加一次。命令原子性不等于请求幂等,也不意味着经历删除、到期、复制故障或主从切换后还能提供持久且全局唯一的编号。下面的并发实验只验证一个正在运行的实例,不给这些跨故障场景背书。

坏掉的样子

对 JSON 字符串调用 HGET 会得到 WRONGTYPE。普通 SET 则有相反的风险:它能够把已有哈希整体替换成字符串,原来的字段随之丢失。因此需要按值的契约区分键名空间,并检查所有写入方实际使用的命令。某个键过去存过哈希,并不意味着这个名字以后会被 Redis 强制约束为哈希。

成功执行普通 SET 还会移除该键原来的过期时间。首次写值和配置有效期属于同一业务操作时,用 SET EX 或 SET PX 一起提交;明确需要继承已有截止时间时,使用 SET KEEPTTL。先 SET、再 EXPIRE 是两条命令,中间崩溃就会留下没有期限的值,不能把事后补过期时间说成等价的原子写入。

大对象会增加传输字节、客户端缓冲,以及复制或修改其内容的命令成本。评估时要看最大值的尺寸和更新频率,而不只是统计有多少个键。如果文档中的属性各自独立变化,哈希可能更符合读写需求;这并不是说所有哈希一定更省内存,或所有字符串读取都会慢。选择要落在实际操作粒度上,而不是类型名称上。

快速验证

准备带有 C 编译器、make、curl、tar 和 Python 3.10 或更新版本的 POSIX 环境。此前记录的执行环境是 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮没有重新运行。下面在临时目录编译使用 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 监听、定时快照和追加日志。结束时关闭客户端、终止自己创建的进程,并删除本次临时运行目录,不会连接或清空默认本机实例。下载依赖需要网络,运行实验本身不需要。

from concurrent.futures import ThreadPoolExecutor
from threading import Barrier

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:
    payload = b"\x00\xff" + "\u8336".encode("utf-8")
    client.set("binary", payload)
    assert client.get("binary") == payload
    assert client.strlen("binary") == 5
    assert client.getrange("binary", 2, 2) == b"\xe8"
    assert client.getrange("binary", 2, 4).decode("utf-8") == "\u8336"
    print("binary round trip: 5 bytes; GETRANGE addresses bytes")

    start = Barrier(4)

    def increment(_):
        start.wait(timeout=5)
        return [client.incr("counter") for _ in range(50)]

    with ThreadPoolExecutor(max_workers=4) as workers:
        batches = list(workers.map(increment, range(4)))
    values = sorted(value for batch in batches for value in batch)
    assert values == list(range(1, 201))
    assert client.get("counter") == b"200"
    print("4 workers: 200 INCR replies, exactly the integers 1 through 200")

    for key, value in [("invalid", b"3.5"),
                       ("overflow", b"9223372036854775807")]:
        client.set(key, value)
        try:
            client.incr(key)
        except redis.ResponseError:
            pass
        else:
            raise AssertionError("INCR must reject this value")
        assert client.get(key) == value
    print("invalid integer and signed 64-bit overflow leave values unchanged")

    client.set("document", b'{"score": 1}')
    assert client.type("document") == b"string"
    try:
        client.hget("document", "score")
    except redis.ResponseError as error:
        assert "WRONGTYPE" in str(error)
    else:
        raise AssertionError("JSON bytes must not become a Redis hash")
    client.hset("object", mapping={"score": 1})
    client.set("object", b"replacement")
    assert client.type("object") == b"string"
    print("JSON is still a string; SET can replace a hash with a string")

    client.set("lease", b"A", px=60000)
    deadline = client.pexpiretime("lease")
    client.set("lease", b"B", keepttl=True)
    assert client.pexpiretime("lease") == deadline
    client.set("lease", b"C")
    assert client.ttl("lease") == -1
    print("SET PX assigns expiry; KEEPTTL keeps it; plain SET removes it")

第一组断言确认五个字节可以原样往返。GETRANGE 能从占三个字节的汉字中单独取出一个字节;选中完整的三个字节后,才能按 UTF-8 还原该字符。随后四个工作线程同步起跑,合计拿到从一到二百的全部整数,最后存储值为二百。校验的是排序后的完整结果集合,不假定并发响应按大小顺序到达。

两次被拒绝的自增都没有改变原值。JSON 始终保持字符串类型,普通 SET 则确实把另一个哈希替换成了字符串。最后,KEEPTTL 保留绝对截止时间,普通 SET 使 TTL 变成 -1。这些结果对应字节处理、整数校验、类型替换和过期语义;它们既不是吞吐量基准,也不能证明自动重试安全或崩溃后编号不会重复。

来源

  • Redis 字符串,二进制值、字节级命令和默认大小限制,已核验 2026-10-15。
  • Redis INCR,缺失键、整数校验和计数器语义,已核验 2026-10-15。
  • Redis SET,类型替换与过期选项,已核验 2026-10-15。
  • Redis 8.10.1 构建说明,只编译核心及分配器选择,已核验 2026-10-15。