Embedded distributed cache in go with distcache
Distributed cache is a concept where instead of your application keep a local copy of cache in its memory, it retrieve the cache from remote server in the cluster. This helps improve your cache hits, cache coherency and prevent cache stampede/thundering herd.
Local cache means each server that’s making the expensive query/operation, cannot be discovered by other servers. As a result, your cache hit as a whole is low. Distributed cache using external server like Redis improve the cache hit because when 1 server retrieve the data and cache it in Redis, other servers can re-use the same data.
However, in Go, you could get the same benefit as distributed cache but without operating an external cache system like Redis.
Golang Groupcache#
Groupcache is a popular cache library first written by Brad Fitz for Google in 2013. There is not much activity in the repo these days but there are several interesting forks that adds new features as the industry evolves.
In short, this is how groupcache works:
- A group of servers form a cluster and communicates with each other using HTTP protocol
- The cluster then builds a consistent hash ring from those peers
- When a server request for a data, it will hash the cache key and find out which server owns the cache key
- The requestor server then calls the server that owns the cache key using HTTP protocol
- If the owning server has the data, it will return immediately to the requestor server. Otherwise, it will fetch from the origin.
Since there’s only 1 server that authoritatively owns the cache key, all servers in the cluster will connects to it to fetch the data. This means, the system only fetch the data once regardless the number of servers in the cluster.
A new fork from Mailgun team adds a some new important features like cache removal API and cache TTL.
Distcache#
distcache is another cool fork of groupcache library. In my opinion, distcache has a pretty good opinion on how the service discovery looks like. It supports the following peer discovery providers: Kubernetes API, NATS, Static IPs, DNS & Standalone (single‑node). This improves the quality of life a lot because unlike the original groupcache package, you no longer have update the server IP lists manually. It also support negative cache as its first class citizen, and support cache warmup on server cluster joins.
Here’s an example, adapted from Distcache advanced example:
package main
import (
"context"
"time"
"github.com/tochemey/distcache"
"github.com/tochemey/distcache/log"
)
func main() {
ctx := context.Background()
logger := log.DefaultLogger
bindPort := 9001
discoveryPort := 9002
bindAddress := "127.0.0.1"
source := newDataSource()
keyspace := newKeySpace(
"myData",
10 << 20, // 10MB cache size
source
)
cfg := distcache.NewStandaloneConfig(
[]distcache.KeySpace{keyspace},
distcache.WithLogger(logger),
distcache.WithBindAddr(bindAddress),
distcache.WithBindPort(bindPort),
distcache.WithDiscoveryPort(discoveryPort),
)
engine, _ := distcache.NewEngine(cfg) // In production, handle error correctly
engine.Start(ctx) // In production, handle error correctly
if kv, err := engine.Get(ctx, "myData", "p1"); err == nil {
logger.Infof("lookup of 'p1' returned %q", string(kv.Value))
}
// In production, handle graceful shutdown
}
// Keyspace determines how much space you want to allocate for the cache and TTL policy.
// You may have many keyspaces, each for its own use cases
type keySpace struct {
name string
maxBytes int64
dataSource distcache.DataSource
}
func newKeySpace(name string, maxBytes int64, source distcache.DataSource) *keySpace {
return &keySpace{name: name, maxBytes: maxBytes, dataSource: source}
}
func (k *keySpace) Name() string { return k.name }
func (k *keySpace) MaxBytes() int64 { return k.maxBytes }
func (k *keySpace) DataSource() distcache.DataSource { return k.dataSource }
func (k *keySpace) ExpiresAt(context.Context, string) time.Time { return time.Time{} }
// DataSource determines how the cache is filled, whether fetching data from DB or from HTTP call.
type dataSource struct {}
func newDataSource() *dataSource {
return &dataSource{}
}
func (d *dataSource) Fetch(_ context.Context, key string) ([]byte, error) {
v := "" // Your expensive operation: DB call, or HTTP call
return []byte(v), nil
}
The code for distcache is much more verbose compared to groupcache but you have so much more control over your data.
Conclusion#
Using embedded distributed cache, you can get the same benefit of Redis, without the burden of managing Redis instances. But operating embedded cache come with its own challenges. For example, when deploying, you cannot simply restart all machines at once, because the cache will be empty for all servers in the cluster. It’s recommended to do rolling restart when you start introducing embedded cache in your system.