Redis Cluster separates key placement from machine identity. A key maps to one of 16,384 fixed hash slots; cluster metadata maps those slots to primaries and their replicas. Adding a machine does not change the slot hash function. Operators redistribute slots, while clients discover the current topology and react to routing changes.

Keys choose slots, not contiguous machine ranges

The ordinary mapping is CRC16/XMODEM of the key modulo 16,384. A nonempty hash tag between the first opening brace and its following closing brace makes Redis hash that substring instead. Thus profile:{42} and quota:{42} share a slot even though their full names differ. Tags are placement instructions, not authorization boundaries.

A primary can own several noncontiguous slot ranges. In a stable topology each slot has an authoritative primary, and a whole key belongs to that slot: a large hash or sorted set is not automatically divided between nodes. CLUSTER SHARDS reports shard membership and slot ranges. Do not confuse “same primary” with “same slot”; they are different conditions.

Clients participate in routing

A cluster-aware client maintains a slot map and connects to the advertised nodes. Contacting the wrong primary can produce MOVED with a destination, not a missing value. MOVED tells the client to update routing; ASK is a temporary migration redirection that requires ASKING before the redirected command, without permanently assigning ownership to that target.

Migration is not an instant, globally synchronized map update. Clients may observe different topology information, and some multi-key operations can return TRYAGAIN while keys are split during migration. Use a maintained cluster client rather than assuming a single endpoint forwards every request. Advertised addresses must be reachable from the application’s network, not only between Redis processes.

Placement does not imply transaction scope

Server-side multi-key commands such as MSET require their keys to share one slot. Merely landing on the same physical primary is insufficient. Hash tags can satisfy the slot constraint, but putting every tenant under one tag concentrates load and limits distribution. A client operation implemented as several independent commands is not equivalent to one atomic multi-key command.

Sharding distributes ownership; replication creates copies for a shard. Replica reads can be stale because replication is asynchronous. Failover promotes a replica for the affected shard, not the whole cluster as one indivisible unit. Acknowledged writes can still be lost during failures, and connection loss can leave a write’s outcome unknown to its caller.

When it breaks

Missing slot coverage and insufficient reachable primaries can prevent service. With cluster-require-full-coverage yes, an unavailable shard can stop key commands even on otherwise healthy primaries. Three primaries without replicas demonstrate distribution, but cannot replace a failed primary’s data with a replica that does not exist.

Watch slot coverage, cluster state, replica health, address announcements, and client redirection errors together. Adding nodes alone does not automatically move existing keys or split a hot key. Resharding, replica placement, and client retry semantics are separate operational decisions. Test the failure you intend to tolerate instead of treating a successful cluster handshake as a high-availability result.

Check it quickly

Use a POSIX machine with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Redis 8.10.1, redis-py 8.1.0, and Python 3.14.4. Build Redis core with libc and install the client in a temporary virtual environment.

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

Run the Python block with "$redis_lab/venv/bin/python". It starts three isolated primaries using temporary directories and six loopback-only ports for data and cluster-bus traffic. Private Unix sockets identify the created processes before configuration. Persistence is disabled. The final failure targets only its own first primary; cleanup stops the remaining processes and removes their temporary files.

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 confirms complete coverage and two disjoint ranges on one primary. A plain client receives MOVED, while the cluster client reads and writes the tagged pair. A second key on that same primary but in another slot makes MSET fail without changing either value. Killing a primary then produces cluster failure and rejected key commands.

The fixture uses RESP2 for directly inspected topology replies and explicit local bus ports. It is a local routing and failure-detection test, not a production topology: there are no replicas or independent machines. ASK migration, actual resharding, replica promotion, network partitions, persistence, TLS, authentication, and production throughput are not exercised here.

Sources