Distributed peer-aware cache for Go: consistent hashing, groupcache-style.
cache-cluster adds peer-aware distribution on top of any github.com/ubgo/cache backend. A consistent-hash ring with virtual nodes deterministically assigns every key to one owning node; reads for keys you do not own are proxied over HTTP to their owner; the owner fills a miss exactly once via your loader, deduped by single-flight. This is the groupcache pattern expressed through the ubgo/cache interface — a hot key is loaded once cluster-wide, not once per node and not once per concurrent request.
Documentation: a full per-feature cookbook with use cases and runnable snippets for the Ring, Node, options, and the peer-fill + single-flight semantics lives in
docs/README.md.
- One fill per hot key, cluster-wide. Ownership routing plus single-flight at the owner collapses a thundering herd into a single backend load.
- Minimal reshuffle on membership change. Consistent hashing means adding or removing a node only moves that node's share of keys, not the whole keyspace.
- Bring your own storage. Any
cache.Cache(in-memory, Redis, …) is the local backend; clustering is a thin layer on top. - No third-party dependencies. Standard library HTTP plus the ubgo/cache contract. The core and this module are dependency-free.
- Explicit, predictable membership. Peers are configured, not gossiped — no surprise topology changes.
- Consistent-hash ring with configurable virtual nodes per peer (
NewRing). - Owner-routed
Get/Set/Del/Has. - Read-through
Loaderinvoked only at the owning node. - Single-flight de-duplication of concurrent fills for the same key at the owner.
Handler()exposes a minimalGET/PUT/DELETE /_cache?key=…peer protocol.- Pluggable HTTP client, fill TTL, and explicit peer map.
go get github.com/ubgo/cache-clusterRequires Go 1.24+.
package main
import (
"context"
"net/http"
clustercache "github.com/ubgo/cache-cluster"
memcache "github.com/ubgo/cache-mem"
)
func main() {
local := memcache.New()
node := clustercache.New("n1", local,
clustercache.WithPeers(map[string]string{
"n1": "http://10.0.0.1:8080",
"n2": "http://10.0.0.2:8080",
"n3": "http://10.0.0.3:8080",
}),
clustercache.WithLoader(func(ctx context.Context, key string) ([]byte, error) {
return db.Load(ctx, key)
}),
)
defer node.Close()
http.Handle("/_cache", node.Handler()) // peers reach each other here
go http.ListenAndServe(":8080", nil)
v, err := node.Get(context.Background(), "user:42") // owner-routed, filled once
_ = v
_ = err
}flowchart TD
subgraph Ring[CRC32 ring 0 .. 2^32-1]
VN1[n1#0]
VN2[n2#0]
VN3[n3#0]
VN4[n1#1]
VN5[n2#1]
VN6[n3#1]
end
K[hashKey key] --> SRCH[sort.Search:<br/>first point greater-or-equal h<br/>wrap to 0 at end]
SRCH --> OWN[owner of that point]
Note[64 virtual nodes per peer<br/>spread load + bound reshuffle]
sequenceDiagram
participant C as Client (non-owner n2)
participant O as Owner n1
participant SF as single-flight @ n1
participant L as Loader (DB)
C->>C: ring.Owner(key) = n1, not self
C->>O: GET /_cache?key=k
O->>O: local.Get(k) miss
O->>SF: flight.Do(k)
SF->>SF: re-check local.Get(k)
SF->>L: loader(ctx, k)
L-->>SF: value
SF->>O: local.Set(k, v, fillTTL)
O-->>C: 200 value
Note over SF: concurrent requests for k<br/>wait on the same flight,<br/>load runs exactly once
The ring is usable standalone (it is exported) and is also what Node uses internally.
Builds a ring with replicas virtual nodes per peer. replicas <= 0 defaults to 64. More virtual nodes spread keys more evenly and reduce the variance of how many keys move when membership changes.
r := clustercache.NewRing(128, "n1", "n2", "n3")Inserts a peer and its virtual nodes (idempotent — re-adding an existing peer is a no-op). Safe for concurrent use.
r.Add("n4")Drops a peer and all its virtual nodes. Keys owned by the removed peer redistribute to the remaining peers; keys owned by other peers do not move (the no-rebalance-on-remove property of consistent hashing).
r.Remove("n2")Returns the peer that owns key, or "" if the ring is empty. The owner is the first virtual-node point clockwise from hashKey(key), wrapping around the end of the ring.
who := r.Owner("user:42") // e.g. "n3"Returns current membership (unordered).
for _, p := range r.Peers() {
fmt.Println(p)
}Builds a node identified by self, backed by a local cache.Cache. The node's own id is added to its ring immediately so a single-node cluster works before any peers are configured.
node := clustercache.New("n1", memcache.New())Sets the full membership as id -> base URL (must include self). Every id is added to the ring; the URL map is how Node reaches owners over HTTP.
clustercache.WithPeers(map[string]string{
"n1": "http://10.0.0.1:8080",
"n2": "http://10.0.0.2:8080",
})Sets the read-through loader. It is invoked only at a key's owner on a miss. Without a loader, a miss is simply cache.ErrNotFound.
clustercache.WithLoader(func(ctx context.Context, key string) ([]byte, error) {
return db.Load(ctx, key)
})TTL applied when a loaded value (or a Set) is stored at the owner. 0 (default) means no expiry.
clustercache.WithFillTTL(10 * time.Minute)Overrides the client used for peer requests (default: http.Client{Timeout: 5s}). Use this to tune timeouts, transport pooling, or TLS.
clustercache.WithHTTPClient(&http.Client{Timeout: 2 * time.Second})Get(ctx, key)— if this node ownskey, serve locally and single-flight the loader on a miss; otherwise proxy aGETto the owner.Set(ctx, key, val)— store at the owner (locally if owned, elsePUTto the owner). Uses the configured fill TTL.Del(ctx, key)— delete at the owner.Has(ctx, key)— presence check, owner-routed; does not trigger the loader.Close()— closes the local backend.
ctx := context.Background()
_ = node.Set(ctx, "k", []byte("v"))
v, err := node.Get(ctx, "k")
ok, _ := node.Has(ctx, "k")
_ = node.Del(ctx, "k")
_ = v; _ = err; _ = okReturns the HTTP handler peers use to reach this node. Mount it at the base URL advertised in WithPeers:
http.Handle("/_cache", node.Handler())Routes (all on /_cache?key=…):
| Method | Behaviour | Status |
|---|---|---|
GET |
value; load-fills via the Loader exactly like a local Get | 200, or 404 if absent / no loader |
PUT |
store the request body at this node | 204 |
DELETE |
delete the key at this node | 204 |
A peer GET invoking the loader at the owner is precisely what makes peer fill work: every node routes to the owner, and only the owner loads.
Owner(key) hashes the key with CRC32, then binary-searches the sorted ring of virtual-node points for the first point clockwise (wrapping at the end). The peer that registered that point owns the key.
Only the keys whose nearest clockwise point changed move. Removing a peer redistributes just that peer's share to its clockwise neighbours; keys owned by unaffected peers stay put. This is the core consistent-hashing property and the reason virtual nodes exist (they smooth the distribution).
Once per key at the owner, even under concurrent load. The owner wraps the loader in a single-flight group keyed by the cache key; concurrent callers wait on the in-flight load and share its result. A second re-check inside the flight handles the case where another flight just filled it.
No. Membership is explicit via WithPeers. Gossip / auto-discovery is intentionally out of scope to keep topology predictable; wire your own membership source and call Ring.Add / Ring.Remove if you need dynamic membership.
Any cache.Cache. cache-mem is the common choice for an in-process tier; a Redis-backed cache.Cache works too if you want a shared per-node store.
| cache-cluster | groupcache | |
|---|---|---|
| Ownership | Consistent-hash ring (virtual nodes) | Consistent-hash ring |
| Single-flight at owner | Yes | Yes |
| Local backend | Any cache.Cache (pluggable) |
Built-in LRU only |
Writes (Set/Del) |
Owner-routed, supported | Read-mostly (no general writes) |
| Peer transport | HTTP (/_cache) |
HTTP / protobuf |
| Membership | Explicit WithPeers |
Explicit peer list |
cache-cluster keeps groupcache's load-once-per-key model but lets you choose the local store and supports owner-routed writes and deletes through the ubgo/cache interface.
github.com/ubgo/cache— the cache contract and shared helpers.github.com/ubgo/cache-mem— fast in-process backend for each node.