Building a Distributed Redis Cluster in Go Using Simple Consistent Hash

This article walks through creating a functional Redis service called EasyRedis, focusing on implementing a distributed cluster with a consistent‑hash algorithm in Go, including detailed code examples, node mapping, command routing, and relay mechanisms.

Nullbody Notes
Nullbody Notes
Nullbody Notes
Building a Distributed Redis Cluster in Go Using Simple Consistent Hash

We introduce EasyRedis, a multi‑module Go project that implements a usable Redis service, and we focus on the distributed cluster component built with a consistent‑hash algorithm.

Consistent Hash Algorithm

When sharding data across nodes, the naive approach node = hashCode(key) % n changes the node assignment for most keys whenever the number of nodes n changes, causing costly data reshuffling. Consistent hashing maps both keys and server addresses into a 2^32‑size ring so that only keys near a changed node need to be remapped.

Nodes are placed on the ring by hashing their IP address; a key’s hash is then located by a clockwise search to the first node encountered. Virtual nodes are used to improve distribution without adding physical servers.

Code Implementation

The consistent‑hash code resides in tool/consistenthash/consistenthash.go:

type HashFunc func(data []byte) uint32

type Map struct {
    hashFunc  HashFunc       // hash function
    replicas  int            // number of virtual nodes per real node
    hashValue []int          // sorted hash values
    hashMap   map[int]string // hash value → real node address
}

func New(replicas int, fn HashFunc) *Map {
    m := &Map{replicas: replicas, hashFunc: fn, hashMap: make(map[int]string)}
    if m.hashFunc == nil {
        m.hashFunc = crc32.ChecksumIEEE
    }
    return m
}

Adding nodes generates replicas hash values per IP address and stores them in a sorted slice:

func (m *Map) Add(ipAddrs ...string) {
    for _, ipAddr := range ipAddrs {
        if ipAddr == "" { continue }
        for i := 0; i < m.replicas; i++ {
            hash := int(m.hashFunc([]byte(strconv.Itoa(i) + ipAddr)))
            m.hashValue = append(m.hashValue, hash)
            m.hashMap[hash] = ipAddr
        }
    }
    sort.Ints(m.hashValue)
}

Finding the node for a key searches the first hash value greater than or equal to the key’s hash, wrapping around to the first element if necessary:

func (m *Map) Get(key string) string {
    if m.IsEmpty() { return "" }
    partitionKey := getPartitionKey(key)
    hash := int(m.hashFunc([]byte(partitionKey)))
    idx := sort.Search(len(m.hashValue), func(i int) bool { return m.hashValue[i] >= hash })
    if idx == len(m.hashValue) { idx = 0 }
    return m.hashMap[m.hashValue[idx]]
}

func getPartitionKey(key string) string {
    beg := strings.Index(key, "{")
    if beg == -1 { return key }
    end := strings.Index(key, "}")
    if end == -1 || end == beg+1 { return key }
    return key[beg+1 : end]
}

Cluster Implementation

The cluster code lives in cluster/cluster.go. When the cluster starts, it reads the peers list from the configuration, creates a consistent‑hash map with 100 virtual nodes per real node, and adds all peer addresses plus its own address:

const replicas = 100

type Cluster struct {
    self        string
    clientFactory *RedisConnPool
    engine      *engine.Engine
    consistHash *consistenthash.Map
}

func NewCluster() *Cluster {
    cluster := Cluster{clientFactory: NewRedisConnPool(), engine: engine.NewEngine(), self: conf.GlobalConfig.Self}
    cluster.consistHash = consistenthash.New(replicas, nil)
    // deduplicate peers
    contains := make(map[string]struct{})
    peers := make([]string, 0, len(conf.GlobalConfig.Peers)+1)
    for _, peer := range conf.GlobalConfig.Peers {
        if _, ok := contains[peer]; ok { continue }
        peers = append(peers, peer)
    }
    peers = append(peers, cluster.self)
    cluster.consistHash.Add(peers...)
    return &cluster
}

When a client sends a Redis command, the cluster looks up the command handler in clusterRouter. Currently only set and get are registered and both delegate to defultFunc:

func (cluster *Cluster) Exec(c abstract.Connection, redisCommand [][]byte) (result protocol.Reply) {
    defer func() {
        if err := recover(); err != nil {
            logger.Warn(fmt.Sprintf("error occurs: %v
%s", err, string(debug.Stack())))
            result = protocol.NewUnknownErrReply()
        }
    }()
    name := strings.ToLower(string(redisCommand[0]))
    routerFunc, ok := clusterRouter[name]
    if !ok { return protocol.NewGenericErrReply("unknown command '"+name+"' or not support command in cluster mode") }
    return routerFunc(cluster, c, redisCommand)
}

var clusterRouter = make(map[string]clusterFunc)

func init() {
    clusterRouter["set"] = defultFunc
    clusterRouter["get"] = defultFunc
}

func defultFunc(cluster *Cluster, conn abstract.Connection, redisCommand [][]byte) protocol.Reply {
    key := string(redisCommand[1])
    peer := cluster.consistHash.Get(key)
    return cluster.Relay(peer, conn, redisCommand)
}

The Relay method decides whether the target peer is the local node; if so it executes the command locally via the engine, otherwise it obtains a connection from the pool and forwards the command to the remote node:

func (cluster *Cluster) Relay(peer string, conn abstract.Connection, redisCommand [][]byte) protocol.Reply {
    if cluster.self == peer {
        return cluster.engine.Exec(conn, redisCommand)
    }
    client, err := cluster.clientFactory.GetConn(peer)
    if err != nil { logger.Error(err); return protocol.NewGenericErrReply(err.Error()) }
    defer cluster.clientFactory.ReturnConn(peer, client)
    logger.Debugf("command:%q, forward to ip:%s", protocol.NewMultiBulkReply(redisCommand).ToBytes(), peer)
    reply, err := client.Send(redisCommand)
    if err != nil { logger.Error(err); return protocol.NewGenericErrReply(err.Error()) }
    return reply
}

Result

The cluster works for basic SET and GET operations, distributing keys according to the consistent‑hash ring. A screenshot of the running cluster is shown below:

Note that the current implementation stores cluster metadata in a static configuration file; production systems typically use gossip or Raft protocols for dynamic membership, which will be added in a future revision.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Backend DevelopmentRedisGoCode ExampleConsistent HashDistributed Cluster
Nullbody Notes
Written by

Nullbody Notes

Go backend development, learning open-source project source code together, focusing on simplicity and practicality.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.