Redis Streams 消费组把新条目分配给具名消费者,并记录尚未确认的投递。它不保证业务副作用恰好发生一次,不会阻止保留策略删除数据,也不会自行恢复被遗弃的任务。将其用作工作队列前,应区分组的读取位置、待处理条目与流里实际保留的数据。

读取位置与待处理任务不同

XGROUP CREATE jobs workers 0-0 从现有历史之前起步;使用 $ 则从当前末端之后开始。同组成员分担新投递,不同消费组拥有各自独立的投递状态。并发工作进程应使用不同的消费者名字。最后投递位置属于整个组,并不是每个消费者分别提交一个偏移。

未使用 CLAIM 或 NOACK 的普通 XREADGROUP 以 > 请求新条目,投递后将它们记入待处理列表 PEL。组的位置在新条目投递时就推进,发生在 XACK 之前。XPENDING 统计的是已经投递但尚未确认的条目,不是从来没有投递过的积压。因此,待处理计数为零时,组里仍可能有大量未读工作。

XACK 把指定 ID 从该组 PEL 移除,不删除流里的记录,也不推进最后投递位置。确认可以乱序完成,其他组依然可以读取保留下来的条目。XREADGROUP 使用 0-0 时读取的是这个具名消费者自己的待处理历史,不是在请求新工作,也不会把另一位消费者名下的待处理消息交给它。

恢复需要显式决策

反复执行普通 > 读取,不会重试被遗弃的 PEL 条目。XAUTOCLAIM 能把满足条件的待处理条目转给请求方,参数必须包含流、组、消费者、最小空闲毫秒数和起始 ID。之后应使用返回游标继续扫描,直到返回 0-0。某一批认领结果为空,并不代表扫描已经完成;过一段时间再次扫描,原来尚未达到空闲阈值的条目也可能变得可认领。

Redis 8.4 还引入了 XREADGROUP CLAIM,可以显式把认领恢复与读取组合起来,本实验没有启用它。这里将 XAUTOCLAIM 空闲阈值设为零,只为得到确定的本地观测;生产阈值必须结合正常处理时长与存活判断策略。转移归属无法让仍在运行的旧工作进程停止,更不能撤回它已经产生的外部副作用。

坏掉的样子

如果外部操作成功后、确认之前发生崩溃,消息仍可能被重新投递,再次处理就会重复副作用。提前确认又可能丢掉必须执行的工作。应使用稳定业务标识,在条件允许时通过事务完成去重;投递计数反映投递尝试,不是数据库成功提交的次数。需要限制重试,并在结束失败任务的跟踪前保留其载荷,便于后续处理。

PEL 引用不会锁住流里的载荷。XDEL 和默认 KEEPREF 裁剪都可能删除内容,同时留下待处理 ID。Redis 7 及更新版本的 XAUTOCLAIM 会报告并清理这种缺失条目,但这是清理记录,不是恢复原文。保留策略必须考虑消费者停机窗口,流的持久化与复制也需要单独作出可靠性决策。

应同时观察未读滞后、待处理数量、空闲时间和重复投递。任何单个计数都不足以说明队列健康。NOACK 会让新投递跳过待处理跟踪,需要依赖该跟踪恢复时就不适合使用。即使流 ID 保持有序,并行消费者的业务完成顺序仍可能与写入顺序不同,不能把分担任务解释成串行执行。

快速验证

