Posted on January 11, 2024

High performance caching with Redis and Go

Go is an excellent language for building high performance web applications, and high performance web applications often require centralized caching.

The de facto standard for centralized caching is Redis, but—to my surprise and dismay—the popular Go libraries of today lack support for memory-efficient streaming. Instead, they offer []byte APIs which you interact with like so:

// This code uses https://github.com/redis/go-redis, but the same
// restrictions apply with Rueidis and Redigo.
func redisHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	// Extract key from RequestURI
	key := strings.TrimLeft(r.RequestURI, "/")

	// Get the value from Redis as a byte slice
	val, err := rdb.Get(ctx, key).Bytes()
	if err == redis.Nil {
		http.Error(w, "Key not found in Redis", http.StatusNotFound)
		return
	} else if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	_, err = w.Write(val)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

This pattern is not a problem if you're caching small objects, but if you're caching objects larger than 1kb, []byte-oriented APIs leave a significant footprint

Introducing Streaming Reads

There is nothing about the Redis protocol that precludes us from building a streaming API. So, I wrote redjet, a performance-oriented Redis library.

With redjet, you can write the above code like so:

func redisHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	// Extract key from RequestURI
	key := strings.TrimLeft(r.RequestURI, "/")

	// Stream the value directly from Redis to the response.
	_, err := rdb.Command("GET", key).WriteTo(w)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

No allocations! And, the code is a bit simpler.

Streaming Writes

Popular redis libraries suffer the same problem when it comes to writing values to Redis. They require you to hold the entire value in memory as a []byte before sending it across the wire.

With redjet, you can stream values to Redis like so:

fi := strings.NewReader("Some file contents")

err := rdb.Command("SET", "key", fi).Ok()
// handle error

There is an important caveat. In the Redis protocol, values are length-prefixed, so we can't stream a vanilla io.Reader. I speculate this is a major reason why popular libraries don't support streaming writes.

To get around this, redjet needs a redjet.LenReader which is defined as:

type LenReader interface {
	Len() int
	io.Reader
}

and may be created on the fly with redjet.NewLenReader:

Conveniently, some types in the standard library such as bytes.Reader, strings.Reader, and bytes.Buffer implicitly implement redjet.LenReader.

Benchmarks

I benchmarked redjet against:

Looking at the 1kb reads, here are the results:

Time per Operation

Library sec/op vs base
redjet 1.302µ ± 2% -
redigo 1.802µ ± 1% +38.42%
go-redis 1.713µ ± 3% +31.58%
rueidis 1.645µ ± 1% +26.35%

Bandwidth

Library B/s vs base
redjet 750.4Mi ± 2% -
redigo 542.1Mi ± 1% -27.76%
go-redis 570.3Mi ± 3% -24.01%
rueidis 593.8Mi ± 1% -20.87%

Memory Allocation

Library B/op vs base
redjet 0.000Ki ± 0% -
redigo 1.039Ki ± 0% ?
go-redis 1.392Ki ± 0% ?
rueidis 1.248Ki ± 1% ?

Allocations per Operation

Library allocs/op vs base
redjet 0.000 ± 0% -
redigo 3.000 ± 0% ?
go-redis 4.000 ± 0% ?
rueidis 2.000 ± 0% ?

Complete benchmark results are available here. I apologize in advance for the tricky formatting.