Skip to content

hash ring

  1. 1The naive baseline — hash mod N
  2. 2A ring with one point per node
  3. 3Virtual nodes

Run a step yourself: cd builds/hash-ring/steps/<step> && go test -v ./...

Step 1

The naive baseline — hash mod N

Even load, but adding one node remaps ~80% of keys. Also fixes a hashing trap before we need it.

Build step 1:The naive baseline — hash mod N

Edit in Go Playground
// Package ringhash maps keys to nodes. This step is the naive baseline: hash(key) mod N.
package ringhash

import "hash/fnv"

// hash64 is FNV-1a (64-bit) followed by murmur3's fmix64 finalizer.
//
// Raw FNV-1a is a trap for rings: strings that differ only in their last byte
// ("node-0", "node-1", ...) land within ~2^40 of each other in a 2^64 space, so every
// node clusters on one tiny arc. Modulo hides this because it only looks at the low
// bits; a ring uses the high bits. The finalizer gives full avalanche: flipping one
// input bit flips ~half the output bits. Production rings use murmur3/xxhash directly.
func hash64(s string) uint64 {
	h := fnv.New64a()
	h.Write([]byte(s))
	return fmix64(h.Sum64())
}

func fmix64(k uint64) uint64 {
	k ^= k >> 33
	k *= 0xff51afd7ed558ccd
	k ^= k >> 33
	k *= 0xc4ceb9fe1a85ec53
	k ^= k >> 33
	return k
}
Test outputfrom the site build6 passed
=== RUN   TestHashSpreadsSimilarInputsAcrossTheSpace
--- PASS: TestHashSpreadsSimilarInputsAcrossTheSpace
=== RUN   TestHashAvalanche
    hash_test.go:31: average bits flipped between neighbouring names: 32.0 / 64
--- PASS: TestHashAvalanche
=== RUN   TestModuloIsDeterministic
--- PASS: TestModuloIsDeterministic
=== RUN   TestModuloEmpty
--- PASS: TestModuloEmpty
=== RUN   TestModuloSpreadsLoadEvenly
    modulo_test.go:24: load: max 1.018× mean, min 0.982× mean
--- PASS: TestModuloSpreadsLoadEvenly
=== RUN   TestModuloAddingANodeMovesMostKeys
    modulo_test.go:38: 4 → 5 nodes: 80.1% of keys moved (ideal would be 20%)
--- PASS: TestModuloAddingANodeMovesMostKeys
PASS
ok  	shardedmind/builds/hash-ring/01-modulo

Run it on your machine:cd builds/hash-ring/steps/01-modulo && go test -v ./...

Step 2

A ring with one point per node

Only keys between the new node and its predecessor move — but load is wildly uneven.

Build step 2:A ring with one point per node

Edit in Go Playground
package ringhash

import (
	"slices"
	"sort"
)

// Ring is a consistent-hash ring with one point per node. Nodes and keys are hashed onto
// the same 64-bit circle; a key belongs to the first node at or after it, clockwise.
//
// Not safe for concurrent use; wrap with a sync.RWMutex if nodes change while serving.
type Ring struct {
	points []uint64          // sorted node positions
	owner  map[uint64]string // position → node
}

func NewRing() *Ring {
	return &Ring{owner: make(map[uint64]string)}
}

// Add places node on the ring. Adding an existing node is a no-op.
func (r *Ring) Add(node string) {
	p := hash64(node)
	if _, ok := r.owner[p]; ok {
		return
	}
	r.owner[p] = node
	i, _ := slices.BinarySearch(r.points, p)
	r.points = slices.Insert(r.points, i, p)
}

// Remove takes node off the ring; its keys fall through to the next node clockwise.
func (r *Ring) Remove(node string) {
	p := hash64(node)
	if r.owner[p] != node {
		return
	}
	delete(r.owner, p)
	i, _ := slices.BinarySearch(r.points, p)
	r.points = slices.Delete(r.points, i, i+1)
}

