R2.05.1 · Hash-tag co-location — dive 1
The tag mechanic.
Valkey cluster hashes only the substring inside the first non-empty {…}.
Everything else in the key is ignored for placement, so emq:{orders}:pending and emq:{orders}:active share
one slot.
Grounding: echo/apps/echo_mq/lib/echo_mq/keyspace.ex · hashtag/1 + slot/1 (the client-side CRC16-XMODEM % 16384) · queue_key/2 · vector slot("123456789") == 12739.
user:{123}:settings on
slot 5970. Hover or select a row.hashtag/1 takes the first non-empty {tag}; slot/1 runs
CRC16-XMODEM % 16384 over it.Only the tag is hashed
hash-tag-colocation.md.txt · How hash tags workValkey cluster computes a key's slot from a CRC16 of its bytes, modulo 16384. The hash tag changes the
input to that CRC16. When a key holds a non-empty substring between a { and the next
}, the cluster hashes that substring alone; the prefix, the suffix, and the colons are ignored
for slot placement.
SET user:{123}:profile … # hashes "123" SET user:{123}:settings … # hashes "123" SET user:{123}:sessions … # hashes "123" -> all on one slot
That is the whole point of the tag: it lets an application name related keys distinctly while guaranteeing
they co-locate. Strip the braces and each full key hashes on its own, the three keys scatter across three
slots, and a multi-key command over them raises CROSSSLOT. The braces are the lever; the
substring between them is what Valkey actually hashes.
A hash tag swaps the hash input from the whole key to one substring — so keys that differ everywhere except inside the braces still land on the same slot.
The exact rule
keyspace.ex · hashtag/1 + slot/1EchoMQ.Keyspace states the rule precisely. hashtag/1 scans for the first
{, then for the next }, and returns the substring between them only when that
substring is non-empty — otherwise it returns the whole key. slot/1 then runs
CRC16-XMODEM over that substring, modulo 16384:
# echo/apps/echo_mq/lib/echo_mq/keyspace.ex def slot(key) when is_binary(key), do: rem(crc16(hashtag(key), 0), 16384) def hashtag(key) do with [_, rest] <- :binary.split(key, "{"), [tag, _] when tag != "" <- :binary.split(rest, "}") do tag else _ -> key end end
In words, three steps, then the modulo: find the first {; from there find the next
}; if a } exists and the span between the braces is non-empty, the hashed substring
is exactly that span, otherwise it is the whole key. The slot is then
CRC16(substring) % 16384. The crc16/2 in the same module is a real inline Elixir
XMODEM with polynomial 0x1021 — the cluster specification's algorithm — so the slot a
client computes matches the slot the cluster assigns. The known vector is asserted in the code:
slot("123456789") == 12739 (CRC16 0x31C3). The extraction trace in the hero runs
this exact rule, so every slot it prints is the slot Valkey would pick.
- first '{'
- The scan takes the first opening brace and looks no further for a better one. Everything before it is part of the key but not part of the hash.
- next '}'
- The closing brace is the first one after that opening brace. The span between them is the candidate substring.
- non-empty
- An empty span (
{}) is rejected; the rule falls back to hashing the whole key. Only a non-empty tag changes the slot.
Notes on ValkeyA Valkey cluster hash tag hashes only the substring in
the first non-empty {...}, then takes CRC16 % 16384 — the same algorithm
slot/1 runs client-side — valkey.io/topics/cluster-spec.
Placement rules and footguns
hash-tag-colocation.md.txt · Placement RulesThe rule is short, but three placements catch people out. Each follows directly from “first brace, then the next closing brace, non-empty.”
The first { wins. {user:123}:profile hashes
user:123 (slot 12893), not 123. The scan takes the first opening brace and stops,
so it will not co-locate with user:{123}:settings (slot 5970).
Braces in the wrong place hash the wrong thing. user:123:{profile} hashes
profile (slot 16237). It is a valid tag, but it tags the suffix, so it scatters away from the
user:{123}:* family. The convention that avoids this: put the entity id in the tag, at the same
position in every related key.
An empty tag {} hashes the whole key. key:{}:suffix has a
{ immediately followed by a }, so the span is empty; the rule falls back to hashing
the entire key (slot 14786). And with multiple tags, only the first is used —
{a}:{b}:key hashes a (slot 15495) and ignores {b}.
user:{123}:profile on slot 5970. Pick key B; hover or select a row.Static verdicts — user:{123}:settings → slot 5970, same, legal · user:123:settings → slot 9984, CROSSSLOT · order:{123}:items → slot 5970 (a different entity colliding because both tags spell 123).
Same slot only when the hashed substrings match: a brace in the wrong place, an empty tag, or a
second tag all break co-location and turn a multi-key command into a CROSSSLOT error.
In EchoMQ — the mechanic behind emq:{q}:
keyspace.ex · bcs3.1
This is the mechanic behind every EchoMQ queue key. EchoMQ.Keyspace.queue_key/2
(echo/apps/echo_mq/lib/echo_mq/keyspace.ex) wraps the queue name in a hash tag, returning
"emq:{" <> q <> "}:" <> type. So emq:{orders}:pending,
emq:{orders}:active, emq:{orders}:schedule, and a job's own row
emq:{orders}:job:ORD0NgWEfAEJfs all hash the substring orders — the whole
queue lives on one slot. The hashtag is the queue name. That single slot is exactly what
lets a per-queue multi-key Lua transition run as one atomic call, computed by the same slot/1
this dive traces.
Every key of one queue answers one slot
The co-location is not assumed — queue_key/2 always braces the queue name, and
slot/1 computes the slot client-side over CRC16 % 16384
(valkey.io/topics/cluster-spec). The
committed manuscript record froze the consequence:
the hashtag IS the queue name
emq:{orders}:* — pending, active, meta, job row all answer slot 105
emq:{fills}:* slot 4165
vector slot("123456789") 12739 ← the client-side CRC16
Valkey hashes only the {tag} substring, so wrapping the shared part of related keys forces
them onto one slot — CRC16(substring) % 16384.
Keyspace.queue_key/2 returns emq:{q}:<type>, so a queue's pending,
active, and job-row keys share the {q} tag and answer one slot; slot/1 computes
it client-side with the vector slot("123456789") == 12739 asserted.
Wrapping the queue name in {q} is what colocates every emq:{q}:* key
— the same first-non-empty-brace rule, computed by the real slot/1.
Recap — first brace, then the slot
next → R2.05.2The tag selects the substring Valkey hashes: first {, the next }, non-empty
— or the whole key. slot/1 turns that substring into a slot over CRC16-XMODEM % 16384.
This dive showed a queue's keys can share a slot. The next dive shows what that buys — why a
multi-key command off one slot raises CROSSSLOT, and how the {q} tag keeps a
queue's multi-key claim legal.
References
sources · relatedSources
- Valkey — Cluster specification — hash tags, the 16384 slots, and the
{…}rule: the first non-empty brace span, else the whole key. - Valkey — CLUSTER KEYSLOT — the command that returns a key's slot, the live equivalent of the client-side
slot/1traced here. - Redis — Cluster specification — the CRC16 slot model and the single-slot
MULTI/EXECatomicity co-location keeps legal.
Related in this course
- R2.05 · Hash-tag co-locationthe module hub
- R2.05.2 · CROSSSLOT preventionthe next dive
- R2.04 · Cross-shard consistencydetecting the tear
- R2.01 · Atomic updatesthe single-slot atomic move
- /echomqthe emq:{q}: keyspace in depth