Redis Cluster 把键的落点与机器身份分成两层:键先映射到一万六千三百八十四个固定槽之一,再由集群元数据描述各槽的主节点和副本。增加机器不会改变槽哈希函数,运维需要重新分配槽,客户端则发现当前拓扑并处理路由变化。槽、分片、主节点与副本不是可以相互替代的概念。
键选择槽,不要求机器持有连续区间
普通映射使用键的 CRC16/XMODEM 值对一万六千三百八十四取模。如果第一个左花括号与其后第一个右花括号之间存在非空标签,则只哈希这部分。例如 profile:{42} 和 quota:{42} 的完整键名不同,却位于同一个槽。标签只是数据放置规则,不是权限控制或租户隔离机制。
一个主节点可以拥有多个不连续的槽段。拓扑稳定时,一个槽有权威主节点;整个键属于该槽,大 hash 或有序集合不会被自动拆到多台机器。CLUSTER SHARDS 能显示分片成员及其槽段。尤其不能把“同一主节点”误当成“同一个槽”:前者是当前机器分配关系,后者才是多键操作需要的条件。
客户端也参与路由
支持集群的客户端维护槽映射,并连接拓扑中公布的节点。访问错误的主节点可能收到带目标地址的 MOVED,而不是得到键不存在。MOVED 要求更新路由;ASK 是迁移期间的临时重定向,需要先向目标发送 ASKING 再发送被重定向的命令,不能因此把该槽永久改归目标节点。
迁移也不是所有参与方瞬间同步更新地图。不同客户端可能暂时持有不同拓扑,多键涉及的数据在迁移期间分散到两端时,还可能得到 TRYAGAIN。应使用维护中的集群客户端,不能假设随便连一个入口就会自动代理所有命令。公布的地址不仅要让 Redis 节点互相可达,也必须能从应用所在网络访问。
放置关系不等于事务范围
MSET 这类服务端多键命令要求所有键属于同一个槽,仅仅处在同一物理主节点上仍不够。哈希标签可以满足同槽条件,但把所有租户都放进同一个标签又会集中负载,削弱分布能力。某个客户端方法如果拆成多个独立命令执行,也不能被当成一个原子的多键命令。
分片分配数据归属,复制则为一个分片提供副本。复制是异步的,所以副本读取可能落后。故障转移提升的是受影响分片的副本,并不是把整个集群作为不可分割的一组一起切换。发生故障时仍可能丢失已经确认的写入;连接中断也可能让调用者无法确定某条写操作最终有没有执行。
坏掉的样子
槽覆盖不完整或可达主节点不足,都可能阻止服务。在 cluster-require-full-coverage yes 下,一个分片不可用会使其他仍健康的主节点也拒绝键命令。三个没有副本的主节点能够展示数据分布,却没有能力从根本不存在的副本中恢复故障节点的数据,不能据此宣布已经具备高可用。
应一起检查槽覆盖、集群状态、副本健康、地址通告和客户端重定向错误。只增加节点不会自动搬迁已有数据,也不会自动拆开单个热键;重新分片、副本放置与客户端重试语义分别需要设计。应测试准备容忍的具体故障,而不是把节点握手成功当成高可用验收结果。
快速验证
原有实验记录使用 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
在同一个 shell 中,用 "$redis_lab/venv/bin/python" 执行 Python 代码。它以临时目录启动三个隔离主节点,分配六个仅监听回环地址的端口,分别用于数据连接与集群总线。配置前通过私有 Unix socket 核对进程身份;关闭持久化,最后只杀死自己创建的第一个主节点,清理时再停止其余进程并移除临时文件。
from contextlib import contextmanager, ExitStack
import os
from pathlib import Path
import signal
import socket
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.crc import key_slot
from redis.exceptions import ClusterCrossSlotError, ClusterDownError, MovedError
from redis.retry import Retry
def until(check):
deadline = time.monotonic() + 15
while not check():
assert time.monotonic() < deadline, "cluster state did not converge"
time.sleep(0.05)
@contextmanager
def node(directory, port, bus):
directory.mkdir()
endpoint = str(directory / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--bind", "127.0.0.1", "--port", str(port),
"--protected-mode", "yes", "--unixsocket", endpoint,
"--unixsocketperm", "700", "--dir", str(directory),
"--save", "", "--appendonly", "no", "--cluster-enabled", "yes",
"--cluster-config-file", "nodes.conf", "--cluster-port", str(bus),
"--cluster-announce-ip", "127.0.0.1", "--cluster-node-timeout", "1000",
"--cluster-require-full-coverage", "yes",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False, protocol=2,
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:
assert time.monotonic() < deadline, "Redis did not start"
time.sleep(0.02)
assert client.info("server")["process_id"] == process.pid
yield client, process
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 in (0, -signal.SIGKILL), errors.decode()
with TemporaryDirectory(prefix="redis-cluster-", dir="/tmp") as directory, ExitStack() as stack:
leases = [stack.enter_context(socket.socket()) for _ in range(6)]
for lease in leases:
lease.bind(("127.0.0.1", 0))
ports = [lease.getsockname()[1] for lease in leases]
nodes = []
for index in range(3):
leases[2 * index].close()
leases[2 * index + 1].close()
nodes.append(stack.enter_context(node(
Path(directory) / str(index), ports[2 * index], ports[2 * index + 1],
)))
clients = [item[0] for item in nodes]
ranges = [[(0, 4095), (8192, 12287)], [(4096, 8191)], [(12288, 16383)]]
for client, owned in zip(clients, ranges):
bounds = [value for interval in owned for value in interval]
assert client.execute_command("CLUSTER", "ADDSLOTSRANGE", *bounds) == b"OK"
for index in (1, 2):
assert clients[0].execute_command(
"CLUSTER", "MEET", "127.0.0.1", ports[2 * index], ports[2 * index + 1],
) == b"OK"
until(lambda: all(b"cluster_state:ok" in client.execute_command("CLUSTER", "INFO")
for client in clients))
shards = [dict(zip(item[::2], item[1::2], strict=True))
for item in clients[0].execute_command("CLUSTER", "SHARDS")]
assert len(shards) == 3
assert sorted(interval for shard in shards
for interval in zip(shard[b"slots"][::2], shard[b"slots"][1::2])) == [
(0, 4095), (4096, 8191), (8192, 12287), (12288, 16383),
]
first_id = clients[0].execute_command("CLUSTER", "MYID")
first_shard = next(shard for shard in shards
if first_id in [dict(zip(item[::2], item[1::2]))[b"id"]
for item in shard[b"nodes"]])
assert first_shard[b"slots"] == [0, 4095, 8192, 12287]
print("16384 slots are covered; one primary owns two noncontiguous ranges")
keys = [b"profile:{42}", b"quota:{42}"]
slot = key_slot(keys[0])
assert key_slot(keys[1]) == slot
assert clients[0].execute_command("CLUSTER", "KEYSLOT", keys[0]) == slot
owner = next(index for index, owned in enumerate(ranges)
if any(start <= slot <= end for start, end in owned))
wrong = clients[(owner + 1) % 3]
try:
wrong.get(keys[0])
except MovedError as error:
assert error.slot_id == slot
assert error.node_addr == ("127.0.0.1", ports[2 * owner])
else:
raise AssertionError("a plain client on the wrong primary must receive MOVED")
routed = redis.RedisCluster(host="127.0.0.1", port=ports[0],
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
assert routed.mset({keys[0]: "Ada", keys[1]: "10"})
assert routed.mget(keys) == [b"Ada", b"10"]
print("MOVED names the owner; a cluster client routes a same-slot MSET and MGET")
other = b"unrelated:2"
assert key_slot(other) != slot
assert any(start <= key_slot(other) <= end for start, end in ranges[owner])
try:
clients[owner].execute_command("MSET", keys[0], "bad", other, "bad")
except ClusterCrossSlotError:
pass
else:
raise AssertionError("cross-slot MSET must fail")
assert routed.get(keys[0]) == b"Ada"
assert routed.get(other) is None
print("cross-slot MSET fails even on the same primary, without changing either key")
finally:
routed.close()
assert nodes[0][1].poll() is None
nodes[0][1].kill()
assert nodes[0][1].wait(timeout=5) == -signal.SIGKILL
until(lambda: all(b"cluster_state:fail" in client.execute_command("CLUSTER", "INFO")
for client in clients[1:]))
try:
clients[1].get(b"any-key")
except ClusterDownError:
print("without replicas, a missing primary stops key service under full coverage")
else:
raise AssertionError("an uncovered cluster must reject key commands")
CLUSTER SHARDS 确认所有槽都有覆盖,而且同一个主节点确实持有两个分离区间。普通客户端在错误节点收到 MOVED,集群客户端则能读写带相同标签的键。另一个键虽然也归同一主节点,却位于不同槽,让 MSET 失败且两边值都没有被改动。杀死一个主节点后,集群进入失败状态,后续键命令被拒绝。
为了明确解析结构,直接检查拓扑回复的连接使用 RESP2,并为本地总线显式设置端口。该实验验证的是本机路由与故障检测,不是生产拓扑:这里没有副本,也没有独立机器。ASK 迁移、真实重新分片、副本提升、网络分区、持久化、TLS、认证和生产吞吐都不在本实验的覆盖范围内。
来源
- Redis Cluster 规范,槽哈希、哈希标签、路由、迁移与写入安全,已核验 2026-09-08。
- Redis Cluster 扩展指南,分片、复制与部署边界,已核验 2026-09-08。
- Redis CLUSTER SHARDS,分片成员与不连续槽段,已核验 2026-09-08。
- Redis CLUSTER ADDSLOTSRANGE,在空测试实例中分配槽段,已核验 2026-09-08。
- redis-py 8.1.0 集群客户端,路由与客户端配置,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅构建核心与选择分配器,已核验 2026-09-08。