// Get returns the node that owns key, or "" if the ring is empty. O(log n).
func (r *Ring) Get(key string) string {
	if len(r.points) == 0 {
		return ""
	}
	h := hash64(key)
	i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h })
	if i == len(r.points) {
		i = 0 // past the last point: wrap around to the first
	}
	return r.owner[r.points[i]]
}

// Nodes returns the nodes on the ring in ring order.
func (r *Ring) Nodes() []string {
	out := make([]string, len(r.points))
	for i, p := range r.points {
		out[i] = r.owner[p]
	}
	return out
}
Test outputfrom the site build13 passed
=== RUN   TestHashSpreadsSimilarInputsAcrossTheSpace
--- PASS: TestHashSpreadsSimilarInputsAcrossTheSpace
=== RUN   TestHashAvalanche
    hash_test.go:31: average bits flipped between neighbouring names: 32.0 / 64
--- PASS: TestHashAvalanche
=== RUN   TestModuloIsDeterministic
--- PASS: TestModuloIsDeterministic
=== RUN   TestModuloEmpty
--- PASS: TestModuloEmpty
=== RUN   TestModuloSpreadsLoadEvenly
    modulo_test.go:24: load: max 1.018× mean, min 0.982× mean
--- PASS: TestModuloSpreadsLoadEvenly
=== RUN   TestModuloAddingANodeMovesMostKeys
    modulo_test.go:38: 4 → 5 nodes: 80.1% of keys moved (ideal would be 20%)
--- PASS: TestModuloAddingANodeMovesMostKeys
=== RUN   TestRingEmpty
--- PASS: TestRingEmpty
=== RUN   TestRingSingleNodeOwnsEverything
--- PASS: TestRingSingleNodeOwnsEverything
=== RUN   TestRingOwnerIsFirstNodeClockwise
--- PASS: TestRingOwnerIsFirstNodeClockwise
=== RUN   TestRingAddIsIdempotent
--- PASS: TestRingAddIsIdempotent
=== RUN   TestRingAddingANodeOnlyMovesKeysToIt
    ring_test.go:79: 4 → 5 nodes: 10.9% of keys moved (modulo moved ~80%)
--- PASS: TestRingAddingANodeOnlyMovesKeysToIt
=== RUN   TestRingRemovingANodeOnlyMovesItsKeys
--- PASS: TestRingRemovingANodeOnlyMovesItsKeys
=== RUN   TestRingLoadIsUneven
    ring_test.go:104: load: max 2.47× mean, min 0.01× mean
--- PASS: TestRingLoadIsUneven
PASS
ok  	shardedmind/builds/hash-ring/02-ring

Run it on your machine:cd builds/hash-ring/steps/02-ring && go test -v ./...

Step 3

Virtual nodes

Many points per node even out the load and scatter a departed node's keys across the cluster.

Build step 3:Virtual nodes

Edit in Go Playground
package ringhash

import (
	"slices"
	"sort"
	"strconv"
)

// Ring is a consistent-hash ring with virtual nodes: each physical node is hashed onto
// the circle `vnodes` times (as "node#0", "node#1", ...). Many small arcs per node average
// out the randomness of one big arc, so load evens out, and a departing node's keys
// scatter across many neighbours instead of landing on one.
//
// Not safe for concurrent use; wrap with a sync.RWMutex if nodes change while serving.
type Ring struct {
	vnodes int
	points []uint64          // sorted vnode positions
	owner  map[uint64]string // position → physical node
	nodes  map[string]bool
}

// NewRing returns an empty ring that places `vnodes` points per node (minimum 1).
func NewRing(vnodes int) *Ring {
	return &Ring{vnodes: max(vnodes, 1), owner: make(map[uint64]string), nodes: make(map[string]bool)}
}

func vnodeKey(node string, i int) string { return node + "#" + strconv.Itoa(i) }

