A typed cache-aside for Redis, built on the rueidis client. It combines rueidis client-side caching with distributed SET NX locking so that, across every process, only one caller populates a missing key while the rest wait on the invalidation push for the populated value. The result is a stampede-resistant cache behind a single generic Cache[K, V] interface.
- One generic interface —
Cache[K, V]for typed keys and values; fakeable in tests. - Stampede protection — in-process leader/follower coordination plus a distributed
SET NXlock-and-wait, so a single caller runs your loader per key and the rest wait on the invalidation rather than piling onto the origin. - Client-side caching — rueidis client-side cache with Redis invalidation messages cuts round trips and unblocks waiters the moment the value lands.
- Multi-key batching —
GetMultigroups operations by Redis cluster slot and executes the per-slot groups concurrently. - Refresh-ahead + XFetch — optional background refresh of stale-but-valid entries, with XFetch-style probabilistic early expiration to smear reload moments across the keyspace.
- Write-through priming —
Set/ForceSet/Touch(and their multi variants) populate or extend entries on every subscribed client without a read-through miss. - Typed keys and values — pluggable
Codec[V]andKeyCodec[K]; per-key partial failures surface as*BatchKeyError[K]. - Pluggable metrics — a
Metricsinterface for hits/misses, lock contention, lock-wait duration, and refresh events.
- Go 1.25+
- Redis 7+ with RESP3 and client-side caching (tracking) enabled
RESP3 client-side caching is load-bearing, not optional: redcache wakes waiters through Redis invalidation pushes. Without RESP3 (or with tracking disabled) there are no pushes — waiters fall back to jittered polling until the lock TTL, raising tail latency and Redis read traffic under contention.
go get github.com/dcbickfo/redcacheStandalone examples live under examples/. They compile with the
module and can be run directly against a local Redis:
go run ./examples/string-cache
go run ./examples/typed-cache
go run ./examples/metricsSet REDIS_ADDR to point them at a non-default Redis address.
v0.3.0 is the next minor release after v0.2.x. redcache is still pre-1.0, and
this minor intentionally breaks the old string-only API. These notes focus on
the interface changes needed for migration. Existing integrations on
*CacheAside or *PrimeableCacheAside do not need to adopt typed domain
keys immediately.
The direct replacement is usually one Conn plus a Cache[string, string]
view:
// v0.2.x
client, err := redcache.NewRedCacheAside(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
redcache.CacheAsideOption{
LockTTL: 5 * time.Second,
RefreshAfterFraction: 0.8,
RefreshWorkers: 4,
RefreshQueueSize: 64,
},
)
if err != nil {
return err
}
defer client.Client().Close()
val, err := client.Get(ctx, time.Minute, "user:123", loadString)// v0.3.x
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
redcache.WithLockTTL(5*time.Second),
redcache.WithRefreshAfterFraction(0.8),
redcache.WithRefreshWorkers(4),
redcache.WithRefreshQueueSize(64),
)
if err != nil {
return err
}
defer conn.Close()
cache := redcache.NewString[string](conn, redcache.StringCodec{})
val, err := cache.Get(ctx, time.Minute, "user:123", loadString)NewPrimeableCacheAside goes away too. You use the same Cache[string, string]
view for read-through and write-through methods:
// v0.2.x
client, err := redcache.NewPrimeableCacheAside(opt, redcache.CacheAsideOption{
LockTTL: 5 * time.Second,
})
if err != nil {
return err
}
defer client.Client().Close()
err = client.Set(ctx, time.Minute, "config:greeting", func(ctx context.Context, key string) (string, error) {
return "hello", nil
})// v0.3.x
conn, err := redcache.Open(opt, redcache.WithLockTTL(5*time.Second))
if err != nil {
return err
}
defer conn.Close()
cache := redcache.NewString[string](conn, redcache.StringCodec{})
err = cache.Set(ctx, time.Minute, "config:greeting", func(ctx context.Context, key string) (string, error) {
return "hello", nil
})- Replace
NewRedCacheAside/NewPrimeableCacheAsidewithOpen, then derive one or more views withNewString,New, orNewBytes. - Keep old string-key integrations on
redcache.NewString[string](conn, redcache.StringCodec{}). Move toNew[K, V]and aKeyCodec[K]only when you want domain-typed keys. - Move lifecycle and raw Redis access to the
Conn:cache.Close()/cache.Client()becomeconn.Close()/conn.Client(). A derivedCache[K, V]is operations-only and safe to inject into application code. - Replace
CacheAsideOption{...}fields with functional options:LockTTL->WithLockTTL,Logger->WithLogger,Metrics->WithMetrics,LockPrefix->WithLockPrefix,RefreshLockPrefix->WithRefreshLockPrefix,RefreshAfterFraction->WithRefreshAfterFraction,RefreshBeta->WithRefreshBeta,RefreshWorkers->WithRefreshWorkers,RefreshQueueSize->WithRefreshQueueSize, andClientBuilder->WithClientBuilder.WithRefreshTimeoutis new; omit it to use the datattlas the refresh callback budget, or set it explicitly for slower refresh functions. - If you implemented
Metricsdirectly, addLoaderDuration,LoaderErrors, andRedisError. Implementations that embedNoopMetricsonly need to override the methods they care about. DelMultiandTouchMultinow take[]K, matchingGetMultiandSetMulti: changecache.DelMulti(ctx, "a", "b")tocache.DelMulti(ctx, []string{"a", "b"}).- Partial multi-key write errors are now
*BatchKeyError[K]. For string-key caches, migratevar be *redcache.BatchErrorchecks tovar be *redcache.BatchKeyError[string]. - TTL-bearing methods now reject
ttl <= 0withErrInvalidTTLbefore touching Redis. UseDel/DelMultito remove entries. - The Go floor is now 1.25.
Once the string-key migration compiles, you can opt into typed values by
choosing a value codec, for example NewString[User](conn, redcache.JSONCodec[User]{}). That changes loaders from returning strings to
returning User directly, but it is not required for a straight v0.2.x
migration.
Open builds a Conn that owns a rueidis client; NewString[V] derives a Cache[string, V] view over it. Pair it with JSONCodec[V] to store JSON-encoded values. Get returns the cached value, calling your loader only on a miss — and only on one caller per key.
package main
import (
"context"
"log"
"time"
"github.com/redis/rueidis"
"github.com/dcbickfo/redcache"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
func main() {
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
redcache.WithLockTTL(5*time.Second),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})
ctx := context.Background()
// Get-with-loader: fn runs only on a cache miss, and only on one caller
// per key across all processes. The loader receives the key being missed.
u, err := cache.Get(ctx, time.Minute, "u-123",
func(ctx context.Context, key string) (User, error) {
// load from the database / upstream service
return User{ID: "u-123", Name: "alice"}, nil
},
)
if err != nil {
log.Fatal(err)
}
log.Printf("user: %+v", u)
}Absence semantics are the caller's to own — there is no ErrNotFound sentinel. Use a *T, sql.Null[T], or a domain sentinel inside V to cache "not found" and avoid cache penetration.
GetMulti follows the same pattern in batch: it returns the cached values and calls the loader once with just the missing keys.
users, err := cache.GetMulti(ctx, time.Minute, []string{"u-1", "u-2", "u-3"},
func(ctx context.Context, missing []string) (map[string]User, error) {
out := make(map[string]User, len(missing))
// load the missing keys in one round trip
for _, id := range missing {
out[id] = User{ID: id}
}
return out, nil
},
)Peek is a read-only, client-side-cached lookup — no loader, no lock. It returns
(value, true, nil) on a cached hit and (zero, false, nil) on a miss (or when
the key currently holds a lock value), for warm-cache checks without populating:
u, ok, err := cache.Peek(ctx, time.Minute, "u-123")
// ok == false means not currently cached; Peek never runs your loader.Derive a view with New[K, V] and a KeyCodec[K] to key the cache by a domain type. KeyCodecFunc[K] adapts a plain function into a KeyCodec[K].
type UserID int64
userIDCodec := redcache.KeyCodecFunc[UserID](func(id UserID) (string, error) {
return fmt.Sprintf("user:%d", id), nil
})
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.New[UserID, User](conn, userIDCodec, redcache.JSONCodec[User]{})
u, err := cache.Get(ctx, time.Minute, UserID(123),
func(ctx context.Context, id UserID) (User, error) {
return loadUser(ctx, id)
},
)The key codec must be deterministic, concurrent-safe, produce a non-empty key, and encode distinct logical keys to distinct Redis keys. Multi-key operations reject typed-key collisions before touching Redis.
NewBytes derives a zero-copy Cache[string, []byte] for opaque payloads — NewString[[]byte] preset with UnsafeBytesCodec. The decoded slice aliases the cache's borrowed read buffer; do not mutate or retain it past the callback. Copy it out if you need an owned value.
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.NewBytes(conn)
b, err := cache.Get(ctx, time.Minute, "blob:42",
func(ctx context.Context, key string) ([]byte, error) {
return loadBlob(ctx, key)
},
)
// b aliases borrowed memory — copy it before retaining or mutating.A Codec[V] maps values to and from the stored envelope payload; a KeyCodec[K] maps typed keys to the Redis key string. Both must be concurrent-safe.
| Codec | For | Allocation behavior |
|---|---|---|
JSONCodec[V] |
any V (default) |
Encode/Decode via encoding/json; returns fresh, caller-owned copies — safe to retain. |
StringCodec |
V = string |
Identity for immutable strings; cache fast paths may skip byte copies — safe to retain. |
UnsafeBytesCodec |
V = []byte |
Zero-copy. The decoded slice aliases borrowed library memory; do not mutate or retain it past the call. |
StringKeyCodec |
K = string |
Identity key codec; enables the K=string fast path. |
KeyCodecFunc[K] |
any K |
Adapts func(K) (string, error) into a KeyCodec[K]. |
The allocation tradeoff is explicit: JSONCodec returns owned decoded values,
and StringCodec returns immutable strings that are safe to retain while the
cache may avoid extra byte copies on string-valued views. UnsafeBytesCodec
skips the copy for throughput, but the []byte it returns borrows the cache's
internal buffer — it is only valid for the duration of the call and must not be
mutated. Likewise, a slice handed to Encode is given to the library and must
not be mutated afterward. Choose the copying codecs unless you have measured a
reason not to.
Decode failures on read are returned wrapped with redcache.ErrDecode (errors.Is-checkable). The library does not auto-evict on a decode failure; the caller decides whether to log, Del, or retry.
Construction takes functional options. They are applied in order; later wins.
| Option | Default | Description |
|---|---|---|
WithLockTTL(d) |
10s |
Bounds both how long a Redis lock survives and how long callers wait for one. Values below 100ms are rejected. |
WithLogger(l) |
slog.Default() |
Logger for errors and debug output. Must be concurrent-safe. |
WithMetrics(m) |
NoopMetrics{} |
Observability sink. Runs on the hot path; must be concurrent-safe. |
WithLockPrefix(p) |
__redcache:lock: |
Prefix tagged onto in-Redis lock values so reads recognise a lock as a miss. |
WithRefreshLockPrefix(p) |
__redcache:refresh: |
Prefix for refresh-ahead dedup keys. |
WithRefreshAfterFraction(f) |
0 (disabled) |
Enables refresh-ahead. Reads with low remaining TTL may trigger a background refresh. Must be in [0, 1). |
WithRefreshBeta(b) |
0 (XFetch off) |
Enables XFetch probabilistic sampling within the refresh window, weighted by recorded compute time. 1.0 matches canonical XFetch. |
WithRefreshTimeout(d) |
data ttl |
Bounds how long a refresh-ahead callback may run. Decoupled from LockTTL. |
WithRefreshWorkers(n) |
4 |
Refresh worker pool size. |
WithRefreshQueueSize(n) |
64 |
Pending-refresh queue capacity; over-full drops silently and the stale value keeps serving. |
WithClientBuilder(b) |
rueidis.NewClient |
Overrides how the internal client is built. A test seam (see Testing). |
Set WithRefreshAfterFraction to enable background refreshes. When a Get/GetMulti returns a value whose remaining TTL has crossed the configured threshold, the stale value is returned immediately and a background worker repopulates the entry. Distributed and local dedup ensure only one refresh runs per key.
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
redcache.WithLockTTL(5*time.Second),
redcache.WithRefreshAfterFraction(0.8), // refresh once 80% of TTL has elapsed
redcache.WithRefreshTimeout(20*time.Second),
redcache.WithRefreshWorkers(4),
redcache.WithRefreshQueueSize(64),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})Set WithRefreshBeta(b) with b > 0 to add the XFetch sampling layer on top of the floor. Below the floor, each read fires a refresh with probability proportional to how close the value is to expiry, weighted by how long the previous value took to compute. Slow-to-compute values get more headroom and refresh earlier; cheap values defer until closer to expiry. This smooths refresh load instead of bunching it at the floor crossing. 1.0 matches the canonical XFetch beta (Vattani et al.); for multi-key writes the recorded compute time is divided evenly across the returned values. That is an approximation: if one key in a batch dominates loader time and per-key XFetch precision matters, split that workload into separate Get calls or smaller GetMulti groups.
By default WithRefreshTimeout is the data ttl passed to Get/GetMulti, not LockTTL. This matters: the refresh callback's compute budget is independent of how long the lock is held. A refresh function that legitimately takes 20s is no longer silently cancelled by a 10s LockTTL. The refresh lock itself still uses LockTTL, and the back-write that records a slow-but-successful result is decoupled from the timeout so a successful-but-slow refresh keeps its write.
Values are stored with a small envelope (__redcache:v1:<delta_ns>:<payload>) capturing the compute time XFetch needs. Reads transparently unwrap it, and legacy un-enveloped values are served with delta=0, falling back to plain floor-based refresh.
Rollback warning: if a deployment writes values under the enveloped format and then rolls back to a pre-envelope release, those older clients will return the raw envelope string as the user value. Flush affected keys (or run a full cache invalidation) before rolling back.
rueidis already ships rueidisaside and rueidislock, and the core lock-and-wait stampede technique is shared between them and redcache: one caller wins a distributed lock, populates the key, and everyone else waits on the client-side-cache invalidation. If that single-key, single-value-type contract is all you need, rueidisaside is an excellent, well-maintained choice and you should reach for it first.
redcache adds, on top of that shared foundation:
GetMultiwith cluster-slot batching — multi-key reads and writes grouped by Redis cluster slot and executed concurrently per slot.- Refresh-ahead + XFetch — probabilistic early refresh of stale-but-valid entries, decoupling reload latency from request latency.
- Typed keys, not just values — a
KeyCodec[K]maps a domain key type to the Redis key, and multi-key write failures come back as a typed, per-key*BatchKeyError[K]. - Write-through priming —
Set/ForceSet/Touch(and multi variants) populate or extend entries without a read-through miss. This is the hardest piece forrueidisasideto absorb rather than just a missing feature:rueidisasideclaims only missing keys, with a single per-client placeholder and no value backup, whereasSetoverwrites an already-live value under a per-call lock token and restores the prior value if the write fails. In-place locking, per-call lock identity, and a backup/restore path are structural to redcache's model, not a flag on the read-miss-only one. Conn+New— open one connection and derive sibling typed caches that share its client, engine, and invalidation stream, so you can cache multiple value types over a single connection.
This is an honest superset for those specific needs, not a claim that rueidisaside is deficient — it deliberately keeps a smaller surface.
Open a Conn once, then derive typed Cache[K, V] views over it with New (or NewString / NewBytes). Every view shares the Conn's rueidis client and invalidation stream, with its own key/value types and codecs. Use it to cache several value types over a single Redis connection instead of opening one connection per type. Lifecycle lives on the Conn: the views are operations-only (no Close / Client), so closing happens in exactly one place. Open the Conn, derive all the views you need, and close the Conn when done.
// One connection backs several typed views.
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
log.Fatal(err)
}
defer conn.Close() // closes the shared client and all views
sessions := redcache.NewString[Session](conn, redcache.JSONCodec[Session]{})
orders := redcache.New[OrderID, Order](
conn,
orderIDCodec,
redcache.JSONCodec[Order]{},
)New (and NewString / NewBytes) does no I/O — it returns a Cache[K, V] with no error, and panics on a nil codec. The constructors are exactly Open (which builds the Conn and owns the client) plus the New / NewString / NewBytes derive-funcs; close the Conn to tear down the underlying client and every view derived from it.
For a service that caches several value types, open one Conn, derive a view per type, and inject the views. Because views are operations-only, the injected code can read and write the cache but cannot close the shared connection — lifecycle stays with whoever holds the Conn:
conn, err := redcache.Open(rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}})
if err != nil {
log.Fatal(err)
}
defer conn.Close()
users := redcache.NewString[User](conn, redcache.JSONCodec[User]{})
sessions := redcache.NewString[Session](conn, redcache.JSONCodec[Session]{})
// Inject `users` and `sessions` (operations-only Cache values) into services.
userSvc := NewUserService(users)
sessionSvc := NewSessionService(sessions)Implement Metrics, or embed NoopMetrics and override only the methods you care about, to wire counters into Prometheus, OpenTelemetry, or any other backend. Methods run on the hot path and must be concurrent-safe.
High-volume events (CacheHits, CacheMisses, LockContended, RefreshTriggered, RefreshSkipped, RefreshDropped) are aggregated per operation and emitted once with a count rather than once per key. LockWaitDuration fires once per resolved wait, and LoaderDuration once per foreground origin-loader call (Get/GetMulti miss, Set/SetMulti — background refresh excluded). LoaderErrors(n) fires when a foreground loader returns an error, with n the number of keys it was responsible for. RedisError(op) fires when a Redis command fails, tagged with op ("read", "lock", "set", "del", "touch"). Diagnostic events (LockLost, RefreshError, RefreshPanicked, InvalidationError) carry the affected key where applicable.
type myMetrics struct {
redcache.NoopMetrics
hits, misses atomic.Int64
}
func (m *myMetrics) CacheHits(n int64) { m.hits.Add(n) }
func (m *myMetrics) CacheMisses(n int64) { m.misses.Add(n) }
conn, err := redcache.Open(
rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
redcache.WithMetrics(&myMetrics{}),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})For OpenTelemetry, use the redcacheotel subpackage — a drop-in Metrics adapter you import as github.com/dcbickfo/redcache/redcacheotel:
import "github.com/dcbickfo/redcache/redcacheotel"
m, err := redcacheotel.NewMetrics(meterProvider) // metric.MeterProvider (e.g. your own *sdkmetric.MeterProvider)
if err != nil {
log.Fatal(err)
}
conn, err := redcache.Open(opt, redcache.WithMetrics(m))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})It records counters (hits, misses, lock contention, refresh and error events) and histograms (lock.wait.duration, loader.duration, in seconds). High-cardinality keys are deliberately not attached as labels; RedisError's bounded op is.
Importing redcache's core does not pull OpenTelemetry into your binary (verified: zero otel symbols linked) — OTel is only compiled in if you import redcacheotel. It does appear in the module graph, since it lives in the main module.
Depend on the redcache.Cache[K, V] interface in your own code, not on a concrete type. Then in unit tests substitute the in-memory fake from the redcachetest subpackage — no Redis required:
import "github.com/dcbickfo/redcache/redcachetest"
func TestUserService(t *testing.T) {
cache := redcachetest.New[string, User]() // satisfies redcache.Cache[string, User]
svc := NewUserService(cache)
// exercise svc; the fake calls your loader, stores results, and honours TTL.
}redcachetest.New[K, V]() returns a *Fake[K, V] that satisfies redcache.Cache[K, V], backed by a map with TTL semantics. It validates the observable single-process contract — your loader runs once per miss, a present unexpired entry is a hit, TTLs expire — which is enough to test loaders, wiring, and call shape. For deterministic expiry tests, construct it with redcachetest.NewWithClock[K, V](clk) and advance a redcachetest.Clock by hand instead of sleeping.
What the fake does not model: distributed single-flight, the SET NX lock layer, client-side-cache invalidation pushes, the stored envelope, or refresh-ahead. Those only emerge against real Redis. For fuller fidelity, drive the real redcache.Cache against rueidis/mock via WithClientBuilder (note: miniredis cannot emulate RESP3 client-side invalidation, so it is unsuitable here).
If you can't or don't want to enable refresh-ahead, the lock layer already prevents per-key thundering herd: only one caller per key runs the origin function while peers wait on the cached invalidation. The remaining stampede risk is cross-key simultaneous expiry — many keys SET in the same window (deploys, batch imports, cold-start backfill) all expire together.
This library deliberately does not jitter the ttl you pass to Get/GetMulti/Set/ForceSet: the contract is that you get the TTL you asked for. Jitter expiries at the call site instead, so callers retain control over the policy:
ttl := baseTTL + time.Duration(rand.Int64N(int64(baseTTL/10))) // ±10%
val, err := cache.Get(ctx, ttl, key, fetch)For workloads that already use WithRefreshAfterFraction + WithRefreshBeta, XFetch handles this naturally — the probabilistic refresh window smears reload moments across the keyspace without changing observable TTLs.
# Install pinned Go/golangci-lint versions via asdf
make setup
# Start Redis
docker compose up -d
# Run tests (requires Redis on localhost:6379)
make test-race
# Check coverage against the current baseline
make cover-check
# Lint
make lint
# Benchmarks
make bench