Redis 发布订阅面向消息发布时已经生效的订阅广播,不会为断线的听众保留收件箱。它适合可以被后续状态替代的实时通知,但 PUBLISH 返回正数,并不证明某个工作进程完成了任务,也不表示离线消费者回来后能够补领消息。
订阅是一次握手
SUBSCRIBE 会先返回订阅确认,连接随后才能收到后续发布的消息。协调发布者与新听众时,应等待这份确认。仅仅把订阅请求发送出去还不够:Redis 处理请求之前,另一个连接就可能已经发布。重新连接和订阅只恢复未来的投递,不会填补之前的历史空白。
在持续在线的连接上,听众按发布顺序接收消息。这里的顺序反映 Redis 的处理顺序,不是多个独立生产者携带的业务时间戳。即使接收有序,把事件分发给并行处理器后,完成顺序仍可能改变。因此,声称发布订阅完全没有顺序是错误的;把有序接收扩大为业务副作用也必然有序,同样不成立。
多个听众各自收到广播,并不是竞争弹出同一个列表元素。频道订阅与匹配的模式订阅是两条独立投递路径。同时订阅 events 和 event*,一次发布就可能产生一份 message 和一份 pmessage;多个模式重叠也会如此。最多一次描述的是没有重试保证,不是跨订阅路径的业务去重承诺。
严格解释返回值
在本次验证的单机版本中,PUBLISH 分别统计频道和模式匹配的投递次数,因此两个客户端连接可以得到返回值三。固定版本的源码确实在两条路径分别递增计数。对于 Redis Cluster,官方说明计数只包含连接到发布节点的听众,因此它也不是整个集群的受众总数。
该响应不会等待处理器提交数据库事务、完成界面更新或确认业务完成。返回零只说明当时没有计入匹配接收者,返回正数不能证明处理成功。如果应用需要识别重叠订阅或生产者重复尝试造成的重复事件,应在消息中放入稳定的事件标识,不能用返回的数字替代业务确认。
坏掉的样子
订阅中断期间发布的消息会被错过。重连循环无法请求某个偏移之后的内容,因为发布订阅既无保留日志,也无回放游标。用于缓存失效时,一次通知丢失可能让旧值持续存在,直到另一个刷新机制介入;用于账单或任务派发时,则可能直接丢掉必须执行的工作。需要回放就应选择保留消息的队列或流,并明确恢复与持久化策略。
慢听众并不是一变慢就被随机丢掉几次读取。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 代码。程序在 /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 receive(subscription):
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
message = subscription.get_message(timeout=0.1)
if message is not None:
return message
raise AssertionError("subscription response timed out")
with instance() as client:
assert client.publish("events", b"before-subscribe") == 0
with client.pubsub() as first, client.pubsub() as second:
for subscription in (first, second):
subscription.subscribe("events")
response = receive(subscription)
assert response["type"] == "subscribe"
assert response["channel"] == b"events" and response["data"] == 1
for value in (b"one", b"two"):
assert client.publish("events", value) == 2
for subscription in (first, second):
messages = [receive(subscription) for _ in range(2)]
assert [message["type"] for message in messages] == ["message"] * 2
assert [message["data"] for message in messages] == [b"one", b"two"]
print("before subscription: 0; two listeners receive one,two in order")
second.unsubscribe("events")
response = receive(second)
assert response["type"] == "unsubscribe" and response["data"] == 0
assert client.publish("events", b"missed") == 1
assert receive(first)["data"] == b"missed"
second.subscribe("events")
assert receive(second)["type"] == "subscribe"
assert client.publish("events", b"after-resubscribe") == 2
for subscription in (first, second):
assert receive(subscription)["data"] == b"after-resubscribe"
print("resubscription receives the new marker, not the missed message")
first.psubscribe("event*")
response = receive(first)
assert response["type"] == "psubscribe" and response["data"] == 2
assert client.publish("events", b"overlap") == 3
messages = [receive(first) for _ in range(2)]
assert {message["type"] for message in messages} == {"message", "pmessage"}
assert all(message["data"] == b"overlap" for message in messages)
assert receive(second)["data"] == b"overlap"
print("two clients, three matching deliveries; one client receives two copies")
第一个发布发生在两个听众订阅之前,返回零。等待双方确认后,连续发布的 one、two 都按相同顺序到达两个听众。断言同时检查消息类型和载荷,因此不会把订阅确认误当业务事件。响应等待有明确截止时间,不依靠随意休眠来猜测订阅何时生效。
第二个听众先显式退订,在这段空白中发布 missed,然后重新订阅。它收到的下一份载荷是 after-resubscribe,而非错过的事件。这里通过退订模拟订阅空白,并未声称复现真实网络故障。最后增加重叠的频道和模式订阅,两个连接产生三次计数,第一位听众收到同一次发布的两份副本。
三条输出摘要都以真实响应上的断言为前提。这些检查验证本机命令与重新订阅的语义,不验证过载阈值、集群传播、崩溃恢复或处理吞吐。应用仍需判断哪些通知可以丢弃,以及某条通知永不到达时,怎样重新取得权威状态。该判断应由数据用途决定,不能由一次成功的发布命令代替。
来源
- Redis 发布订阅,订阅确认、顺序、最多一次投递与重叠订阅,已核验 2026-09-08。
- Redis PUBLISH,命令返回值与集群计数范围,已核验 2026-09-08。
- Redis 客户端管理,输出缓冲与断连限制,已核验 2026-09-08。
- Redis 8.10.1 发布订阅实现,频道与模式匹配分别计数的源码,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅构建核心与分配器选择,已核验 2026-09-08。