Overview
Redis fits Go unusually well: a simple wire protocol, connection pooling that goroutines share
naturally, and latencies where Go's scheduler shines. The client question is effectively
settled — redis/go-redis v9 is the default — with rueidis as the performance-minded
challenger and redigo as the legacy option still found in older codebases. The interesting
material is the patterns on top: cache-aside, distributed locks (and their caveats), rate
limiting, and choosing between pub/sub and streams.
Key points
- go-redis (redis/go-redis v9) is the default: typed commands (
Get(ctx, k)returning*StringCmd), built-in pooling, single-node/cluster/sentinel/ring clients behind one API, pipelines andTxPipelined(MULTI/EXEC), Lua viaScript, and a hook interface used byredisotelfor tracing/metrics. - rueidis trades familiarity for throughput: automatic command pipelining across goroutines, RESP3, and client-side caching (server-assisted invalidation) — significant wins for read-heavy workloads, at the cost of a less conventional API.
- redigo (gomodule/redigo) is the old guard:
Conn.Do("GET", key)with untyped replies and manual pool handling. Fine where it exists; not a choice for new code. - Cache-aside is the dominant use: read → miss → load from source →
SETwith TTL. Always set TTLs; decide explicitly what a Redis outage means (fail open vs fail closed). - Distributed locks: go-redsync/redsync implements Redlock over one or more instances. Know the caveats (Kleppmann's critique): without fencing tokens a paused client can act on a lock it lost — use Redis locks for efficiency (avoid duplicate work), not correctness.
- Rate limiting: go-redis/redis_rate implements GCRA on Redis — atomic via Lua, shared across instances; pairs with idempotency keys for API surfaces.
- Pub/sub vs streams: pub/sub is fire-and-forget (missed if not subscribed); streams
(
XADD/XREADGROUP) give persistence, consumer groups, acknowledgement, and replay — the Kafka-shaped choice inside Redis. - Serialization is yours: values are bytes. JSON is the lazy default; msgpack/protobuf cut size and CPU; whatever you pick, version the schema — cached values outlive deploys.
Details
Client landscape
| Client | Model | Distinguishers |
|---|---|---|
| go-redis v9 | pooled, typed commands | cluster/sentinel support, hooks (OTel), the ecosystem default |
| rueidis | auto-pipelining | RESP3, server-assisted client-side caching, highest throughput |
| redigo | pooled, untyped Do |
minimal, stable, legacy codebases |
go-redis unless a benchmark on your workload says rueidis; the auto-pipelining gain is real for many small concurrent reads, negligible for large values or low concurrency.
Lock and cache discipline
- Lock acquisition is
SET key val NX PX ttl; release must be a Lua compare-and-delete so you never delete a lock another client now holds. redsync packages exactly this plus retry and multi-instance quorum. - Cache stampedes: on hot-key expiry, many goroutines rebuild at once — use
golang.org/x/sync/singleflightin-process, or probabilistic early expiry across instances. - Client-side caching (rueidis) moves hot reads to process memory with Redis invalidating — effectively a coherent L1; mind memory bounds per instance.
Pub/sub vs streams, concretely
- go-redis
Subscribereturns a*PubSubwhoseChannel()feeds a Go channel — natural fan-in, but delivery is at-most-once per connected subscriber. - Streams via
XAdd/XReadGroup/XAck: at-least-once with explicit acks,XAUTOCLAIMto steal entries from dead consumers; you own the reclaim loop — there is no container doing it.
Examples
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Cache-aside with singleflight to stop stampedes
var g singleflight.Group
func GetUser(ctx context.Context, id string) (*User, error) {
if b, err := rdb.Get(ctx, "user:"+id).Bytes(); err == nil {
return decode(b)
} else if err != redis.Nil {
return nil, err // real Redis error: decide fail-open vs fail-closed
}
v, err, _ := g.Do(id, func() (any, error) {
u, err := loadFromDB(ctx, id)
if err != nil {
return nil, err
}
_ = rdb.Set(ctx, "user:"+id, encode(u), 5*time.Minute).Err()
return u, nil
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
Related
- Storage access from Go — the map — parent overview by storage kind.
- Cache management — invalidation, TTL, and stampede strategy independent of client.
- Rate limiting and idempotency — the design patterns redis_rate implements.
- Redis from Java — the same store from the JVM: Jedis/Lettuce/Redisson where Go has go-redis/rueidis.