Redis 流把条目保存在有序 ID 下,读者可以重新读取尚未删除的历史。与发布订阅不同,生产者追加时读者不必在线。这个差别提供的是回放能力,不是无条件持久性:条目删除、保留限制及持久化设置仍决定延迟或故障后究竟还剩下什么。

区分写入顺序与事件时间

XADD 使用 * 时生成包含毫秒部分和序列部分的 ID。在同一条流内,ID 单调递增,Redis 必要时会补偿本机时钟回退。显式提供的 ID 也必须大于这条流最后生成的 ID。比较时应比较两个数值部分,不能把任意长度的字符串字典序当成数值顺序。

ID 排定的是写入该流的顺序,不会建立多条独立流之间的全局顺序,也不证明外部事件真正发生的时间。需要业务时间时,应单独保存相应字段。两次普通 XADD 即使字段完全相同,也会创建两个不同条目:自动 ID 标识记录,并不自动识别重复业务操作。

保存最后处理的位置

XRANGE 默认包含起止两端。把已经处理过的最后一个 ID 作为起点,会再次返回该条目;在边界前加左圆括号可以排除它。XREAD 则返回严格大于指定 ID 的条目。配合 COUNT 分批读取时,应保存最后返回的 ID,下次从这个位置继续。

普通 XREAD 不会替每个读者维护服务端偏移,也不会移除已投递条目。独立读者可以读取同一范围,重复请求也会重复返回仍被保留的数据。检查点由应用负责:副作用之前保存位置,故障后可能跳过尚未执行的工作;副作用之后保存,故障后可能重复已经完成的工作。不能接受这类差异时,需要把位置推进与幂等处理协调起来。

美元符号 ID 表示从 Redis 处理这次调用时的流末端之后开始。它适合首次进入实时订阅,不适合恢复已有进度。每次响应后继续用 $,会跳过两次调用之间追加的条目。收到数据后应使用真实的最后一个 ID。消费组另行增加投递与确认状态;XACK 并不是普通 XREAD 的偏移提交命令。

坏掉的样子

慢读者可能落后于保留窗口。XTRIM MAXLEN 删除最旧、ID 最小的条目,留下较新的内容,不是裁掉最新的一端。使用默认引用策略时,精确 MAXLEN 执行数量限制;近似裁剪可能为了效率多保留条目。ACKED 策略还可能为了保留未确认条目而超过这个数量。redis-py 的 xtrim 默认使用近似模式,因此实验显式将其关闭。

XDEL 可以删除指定记录。不能因为某个消费者还没有确认,就认为删除或很短的保留窗口不会丢数据。默认 KEEPREF 裁剪可能留下待处理引用,却删除对应载荷。Redis 8.2 新增了包括 ACKED 在内的显式引用策略,但保留策略仍由应用决定。已经保存的 ID 不能重建被删除的数据。

流也是 Redis 数据,会受 RDB/AOF 设置、复制行为、键删除及可能的内存淘汰影响。一次追加成功,不能证明故障切换无损或业务恰好执行一次。应结合消费者可能离线的时间和追赶能力设置保留范围,监控滞后,并用实际持久化配置演练恢复。本实验特意关闭持久化,不承诺重启后数据仍然存在。

快速验证

需要 POSIX 环境、C 编译器、make、curl、tar,以及 Python 3.10 或更新版本。此前记录的执行环境是 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 代码。程序在 /tmp 下创建私有 Unix socket,关闭 TCP、定时快照和追加日志,最后只终止自己启动的进程并清理运行目录。下载依赖需要网络,不会连接默认端口上的本机 Redis。

from concurrent.futures import ThreadPoolExecutor

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()


def ids(entries):
    return [entry[0] for entry in entries]