原有实验记录使用 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 代码。程序在 /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:
    for entry_id in ("1-0", "2-0", "3-0"):
        client.xadd("jobs", {"job": entry_id}, id=entry_id)
    client.xgroup_create("jobs", "workers", id="0-0")
    client.xgroup_create("jobs", "tail", id="$")
    assert client.xpending("jobs", "workers")["pending"] == 0
    assert client.xreadgroup("tail", "observer", {"jobs": ">"}) == []
    first = client.xreadgroup("workers", "w1", {"jobs": ">"}, count=1)[0][1]
    second = client.xreadgroup("workers", "w2", {"jobs": ">"}, count=1)[0][1]
    assert first[0][0] == b"1-0" and second[0][0] == b"2-0"
    assert client.xpending("jobs", "workers")["pending"] == 2
    group = next(item for item in client.xinfo_groups("jobs") if item["name"] == b"workers")
    assert group["last-delivered-id"] == b"2-0"
    print("backlog starts with PEL=0; new deliveries split 1-0/2-0 and advance the group position")

    effects = [first[0][0]]
    assert client.xack("jobs", "workers", "2-0") == 1
    assert client.xlen("jobs") == 3
    group = next(item for item in client.xinfo_groups("jobs") if item["name"] == b"workers")
    assert group["last-delivered-id"] == b"2-0"
    assert client.xreadgroup("workers", "w2", {"jobs": "0-0"}) == [[b"jobs", []]]
    assert client.xreadgroup("workers", "w1", {"jobs": "0-0"})[0][1] == first
    third = client.xreadgroup("workers", "w2", {"jobs": ">"})[0][1]
    assert third[0][0] == b"3-0"
    assert client.xack("jobs", "workers", "3-0") == 1
    assert client.xreadgroup("workers", "w2", {"jobs": ">"}) == []
    print("XACK keeps stream data and read position; 0-0 reads own pending; > does not recover w1")

    cursor, claimed, deleted = client.xautoclaim(
        "jobs", "workers", "w2", min_idle_time=0, start_id="0-0", count=10,
    )
    assert cursor == b"0-0" and claimed == first and deleted == []
    pending = client.xpending_range("jobs", "workers", "-", "+", 10)
    assert len(pending) == 1 and pending[0]["consumer"] == b"w2"
    assert pending[0]["times_delivered"] >= 2
    effects.append(claimed[0][0])
    assert effects.count(b"1-0") == 2
    assert client.xack("jobs", "workers", "1-0") == 1
    assert client.xack("jobs", "workers", "1-0") == 0
    assert client.xpending("jobs", "workers")["pending"] == 0
    assert client.xlen("jobs") == 3
    print("explicit claim changes owner; simulated side effect repeats; ACK clears PEL, not the stream")

    client.xadd("jobs", {"job": "4-0"}, id="4-0")
    assert client.xreadgroup("tail", "observer", {"jobs": ">"})[0][1][0][0] == b"4-0"
    assert client.xreadgroup("workers", "w1", {"jobs": ">"})[0][1][0][0] == b"4-0"
    assert client.xdel("jobs", "4-0") == 1
    assert client.xpending("jobs", "workers")["pending"] == 1
    cursor, claimed, deleted = client.xautoclaim(
        "jobs", "workers", "w2", min_idle_time=0, start_id="0-0", count=10,
    )
    assert cursor == b"0-0" and claimed == [] and deleted == [b"4-0"]
    assert client.xpending("jobs", "workers")["pending"] == 0
    print("a $ group starts after old entries; XDEL loses pending payload; claim reports deleted ID")

workers 组开始时有三个未读条目,待处理数量却是零。两次读取分别分配 1-0 和 2-0,在任何确认之前,组位置已经到达 2-0。确认第二条后,流长度与该位置保持不变。读取待处理历史可以区分两个消费者;再次请求新消息得到的是 3-0,不会替第一位消费者重试 1-0。

effects 列表模拟已经完成操作但未确认的情况。显式认领把 1-0 交给 w2,再处理一次就记录了两次副作用。这是在模拟重复业务处理,不是具有事务安全性的实现。随后确认清空 PEL,流中的三条原始记录仍然存在,说明待处理状态与消息内容不是同一份生命周期。

tail 组只会看到后来追加的 4-0。该条目仍处于待处理时将其删除,之后认领返回的是已删除 ID 列表,没有载荷可供重试。检查覆盖命令层面的状态变化,没有验证进程崩溃检测、自动重试调度、持久化去重或故障切换恢复。这些边界决定了它不能被直接当成一套完整的生产消费协议。

来源

  • Redis XGROUP CREATE,消费组起始位置,已核验 2026-09-08。
  • Redis XREADGROUP,新消息与待处理读取、NOACK 和可选 CLAIM 行为,已核验 2026-09-08。
  • Redis XPENDING,待处理归属与投递次数,已核验 2026-09-08。
  • Redis XACK,确认与待处理列表移除,已核验 2026-09-08。
  • Redis XAUTOCLAIM,恢复游标、空闲阈值与已删除条目,已核验 2026-09-08。
  • Redis XTRIM,保留与消费组引用策略,已核验 2026-09-08。
  • Redis 持久化,RDB 与 AOF 的持久性取舍,已核验 2026-09-08。
  • Redis 8.10.1 构建说明,仅构建核心与分配器选择,已核验 2026-09-08。