Below is a hash ring. The colored wedges are servers; the small dots are keys. Each key belongs to the first server you meet going clockwise. Add or remove a server and watch the counter: only the keys that change owner light up, and there are surprisingly few of them.
<p class="hint">The big ring is the hash space (0–360). Colored wedges are <b>servers</b>; dots are <b>keys</b>. A key belongs to the first server found going <b>clockwise</b>. Add or remove a server — only the dots that change owner flash, and there are very few.</p>
<div class="wrap">
<svg id="ring" viewBox="0 0 320 320" width="300" height="300" aria-label="hash ring"></svg>
</div>
<div class="status" id="status">3 servers, 30 keys.</div>
<div class="btns">
<button id="add" type="button">Add a server</button>
<button id="remove" type="button">Remove a server</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.wrap { display: flex; justify-content: center; }
svg { max-width: 100%; height: auto; }
.key { fill: #9aa0a6; transition: fill .2s, r .2s; }
.key.moved { fill: #e63946; }
.srv-dot { stroke: #fff; stroke-width: 2; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; text-align: center; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Consistent hashing: servers and keys live on a circle [0,360). A key is owned
// by the first server clockwise from it. Adding/removing one server only changes
// ownership of the keys in one arc — about K/n of them (Karger et al., 1997).
const PALETTE = ['#1d3557','#2a9d8f','#e9c46a','#f4a261','#e76f51','#7d4f9e','#457b9d','#06a77d'];
const CX = 160, CY = 160, R = 120;
const NUM_KEYS = 30;
// Deterministic positions so the demo is stable and verifiable.
function hashTo360(n){ // simple integer hash -> [0,360)
let x = (n * 2654435761) >>> 0;
x ^= x >>> 13; x = (x * 1597334677) >>> 0; x ^= x >>> 16;
return (x % 36000) / 100;
}
const KEYS = [];
for (let i = 0; i < NUM_KEYS; i++) KEYS.push({ id: i, ang: hashTo360(i + 1) });
let servers = []; // array of angles (degrees)
let seed = 1000;
function freshServer(){ const a = hashTo360(seed++); return a; }
function reset(){ servers = [hashTo360(7), hashTo360(99), hashTo360(250)].sort((a,b)=>a-b); }
// Owner of a key = first server at angle >= key angle (clockwise), wrapping around.
function ownerOf(ang){
const s = [...servers].sort((a,b)=>a-b);
for (const v of s) if (v >= ang) return v;
return s[0]; // wrap to the first server
}
function colorFor(ang){
const s = [...servers].sort((a,b)=>a-b);
return PALETTE[s.indexOf(ang) % PALETTE.length];
}
function polar(ang, rad){
const t = (ang - 90) * Math.PI / 180; // 0deg at top, clockwise
return [CX + rad * Math.cos(t), CY + rad * Math.sin(t)];
}
let moved = new Set();
function render(){
const svg = document.getElementById('ring');
const s = [...servers].sort((a,b)=>a-b);
let html = `<circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="#d7dde3" stroke-width="10"/>`;
// server markers
s.forEach(a => {
const [x,y] = polar(a, R);
html += `<circle class="srv-dot" cx="${x}" cy="${y}" r="9" fill="${colorFor(a)}"/>`;
});
// keys, colored by owner
KEYS.forEach(k => {
const own = ownerOf(k.ang);
const [x,y] = polar(k.ang, R);
const cls = moved.has(k.id) ? 'key moved' : 'key';
const r = moved.has(k.id) ? 6 : 4.5;
html += `<circle class="${cls}" cx="${x}" cy="${y}" r="${r}" fill="${colorFor(own)}"/>`;
});
svg.innerHTML = html;
document.getElementById('remove').disabled = servers.length <= 1;
}
function setStatus(t){ document.getElementById('status').textContent = t; }
function snapshot(){ const m = {}; KEYS.forEach(k => m[k.id] = ownerOf(k.ang)); return m; }
function diff(before){
moved = new Set();
KEYS.forEach(k => { if (before[k.id] !== ownerOf(k.ang)) moved.add(k.id); });
return moved.size;
}
function add(){
const before = snapshot();
servers.push(freshServer());
const n = diff(before);
render();
setStatus(fmt('Added server #%length%. %n% of %NUM_KEYS% keys moved (%round%%).', { length: servers.length, n: n, NUM_KEYS: NUM_KEYS, round: Math.round(100*n/NUM_KEYS) }));
}
function remove(){
if (servers.length <= 1) return;
const before = snapshot();
servers.pop();
const n = diff(before);
render();
setStatus(fmt('Removed a server (%length% left). %n% of %NUM_KEYS% keys moved (%round%%).', { length: servers.length, n: n, NUM_KEYS: NUM_KEYS, round: Math.round(100*n/NUM_KEYS) }));
}
function doReset(){ reset(); seed = 1000; moved = new Set(); render(); setStatus(fmt('%length% servers, %NUM_KEYS% keys.', { length: servers.length, NUM_KEYS: NUM_KEYS })); }
document.getElementById('add').addEventListener('click', add);
document.getElementById('remove').addEventListener('click', remove);
document.getElementById('reset').addEventListener('click', doReset);
doReset();
<p class="hint">The big ring is the hash space (0–360). Colored wedges are <b>servers</b>; dots are <b>keys</b>. A key belongs to the first server found going <b>clockwise</b>. Add or remove a server — only the dots that change owner flash, and there are very few.</p>
<div class="wrap">
<svg id="ring" viewBox="0 0 320 320" width="300" height="300" aria-label="hash ring"></svg>
</div>
<div class="status" id="status">3 servers, 30 keys.</div>
<div class="btns">
<button id="add" type="button">Add a server</button>
<button id="remove" type="button">Remove a server</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.wrap { display: flex; justify-content: center; }
svg { max-width: 100%; height: auto; }
.key { fill: #9aa0a6; transition: fill .2s, r .2s; }
.key.moved { fill: #e63946; }
.srv-dot { stroke: #fff; stroke-width: 2; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; text-align: center; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Consistent hashing: servers and keys live on a circle [0,360). A key is owned
// by the first server clockwise from it. Adding/removing one server only changes
// ownership of the keys in one arc — about K/n of them (Karger et al., 1997).
const PALETTE = ['#1d3557','#2a9d8f','#e9c46a','#f4a261','#e76f51','#7d4f9e','#457b9d','#06a77d'];
const CX = 160, CY = 160, R = 120;
const NUM_KEYS = 30;
// Deterministic positions so the demo is stable and verifiable.
function hashTo360(n){ // simple integer hash -> [0,360)
let x = (n * 2654435761) >>> 0;
x ^= x >>> 13; x = (x * 1597334677) >>> 0; x ^= x >>> 16;
return (x % 36000) / 100;
}
const KEYS = [];
for (let i = 0; i < NUM_KEYS; i++) KEYS.push({ id: i, ang: hashTo360(i + 1) });
let servers = []; // array of angles (degrees)
let seed = 1000;
function freshServer(){ const a = hashTo360(seed++); return a; }
function reset(){ servers = [hashTo360(7), hashTo360(99), hashTo360(250)].sort((a,b)=>a-b); }
// Owner of a key = first server at angle >= key angle (clockwise), wrapping around.
function ownerOf(ang){
const s = [...servers].sort((a,b)=>a-b);
for (const v of s) if (v >= ang) return v;
return s[0]; // wrap to the first server
}
function colorFor(ang){
const s = [...servers].sort((a,b)=>a-b);
return PALETTE[s.indexOf(ang) % PALETTE.length];
}
function polar(ang, rad){
const t = (ang - 90) * Math.PI / 180; // 0deg at top, clockwise
return [CX + rad * Math.cos(t), CY + rad * Math.sin(t)];
}
let moved = new Set();
function render(){
const svg = document.getElementById('ring');
const s = [...servers].sort((a,b)=>a-b);
let html = `<circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="#d7dde3" stroke-width="10"/>`;
// server markers
s.forEach(a => {
const [x,y] = polar(a, R);
html += `<circle class="srv-dot" cx="${x}" cy="${y}" r="9" fill="${colorFor(a)}"/>`;
});
// keys, colored by owner
KEYS.forEach(k => {
const own = ownerOf(k.ang);
const [x,y] = polar(k.ang, R);
const cls = moved.has(k.id) ? 'key moved' : 'key';
const r = moved.has(k.id) ? 6 : 4.5;
html += `<circle class="${cls}" cx="${x}" cy="${y}" r="${r}" fill="${colorFor(own)}"/>`;
});
svg.innerHTML = html;
document.getElementById('remove').disabled = servers.length <= 1;
}
function setStatus(t){ document.getElementById('status').textContent = t; }
function snapshot(){ const m = {}; KEYS.forEach(k => m[k.id] = ownerOf(k.ang)); return m; }
function diff(before){
moved = new Set();
KEYS.forEach(k => { if (before[k.id] !== ownerOf(k.ang)) moved.add(k.id); });
return moved.size;
}
function add(){
const before = snapshot();
servers.push(freshServer());
const n = diff(before);
render();
setStatus(fmt('Added server #%length%. %n% of %NUM_KEYS% keys moved (%round%%).', { length: servers.length, n: n, NUM_KEYS: NUM_KEYS, round: Math.round(100*n/NUM_KEYS) }));
}
function remove(){
if (servers.length <= 1) return;
const before = snapshot();
servers.pop();
const n = diff(before);
render();
setStatus(fmt('Removed a server (%length% left). %n% of %NUM_KEYS% keys moved (%round%%).', { length: servers.length, n: n, NUM_KEYS: NUM_KEYS, round: Math.round(100*n/NUM_KEYS) }));
}
function doReset(){ reset(); seed = 1000; moved = new Set(); render(); setStatus(fmt('%length% servers, %NUM_KEYS% keys.', { length: servers.length, NUM_KEYS: NUM_KEYS })); }
document.getElementById('add').addEventListener('click', add);
document.getElementById('remove').addEventListener('click', remove);
document.getElementById('reset').addEventListener('click', doReset);
doReset();
Try adding several servers in a row. With plain hash(key)modn, every add would relocate nearly 100% of keys. On the ring, each new server steals only the slice between it and its clockwise neighbor — on average about 1/n of all keys. That gap between "move everything" and "move a sliver" is the whole reason large systems can grow without falling over.
Comments
Loading comments...