with instance() as client:
    for entry_id in ("1-0", "1-1", "2-0"):
        assert client.xadd("events", {"value": entry_id}, id=entry_id) == entry_id.encode()
    try:
        client.xadd("events", {"value": "duplicate"}, id="1-1")
    except redis.ResponseError as error:
        assert "equal or smaller" in str(error)
    else:
        raise AssertionError("XADD must reject an ID below the stream's last ID")
    generated = [client.xadd("auto", {"value": "same"}) for _ in range(2)]
    assert tuple(map(int, generated[0].split(b"-"))) < tuple(map(int, generated[1].split(b"-")))
    assert client.xlen("auto") == 2
    print("explicit IDs advance; duplicate/lower ID rejected; equal payloads get distinct auto IDs")

    assert ids(client.xrange("events", "1-1", "+")) == [b"1-1", b"2-0"]
    assert ids(client.xrange("events", "(1-1", "+")) == [b"2-0"]
    first = client.xread({"events": "0-0"}, count=2)
    second = client.xread({"events": "0-0"}, count=2)
    assert first == second and ids(first[0][1]) == [b"1-0", b"1-1"]
    saved = first[0][1][-1][0]
    remaining = client.xread({"events": saved})
    assert ids(remaining[0][1]) == [b"2-0"]
    saved = remaining[0][1][-1][0]
    print("XRANGE includes its start; XREAD excludes the saved ID; independent reads replay")

    client.xadd("events", {"value": "between-calls"}, id="3-0")
    assert client.xread({"events": "$"}, block=100) == []
    assert ids(client.xread({"events": saved})[0][1]) == [b"3-0"]

    def read_live():
        with client.client() as connection:
            connection.client_setname("stream-lab-reader")
            return connection.xread({"events": "$"}, block=1000)

    with ThreadPoolExecutor(max_workers=1) as worker:
        future = worker.submit(read_live)
        deadline = time.monotonic() + 1
        while not any(
            entry["name"] == "stream-lab-reader" and "b" in entry["flags"]
            for entry in client.client_list()
        ):
            assert time.monotonic() < deadline, "reader never blocked"
            time.sleep(0.01)
        client.xadd("events", {"value": "live"}, id="4-0")
        assert ids(future.result(timeout=2)[0][1]) == [b"4-0"]
    print("$ skips existing entries; a saved ID catches up; observed blocking receives a new entry")

    assert client.xdel("events", "2-0") == 1
    assert client.xrange("events", "2-0", "2-0") == []
    assert client.xtrim("events", maxlen=2, approximate=False) == 2
    assert ids(client.xrange("events")) == [b"3-0", b"4-0"]
    assert ids(client.xread({"events": "0-0"})[0][1]) == [b"3-0", b"4-0"]
    print("XDEL removes a record; exact MAXLEN keeps the newest two; old IDs cannot restore deleted data")

显式 ID 形成可预测的三条记录。再次提交更旧的 ID 会失败,而两次自动追加相同载荷仍得到递增且不同的 ID。边界断言展示 XRANGE 会包含 1-1,XREAD 则严格从它之后继续。两个从零开始的独立读取返回完全相同的数据,没有任何一方把数据从流里消费掉。

在 $ 调用之前追加的条目被跳过,该调用最终超时;从已保存 ID 读取则可以找回它。实时读取部分先等待 CLIENT LIST 报告具名读者确实处于阻塞状态,再追加 4-0。断言检查收到的记录,而不是要求调度器在某个精确毫秒内完成唤醒。

最后,XDEL 删除 2-0,精确 MAXLEN 只保留 3-0 和 4-0。此时再从零读取,也只能取得这两条仍然存在的记录。结果说明了 ID 顺序、游标边界、实时等待以及保留策略导致的丢失;没有验证崩溃持久性、消费组恢复、跨流顺序或生产吞吐。读取能够成功,不代表历史仍然完整。

来源

  • Redis XADD,条目 ID 与单调递增顺序,已核验 2026-10-15。
  • Redis XREAD,排除已保存位置、阻塞与美元符号 ID,已核验 2026-10-15。
  • Redis XRANGE,包含及排除范围边界,已核验 2026-10-15。
  • Redis XTRIM,保留限制与待处理引用策略,已核验 2026-10-15。
  • Redis XDEL,显式删除保留的条目,已核验 2026-10-15。
  • Redis 持久化,RDB 与 AOF 的持久性取舍,已核验 2026-10-15。
  • Redis 8.10.1 构建说明,仅构建核心与分配器选择,已核验 2026-10-15。