// Add places node's vnodes on the ring. Adding an existing node is a no-op.
// O(v log P) for v vnodes and P points, plus a re-sort.
func (r *Ring) Add(node string) {
	if r.nodes[node] {
		return
	}
	r.nodes[node] = true
	for i := range r.vnodes {
		p := hash64(vnodeKey(node, i))
		if _, taken := r.owner[p]; taken {
			continue // 64-bit collision: astronomically rare; first owner keeps the point
		}
		r.owner[p] = node
		r.points = append(r.points, p)
	}
	slices.Sort(r.points)
}

// Remove takes all of node's vnodes off the ring.
func (r *Ring) Remove(node string) {
	if !r.nodes[node] {
		return
	}
	delete(r.nodes, node)
	r.points = slices.DeleteFunc(r.points, func(p uint64) bool {
		if r.owner[p] == node {
			delete(r.owner, p)
			return true
		}
		return false
	})
}

// Get returns the node that owns key, or "" if the ring is empty. O(log P).
func (r *Ring) Get(key string) string {
	if len(r.points) == 0 {
		return ""
	}
	h := hash64(key)
	i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h })
	if i == len(r.points) {
		i = 0
	}
	return r.owner[r.points[i]]
}

// Len is the number of points (vnodes) on the ring.
func (r *Ring) Len() int { return len(r.points) }
Test outputfrom the site build13 passed
=== RUN   TestHashSpreadsSimilarInputsAcrossTheSpace
--- PASS: TestHashSpreadsSimilarInputsAcrossTheSpace
=== RUN   TestHashAvalanche
    hash_test.go:31: average bits flipped between neighbouring names: 32.0 / 64
--- PASS: TestHashAvalanche
=== RUN   TestModuloIsDeterministic
--- PASS: TestModuloIsDeterministic
=== RUN   TestModuloEmpty
--- PASS: TestModuloEmpty
=== RUN   TestModuloSpreadsLoadEvenly
    modulo_test.go:24: load: max 1.018× mean, min 0.982× mean
--- PASS: TestModuloSpreadsLoadEvenly
=== RUN   TestModuloAddingANodeMovesMostKeys
    modulo_test.go:38: 4 → 5 nodes: 80.1% of keys moved (ideal would be 20%)
--- PASS: TestModuloAddingANodeMovesMostKeys
=== RUN   TestRingEmpty
--- PASS: TestRingEmpty
=== RUN   TestRingSingleNodeOwnsEverything
--- PASS: TestRingSingleNodeOwnsEverything
=== RUN   TestRingPlacesVnodesPerNode
--- PASS: TestRingPlacesVnodesPerNode
=== RUN   TestRingAddingANodeOnlyMovesKeysToIt
    ring_test.go:58: 4 → 5 nodes: 18.4% of keys moved (ideal 1/5 = 20%)
--- PASS: TestRingAddingANodeOnlyMovesKeysToIt
=== RUN   TestRingRemovalSpreadsKeysAcrossSurvivors
    ring_test.go:82: node-2's keys went to: map[node-0:6311 node-1:3968 node-3:3575 node-4:8010]
--- PASS: TestRingRemovalSpreadsKeysAcrossSurvivors
=== RUN   TestRingVnodesEvenOutLoad
    ring_test.go:91: 160 vnodes/node: max 1.14× mean, min 0.89× mean
--- PASS: TestRingVnodesEvenOutLoad
=== RUN   TestRingVnodeSweep
    ring_test.go:104: vnodes=1    points=10    max 2.24× min 0.08×
    ring_test.go:104: vnodes=10   points=100   max 1.26× min 0.61×
    ring_test.go:104: vnodes=50   points=500   max 1.25× min 0.85×
    ring_test.go:104: vnodes=100  points=1000  max 1.16× min 0.82×
    ring_test.go:104: vnodes=200  points=2000  max 1.11× min 0.84×
    ring_test.go:104: vnodes=500  points=5000  max 1.11× min 0.89×
--- PASS: TestRingVnodeSweep
PASS
ok  	shardedmind/builds/hash-ring/03-vnodes

Run it on your machine:cd builds/hash-ring/steps/03-vnodes && go test -v ./...