Redis 流不会自动为每个连接维护独立游标。普通 XREAD 使用应用提供的位置;消费组则在服务端保存一份组级最后投递位置,加上待处理记录。混淆这两类进度,可能让重启后的消费者跳过未完成任务,也可能重新执行早已完成的业务操作。
消费组在投递时推进位置
XGROUP CREATE 使用 0-0 时从保留历史之前开始,使用 $ 则从当前末端之后开始。每个组在同一份流记录上维护独立投递状态:账单组可能已投递两条,分析组却只投递一条。同一组内的具名消费者分担新工作,并不是每人都得到每一条消息。
在未启用 CLAIM 或 NOACK 的普通 XREADGROUP 中,> 请求组投递位置之后的条目,并把新投递加入待处理列表 PEL。这一位置在业务处理和确认之前就已经前移。XACK 只移除某一组里指定的待处理 ID,不删除流记录,也不推进该组最后投递 ID。
确认可以乱序到达。第二条完成,不代表第一条已经完成;组的投递位置也可以到达流末端,同时仍有旧条目处于待处理状态。除非应用契约确实保证连续完成,否则不能用最大的已投递 ID 或已确认 ID,反推它之前所有业务都已成功执行。
待处理历史属于具名消费者
XREADGROUP 使用 0-0 时,请求的是当前名字对应的待处理历史,不是随意回放整条流。一个新名字看不到另一位消费者名下的待处理条目。重启时换个名字,只读 >,不会恢复之前遗留的投递;服务可能不断处理新任务,看起来正常运行,旧任务却再也没有推进。
恢复需要读取原消费者的待处理历史,或显式转移符合条件的任务,例如使用 XAUTOCLAIM。空闲阈值必须考虑正常处理时长。当前 Redis 也支持可选的 XREADGROUP CLAIM,本实验未启用。转移归属无法阻止迟到的旧工作进程重复外部动作,因此仍需要与业务风险匹配的去重策略。
可以用 XINFO GROUPS 对比 last-delivered-id、pending 和 lag。lag 估计从组当前位置起仍待投递的条目,不是尚未完成的业务动作。某些重设位置或删除操作后,该指标可能无法计算;空值不等于零。lag 为零而 pending 非零,表示新消息已投递到末端,但仍有投递等待确认。
坏掉的样子
XGROUP SETID 是对组投递位置的主动修改,不是无害的确认修复。完成工作后回退,会让仍保留的已确认条目再次成为新投递候选;向前跳则可能越过未读条目。它不会自动清空既有 PEL,前移或后退也都不会改变另一消费组的位置,必须把这些状态分别检查。
执行重设位置前,需要协调消费者,并理解重新处理会造成哪些业务副作用。用新消费组做回放,隔离的是投递账本;如果调用同一个业务处理器,外部副作用并不因此隔离。确认不是永久禁止再次读取记录的删除标记,不应靠回退位置代替诊断真实的待处理条目。
不同组拥有独立位置,不代表拥有独立保留空间。删除或裁剪共享载荷,会影响所有还需要该内容的组。默认 KEEPREF 裁剪可能留下 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()
def groups(client):
return {group["name"]: group for group in client.xinfo_groups("events")}
with instance() as client:
for entry_id in ("1-0", "2-0", "3-0"):
client.xadd("events", {"event": entry_id}, id=entry_id)
for name in ("billing", "analytics"):
client.xgroup_create("events", name, id="0-0")
billing = client.xreadgroup("billing", "b1", {"events": ">"}, count=2)[0][1]
analytics = client.xreadgroup("analytics", "a1", {"events": ">"}, count=1)[0][1]
assert [entry[0] for entry in billing] == [b"1-0", b"2-0"]
assert [entry[0] for entry in analytics] == [b"1-0"]
state = groups(client)
assert state[b"billing"]["last-delivered-id"] == b"2-0"
assert state[b"analytics"]["last-delivered-id"] == b"1-0"
assert state[b"billing"]["pending"] == 2 and state[b"analytics"]["pending"] == 1
assert state[b"billing"]["lag"] == 1 and state[b"analytics"]["lag"] == 2
print("two groups read overlapping records but retain independent positions and PELs")
assert client.xack("events", "billing", "2-0") == 1
state = groups(client)
assert state[b"billing"]["last-delivered-id"] == b"2-0"
assert state[b"billing"]["pending"] == 1 and state[b"analytics"]["pending"] == 1
assert client.xreadgroup("billing", "b2", {"events": "0-0"}) == [[b"events", []]]
own = client.xreadgroup("billing", "b1", {"events": "0-0"})[0][1]
assert [entry[0] for entry in own] == [b"1-0"]
next_entry = client.xreadgroup("billing", "b2", {"events": ">"})[0][1]
assert [entry[0] for entry in next_entry] == [b"3-0"]
assert groups(client)[b"billing"]["lag"] == 0
assert groups(client)[b"billing"]["pending"] == 2
assert client.xack("events", "billing", "1-0", "3-0") == 2
assert groups(client)[b"billing"]["pending"] == 0
assert client.xpending("events", "analytics")["pending"] == 1
assert client.xlen("events") == 3
print("ACK affects only one group's PEL; a new consumer sees no other owner's pending history")
client.xgroup_setid("events", "billing", "0-0")
replay = client.xreadgroup("billing", "b3", {"events": ">"}, count=1)[0][1]
assert [entry[0] for entry in replay] == [b"1-0"]
assert groups(client)[b"analytics"]["last-delivered-id"] == b"1-0"
assert client.xack("events", "billing", "1-0") == 1
client.xgroup_setid("events", "analytics", "$")
state = groups(client)
assert state[b"analytics"]["last-delivered-id"] == b"3-0"
assert state[b"analytics"]["pending"] == 1
assert state[b"analytics"]["lag"] == 0
assert client.xreadgroup("analytics", "a1", {"events": ">"}) == []
own = client.xreadgroup("analytics", "a1", {"events": "0-0"})[0][1]
assert [entry[0] for entry in own] == [b"1-0"]
assert client.xack("events", "analytics", "1-0") == 1
print("SETID rewinds acknowledged data for replay; moving forward skips unread data but keeps PEL")
账单组读取两条、分析组读取一条,得到不同投递位置和待处理数量。确认账单组第二条只改变其 PEL。b2 无法读取 b1 的待处理历史,却能接到下一条新记录。账单组的新条目全部投递后,lag 已经是零,但此时还有两条投递等待确认,直接说明两类计数不能互相替代。
在账单组任务全部确认后,显式回退位置,b3 又拿到了 1-0。分析组状态并未随之改变。把分析组前移到 $,会跳过它的未读记录,但原有待处理 1-0 仍然存在,原消费者还可以读出并确认。这个行为同时展示了位置重设与确认不是同一操作。
所有状态比较都基于 Redis 的实际响应,不需要假设精确时序或启动并发工作进程。实验验证组间隔离、投递位置、待处理归属与显式重设位置,没有实现崩溃检测、恢复调度、持久化测试或具有事务安全性的业务处理。读者应先确定希望修复哪一类状态,再决定操作哪一个命令。
来源
- Redis XREAD,独立读者提供的读取位置,已核验 2026-09-08。
- Redis XREADGROUP,组投递位置与消费者待处理历史,已核验 2026-09-08。
- Redis XACK,仅作用于指定组的待处理确认,已核验 2026-09-08。
- Redis XINFO GROUPS,最后投递 ID、待处理计数及滞后统计的限制,已核验 2026-09-08。
- Redis XGROUP SETID,显式重设组位置,已核验 2026-09-08。
- Redis XAUTOCLAIM,显式恢复符合条件的待处理条目,已核验 2026-09-08。
- Redis XTRIM,载荷保留与消费组引用,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅构建核心与分配器选择,已核验 2026-09-08。