A Go cache library with two independent in-memory designs: a multi-index collection cache and a per-key TTL cache. Redis support is available in separate subpackages.
The root package and ttlcache use only the standard library. Redis code is
linked only when a program imports rediscache or ttlcache/redisadapter.
See Dependency footprint.
- Multi-index lookup: O(1) lookups by several keys (ID, email, phone, …)
- Thread-safe: safe for concurrent readers and writers
- Reproducible change detection: a content hash that is stable across processes
- Redis support: collection and TTL adapters live in separate subpackages
- Hybrid cache: explicit L1/L2 composition for both cache designs
- Stdlib-only memory packages: the root package and
./ttlcachedo not link Redis - Generic: works with any value type through Go generics
- Fluent collection configuration: builder methods for memory and Redis collection caches
- Per-key TTL cache: independent expiration, LRU/cost limits, statistics and a test clock in
./ttlcache - Request coalescing: optional stale-while-revalidate loading in
./ttlcache/loader - Redis-backed TTL KV: optional
redis-kitadapter and explicit cache-aside hybrid composition
- Go 1.27.4+ (
go.moddeclaresgo 1.27.4) github.com/redis/go-redis/v9when importing./rediscacheor./ttlcache/redisadaptergithub.com/soulteary/redis-kitwhen importing./ttlcache/redisadapter- Redis with
EVALsupport (any 2.6+) whenMaxValueBytesis set
The cache uses Pipeline and TxPipeline on the client supplied by the caller. With go-redis v9.23.0 or newer, callers that rely on PoolSize or MaxActiveConns as a connection budget must configure PipelinePoolSize: -1 when creating each standalone, failover, cluster or ring client. Otherwise go-redis can create an additional dedicated pipeline pool. The cache does not own or mutate an existing client; its constructors cannot retroactively change that client’s pools. The examples below use the shared-pool setting.
go get github.com/soulteary/cache-kit/v2Coming from v1? See Upgrade Notes (v2.0.0).
package main
import (
"fmt"
cache "github.com/soulteary/cache-kit/v2"
)
type User struct {
ID string
Email string
Phone string
Name string
}
func main() {
config := cache.DefaultConfig[User]().
WithPrimaryKey(func(u User) string { return u.ID })
c := cache.NewMultiIndexCache(config)
c.AddIndex("email", func(u User) string { return u.Email })
c.AddIndex("phone", func(u User) string { return u.Phone })
c.Set([]User{
{ID: "1", Email: "alice@example.com", Phone: "1111111111", Name: "Alice"},
{ID: "2", Email: "bob@example.com", Phone: "2222222222", Name: "Bob"},
})
user, ok := c.Get("1") // by primary key
user, ok = c.GetByIndex("email", "bob@example.com") // by index
user, ok = c.GetByIndex("phone", "1111111111")
_ = user
_ = ok
fmt.Println("cache hash:", c.GetHash())
}The TTL cache is separate from the collection-oriented multi-index cache. Each key has its own lifetime; expired values are never returned, even when the background cleanup interval has not elapsed.
import (
"time"
"github.com/soulteary/cache-kit/v2/ttlcache"
)
pages := ttlcache.New(ttlcache.Config[string, []byte]{
DefaultTTL: 10 * time.Minute,
CleanupInterval: time.Minute,
MaxEntries: 1_000,
MaxCost: 64 << 20,
Cost: func(_ string, body []byte) int64 { return int64(len(body)) },
})
defer func() { _ = pages.Close() }()
_ = pages.Set("https://example.com/article", html, 5*time.Minute)
body, ok := pages.Get("https://example.com/article")
stats := pages.Stats()
_, _, _ = body, ok, statsThe TTL argument to Set applies to that key. Pass 0 to use DefaultTTL, or
ttlcache.NoExpiration to keep the value until deletion or eviction. Reads
remove expired entries synchronously, so CleanupInterval controls memory
reclamation rather than correctness. Set it to zero to disable the cleanup
goroutine.
Capacity is optional. MaxEntries limits the number of keys; MaxCost and
MaxItemCost use the value returned by Cost. When a limit is exceeded, the
least recently used entries are removed. Eviction callbacks run after the
cache lock is released and may safely call the cache again.
Optional packages add loading and storage policy without changing the core:
ttlcache/loader: same-key request coalescing, negative caching, TTL jitter, and stale-while-revalidate.ttlcache/redisadapter: adaptsredis-kit/cacheto the TTL store contract.ttlcache/hybrid: explicit cache-aside L1/L2 composition. An L2 hit is inserted into L1 using the remaining L2 TTL, never a fresh full TTL.
This is a generic expiring key/value cache, not an implementation of HTTP cache
semantics such as Vary, validators, revalidation, or Cache-Control.
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/soulteary/cache-kit/v2/rediscache"
)
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{Addr: "localhost:6379", PipelinePoolSize: -1})
defer client.Close()
config := rediscache.DefaultConfig().
WithKeyPrefix("myapp:users:").
WithTTL(30 * time.Minute)
c := rediscache.New[User](client, config)
if err := c.Set(ctx, []User{{ID: "1", Name: "Alice"}}); err != nil {
panic(err)
}
users, err := c.Get(ctx)
if err != nil {
panic(err)
}
_ = users
// A monotonically increasing counter, useful for "has anyone else written?"
version, _ := c.GetVersion(ctx)
fmt.Println("cache version:", version)
}package main
import (
"context"
"github.com/redis/go-redis/v9"
cache "github.com/soulteary/cache-kit/v2"
"github.com/soulteary/cache-kit/v2/rediscache"
)
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{Addr: "localhost:6379", PipelinePoolSize: -1})
defer client.Close()
memConfig := cache.DefaultConfig[User]().
WithPrimaryKey(func(u User) string { return u.ID })
redisConfig := rediscache.DefaultConfig().WithKeyPrefix("users:")
c := rediscache.NewHybrid[User](memConfig, client, redisConfig)
c.AddIndex("email", func(u User) string { return u.Email })
// Writes memory first, then Redis.
if err := c.Set(ctx, []User{{ID: "1", Email: "alice@example.com"}}); err != nil {
panic(err)
}
user, ok := c.GetByIndex("email", "alice@example.com") // served from memory
_, _ = user, ok
c.LoadFromRedis(ctx) // warm memory on startup
c.SyncToRedis(ctx) // push memory to Redis
}GetHash() returns a content hash of everything in the cache. Two caches
holding equal values produce equal hashes, in this process and in the next one.
The default hash walks each value with reflection and includes every exported field, whatever its struct tags say:
type User struct {
ID string
Email string
Password string `json:"-"` // excluded from JSON, still hashed
}That matters because a value whose Password changed is observably different
through Get and GetAll; a hash that skipped it would report "no change"
for a change that happened.
Equally, the hash never looks at memory addresses, so MemoryCache[*User]
hashes its contents rather than its layout — the same data hashes the same
after a restart or a reallocation. time.Time hashes by instant, so a
monotonic reading does not make two equal timestamps differ.
Supply one with WithHashFunc for:
-
Values with sensitive fields. The default includes passwords and tokens in the hash input. Hash only the stable, non-sensitive fields instead.
-
Types whose identity lives in unexported fields, channels or funcs. Those contribute only their type to the default hash.
-
Maps keyed by pointers, channels, or interfaces holding either. Go compares such keys by identity while a content hash can only see what they point at. Given two distinct
*intboth addressing1, these two maps differ observably —m[a]is"x"in one and"y"in the other — yet no function of content alone can separate them:map[*int]string{a: "x", b: "y"} map[*int]string{a: "y", b: "x"}
Hashing the addresses would distinguish them and make the digest differ between runs, which this hash must never do.
-
Maps keyed by
time.Time, at any depth. Its==compares the*Locationpointer and the monotonic reading, not only the instant, sot.UTC()andt.In(time.FixedZone("z", 0))are two distinct keys that can coexist in one map. Both a location pointer and a monotonic reading are process-local, so a reproducible hash cannot follow either.
config := cache.DefaultConfig[User]().
WithPrimaryKey(func(u User) string { return u.ID }).
WithHashFunc(func(users []User) string {
h := sha256.New()
for _, u := range users { // callers control the order; see WithSortFunc
fmt.Fprintf(h, "%s\x1f%s\x1e", u.ID, u.Email)
}
return hex.EncodeToString(h.Sum(nil))
})Use WithSortFunc (or cache.StringSorter) when the input order varies but the
hash must not.
Index keys are normalized — lower-cased and trimmed — before storage.
Primary keys are not. Get("ABC") and GetByIndex("name", "ABC") therefore
behave differently for the same string.
Normalization means two values differing only in case or surrounding whitespace
collide on one index key. The later value wins and the earlier becomes
unreachable through that index, so a lookup by email can return a different
record than the one intended. Set OnIndexConflict to find out:
config := cache.DefaultConfig[User]().
WithPrimaryKey(func(u User) string { return u.ID })
config.OnIndexConflict = func(indexName, key, existingPK, newPK string) {
log.Printf("index %q key %q: %s is now shadowed by %s",
indexName, key, existingPK, newPK)
}The callback fires from both Set and AddIndex (so populating the cache
before adding an index is reported too), with the winner chosen by insertion
order. It runs with the cache lock released, so it may call back into Get,
Len or GetAll.
PrimaryKeyFunc is required when storing non-empty data: Set(non-empty)
panics without it. Set(nil) and Set([]V{}) are allowed either way.
config := cache.DefaultConfig[User]().
// Required: primary key extraction
WithPrimaryKey(func(u User) string { return u.ID }).
// Optional: custom hash (see Change Detection)
WithHashFunc(myHash).
// Optional: validation — invalid values are skipped
WithValidateFunc(func(u User) error {
if u.ID == "" {
return fmt.Errorf("ID required")
}
return nil
}).
// Optional: normalization applied before storing
WithNormalizeFunc(func(u User) User {
u.Email = strings.ToLower(u.Email)
return u
}).
// Optional: deterministic order for hashing
WithSortFunc(cache.StringSorter(func(u User) string { return u.ID }))
// Optional: be told when two values share one index key
config.OnIndexConflict = func(indexName, key, existingPK, newPK string) { /* … */ }| Option | Default | Notes |
|---|---|---|
PrimaryKeyFunc |
nil |
required for non-empty Set |
HashFunc |
reflection-based content hash | see Change Detection |
ValidateFunc |
nil |
a value that fails is skipped, not an error |
NormalizeFunc |
nil |
applied before storing and before key extraction |
SortFunc |
nil |
without it, values hash in insertion order |
OnIndexConflict |
nil |
without it, a shadowed index entry is silent |
KeyPrefixandVersionKeySuffixmust be non-empty;rediscache.NewWithKeyrequires a non-empty key. Use a unique prefix per cache to avoid collisions and key-space pollution.- Both keys must be at most 512 bytes.
- Actual keys: data key =
KeyPrefix + "data"(e.g.myapp:cache:data); version key = data key +VersionKeySuffix(e.g.myapp:cache:data:version).
config := rediscache.DefaultConfig().
WithKeyPrefix("myapp:cache:"). // required, non-empty; default "cache:"
WithVersionKeySuffix(":version"). // required, non-empty; default ":version"
WithTTL(1 * time.Hour). // applies to the DATA key; 0 means 1h at Set time
WithOperationTimeout(5 * time.Second). // per-operation bound, on top of your context
WithMaxValueBytes(4 * 1024 * 1024) // refuse an oversized value on Get; default 16MiB| Option | Default | Notes |
|---|---|---|
KeyPrefix |
"cache:" |
make it unique per cache |
VersionKeySuffix |
":version" |
appended to the data key |
TTL |
1h |
data key only — the version key is persistent |
OperationTimeout |
5s |
bounds each round trip on top of your context; 0 means no bound of its own |
MaxValueBytes |
16 MiB | 0 disables the check |
MaxValueBytes is enforced inside Redis: a Lua script measures the key with
STRLEN and returns the value in the same round trip, so an oversized value is
never transferred or allocated, and no other writer can swap the key between
the measurement and the read. Get returns an error naming both sizes.
Every method that talks to Redis takes a context.Context, so cancellation
and trace context reach the server. OperationTimeout narrows that context
rather than replacing it; set it to 0 to let your own deadline stand alone.
Hybrid.Set writes memory first, then Redis. If Redis fails, memory
already holds the new data — handle the error (retry, or call LoadFromRedis)
to reconcile.
Cluster, Ring and Sentinel. The constructors take rediscache.Client — the
six commands this package issues — so *redis.Client, *redis.ClusterClient,
*redis.Ring and redis.UniversalClient all work. On a sharded client, give
the data and version keys a common hash tag (KeyPrefix "{users}:" rather
than "users:"); Set, SetWithTTL and Clear write both in one
transaction, and Redis refuses a MULTI that spans slots.
Redis integrations live in ./rediscache and ./ttlcache/redisadapter. The
root package, ./ttlcache, ./ttlcache/loader, and ./ttlcache/hybrid do not
import go-redis or redis-kit.
The following historical measurement compares a program that imports only the
root package in v1.7.0 and v2.0.0. The new TTL packages do not change these
numbers because they are not imported by that program. Measured v1.7.0
against v2.0.0 (go build -trimpath, Go 1.27.0, linux/amd64):
| v1.7.0 | v2.0.0 | |
|---|---|---|
| Linked packages | 199 | 95 |
| Modules in the build | 5 | 1 |
| Binary size | 6,991,822 B | 3,991,817 B (−42.9%) |
Consumer go.mod indirect requires |
4 | 0 |
Consumer go.sum modules |
14 | 1 |
The go.sum line is module graph pruning at work: a requirement that no
imported package needs stays out of the consumer's checksums. Importing
./rediscache or ./ttlcache/redisadapter brings Redis dependencies into the
build. The repository's go.mod still declares them, so tools that inspect the
complete module graph can see them even when the final binary does not link
them.
One thing pruning does not do is insulate you from minimal version selection. If a program uses go-redis or redis-kit itself, cache-kit's version requirements can affect the selected versions.
c := cache.NewMultiIndexCache[V](config)
// Index management
c.AddIndex(name, keyFunc)
c.RemoveIndex(name)
c.HasIndex(name) bool
c.IndexCount() int
c.IndexNames() []string
// Data operations
c.Set(values)
c.Get(primaryKey) (V, bool)
c.GetByIndex(indexName, key) (V, bool)
c.GetAll() []V
c.Len() int
c.Clear()
// Iteration over a snapshot; the callback may call back into the cache
c.Iterate(func(v V) bool)
// Change detection
c.GetHash() stringc := ttlcache.New[K, V](config)
c.Set(key, value, ttl) error // ttl == 0 uses DefaultTTL
c.Get(key) (V, bool)
c.Delete(key) bool
c.Expire(key, ttl) bool
c.TTL(key) (time.Duration, bool)
c.Len() int
c.Clear()
c.Stats() ttlcache.Stats
c.Close() errorStats counters are cumulative. Entries and Cost describe the cache at
the time of the call. Use ManualClock in tests to exercise expiry without
sleeping.
l := loader.New[string, Page](loader.Config{MaxEntries: 1_000, MaxRefresh: 8})
defer func() { _ = l.Close() }()
result, err := l.GetOrLoad(ctx, url, fetchPage, loader.Policy{
FreshTTL: 5 * time.Minute,
StaleTTL: 30 * time.Minute,
ErrorTTL: 10 * time.Second,
TTLJitter: 0.1,
})Concurrent misses for one key share a load. After FreshTTL, a cached value is
returned with result.Stale == true while one background refresh runs. The
entry is removed after the additional StaleTTL. ErrorTTL briefly caches a
foreground load error; it does not replace an existing stale value. Cancelling
a caller's context stops that caller from waiting, but the shared load remains
available to other callers and is cancelled when the loader closes. The load
context retains values from the caller that started it.
l1 := ttlcache.New(ttlcache.DefaultConfig[string, Page]())
l2 := redisadapter.New[string, Page](redisClient, "pages:", nil)
c := hybrid.New(l1, l2, hybrid.RedisFirst)
page, ok, err := c.Get(ctx, url)
err = c.Set(ctx, url, page, 10*time.Minute)
err = c.Delete(ctx, url)RedisFirst is the default and attempts to remove L2 if the L1 write fails.
MemoryFirst avoids an L2 write when L1 rejects the value, but an L2 failure
can leave the new value in L1. In either case, inspect *hybrid.PartialError
when the layers may differ. Closing the hybrid cache stops L1 only; the caller
owns the Redis client. The Redis adapter reads each value and its remaining TTL
atomically before refilling L1.
c := rediscache.New[V](client, config)
c := rediscache.NewWithKey[V](client, "custom:key", config)
c.Set(ctx, values) error
c.SetWithTTL(ctx, values, ttl) error
c.Get(ctx) ([]V, error)
c.Clear(ctx) error // removes data and version key; GetVersion() then returns 0
c.Exists(ctx) (bool, error)
c.GetVersion(ctx) (int64, error)
c.TTL(ctx) (time.Duration, error)
c.Refresh(ctx) error // extends the data TTL; leaves the version key persistentc := rediscache.NewHybrid[V](memConfig, redisClient, redisConfig)
c.AddIndex(name, keyFunc)
c.Set(ctx, values) error
c.GetByIndex(indexName, key) (V, bool) // memory only, no round trip
c.GetAll() []V // memory only, no round trip
c.LoadFromRedis(ctx) error
c.SyncToRedis(ctx) error
c.Memory() *cache.MemoryCache[V]
c.Redis() *rediscache.Cache[V]Two breaking changes, released together so there is one import-path rewrite rather than two.
1. The Redis cache moved to ./rediscache. The root package no longer
imports go-redis; see Dependency footprint for what
that saves. Deprecated shims in the root package were not an option — a shim
has to import go-redis, which relinks it and gives back the whole benefit.
The module path is therefore github.com/soulteary/cache-kit/v2. Everyone
must update the import path, including programs with no Redis at all.
| v1 | v2 |
|---|---|
cache.RedisCache[V] |
rediscache.Cache[V] |
cache.NewRedisCache[V] |
rediscache.New[V] |
cache.NewRedisCacheWithKey[V] |
rediscache.NewWithKey[V] |
cache.RedisConfig |
rediscache.Config |
cache.DefaultRedisConfig |
rediscache.DefaultConfig |
cache.HybridCache[V] |
rediscache.Hybrid[V] |
cache.NewHybridCache[V] |
rediscache.NewHybrid[V] |
The memory cache — MemoryCache, Config, DefaultConfig,
NewMultiIndexCache, StringSorter, the hashing and the index machinery — is
unchanged apart from the /v2 in its import path.
2. Every Redis operation takes a context.Context. v1 rooted each call at
context.Background(), so a cancelled request could not stop the round trip
and no trace context reached the server. The methods could not gain a
parameter without a major version, and this is that version.
c.Set(values) → c.Set(ctx, values)
c.SetWithTTL(values, ttl) → c.SetWithTTL(ctx, values, ttl)
c.Get() → c.Get(ctx)
c.Exists() → c.Exists(ctx)
c.GetVersion() → c.GetVersion(ctx)
c.Clear() → c.Clear(ctx)
c.TTL() → c.TTL(ctx)
c.Refresh() → c.Refresh(ctx)
h.Set(values) → h.Set(ctx, values)
h.LoadFromRedis() → h.LoadFromRedis(ctx)
h.SyncToRedis() → h.SyncToRedis(ctx)Hybrid's memory-only methods — AddIndex, GetByIndex, GetAll, Memory,
Redis — are unchanged; they issue no commands.
Two behaviour changes come with it. OperationTimeout now narrows the context
you pass instead of replacing it, and a non-positive OperationTimeout means
no bound of this package's own — in v1 it produced an already-expired context,
so a RedisConfig built by hand rather than from DefaultRedisConfig failed
every operation before it was issued.
Also in v2: the constructors accept rediscache.Client instead of
*redis.Client, so *redis.ClusterClient, *redis.Ring and
redis.UniversalClient work without a wrapper. A nil client of a concrete
type still yields the redis client is nil error rather than a panic.
Dependency refresh only. No API was removed and no call needs rewriting.
- Test Redis is
miniredisv2.39.0 (was v2.36.1). - Transitive
yuin/gopher-luais v1.1.2 (was v1.1.1), matching the other kits.
Change detection and the Redis value guard both behave differently in this release. No exported function was removed or changed shape; one config field was added.
- Hash values differ from earlier releases. The default hash is computed a new way, so the first comparison after upgrading reports a change even when nothing changed. If you persist hashes across deploys, expect one spurious change and re-baseline.
GetHash()is now reproducible for pointer andtime.Timevalues. The default hash usedfmt.Sprintf("%v", v), which prints a pointer as its address:MemoryCache[*User]hashed its memory layout, so every restart and every reallocation reported a change that had not happened.time.Time's monotonic reading had the same effect. Both are hashed by content now.- Fields hidden from JSON now participate in change detection. An exported
field tagged
json:"-", or state excluded by a customMarshalJSON, used to be left out of the hash: two values differing only there hashed alike, so a real change went undetected. Every exported field is included now regardless of tags. If you relied onjson:"-"to keep a volatile field out of the hash, move that exclusion into aWithHashFunc. - An empty cache has one hash. A new cache started at
""andClear()reset to"", whileSet(nil)computed the empty-state hash — soClear()on an already-empty cache changedGetHash()and reported a phantom change. All three now agree. Iterateno longer holds the read lock during the callback. It snapshots under the lock and calls you without it, so a callback that touches the cache again no longer deadlocks, and a panicking callback no longer leaves the lock held. The callback now sees a snapshot rather than live data — the same guaranteeGetAllalready gave.- Shadowed index entries can be observed. Set the new
Config.OnIndexConflictto be told when two values normalize to one index key; previously the earlier value just became unreachable.AddIndexreports its rebuild collisions too, and picks the winner by insertion order rather than map iteration order. - Redis writes are atomic.
Set,SetWithTTLandClearuseTxPipeline(MULTI/EXEC) instead of a plain pipeline, so two concurrent writers can no longer leave one writer's data paired with another's version. - The Redis version key no longer expires. It used to be given the data
TTL; when it expired the counter restarted at 1, and a consumer tracking "is
the version higher than what I last saw" stopped seeing updates.
SetandRefreshnowPERSISTit, which also repairs a version key written by an earlier release.Clearstill deletes it explicitly. MaxValueBytesactually prevents the allocation. The size was checked after the value had been fetched — by the timelen(data)could be compared, the oversized value was already in memory. A Lua script now measures and fetches in one atomic step.
- User whitelist caching: O(1) lookups by phone, email or user ID
- Configuration caching: config reachable through several keys
- Hot data caching: frequently read data with multiple indexes
- Distributed caching: share state across instances through Redis
go test ./...
# With coverage
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.outContributions are welcome — please open a Pull Request.
Apache License 2.0 — see LICENSE for details.