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.