rgoussu@goussu: ~/library/go/storage
~/library/go/storage cat redis.md

Redis from Go

# go-redis as the default client, rueidis and redigo alternatives, and the cache, lock, rate-limit, and streaming patterns built on them.

Conceptsaved 2026-08-09 #go#storage#redis#caching

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 and TxPipelined (MULTI/EXEC), Lua via Script, and a hook interface used by redisotel for 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 → SET with 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/singleflight in-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 Subscribe returns a *PubSub whose Channel() 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, XAUTOCLAIM to 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