go-cache is an in-memory key:value store/cache similar to memcached that is suitable for applications running on a single machine. Its major advantage is that, being essentially a thread-safe map[string]interface{} with expiration times, it doesn't need to serialize or transmit its contents over the network.
Key Features:
- Sharding: Reduces lock contention for high-concurrency workloads.
- Redis Integration: Optional L2 caching and persistence layer using
go-redis(supports v8 and v9). - Memory-Authoritative Mode: Optional write-behind-only mode where Redis is never read synchronously on the hot path — after warmup, reads are fully in-memory while writes still persist asynchronously.
- Single Round-Trip Reads: Redis reads fetch the value and its TTL in one round-trip (pipeline /
GETEX) when the adapter supports it, instead of a separateGET+TTL. - Startup Warmup:
WarmFromRedisrebuilds in-memory state from Redis at boot, so durability survives restarts without any synchronous reads at runtime. - Capacity Management: Internal LRU-like eviction when memory limits are reached.
- Generics: Type-safe API (Go 1.18+).
- SetNX: Atomic "Set if Not Exists" operation, either synchronized with Redis (default) or local-authoritative with async persistence.
- Numeric Operations: Atomic increment/decrement support for numeric types, persisted to Redis.
- Set Cache: Track unique members per key, each with its own TTL — ideal for counting active sessions/devices per user.
- Graceful Shutdown: Ensures pending Redis operations are completed before exit.
- Sync: Force refresh items from Redis.
- Back-Pressure Control: Observe dropped async writes via
DroppedWrites, and optionally block briefly instead of dropping when the write-behind queue is full. - Configurable Timeouts: Fine-tune Redis L2 operation timeouts for all cache types.
- Performance: Extremely low latency local operations (see BENCHMARKS.md).
go get github.com/mysamimi/go-cache/v3
- Memory-Authoritative / Write-Behind-Only Mode:
WithWriteThroughOnly()keeps the hot path (Get/ModifyNumeric/SetNX) fully in-memory — Redis is used purely as an async write-behind layer and is never read synchronously on a local miss. - Single Round-Trip Redis Reads: Added the optional
RedisGetTTLClientinterface (implemented by the bundled v8/v9 adapters via a pipeline). When available, a Redis read fetches value + TTL in one round-trip instead of separateGETandTTLcalls. - Startup Warmup:
WarmFromRedis(keys)loads existing Redis state into local memory at startup, for durability across restarts without runtime synchronous reads. - Local-Authoritative SetNX: In write-through-only mode,
SetNXguarantees atomicity via the local shard lock and pushes the RedisSETNXasynchronously. The original cluster-wide synchronous behavior remains the default. - Back-Pressure Metrics & Blocking: Async write drops (when the queue is full) are now counted and exposed via
DroppedWrites().WithBlockOnFull(d)blocks briefly instead of dropping immediately. - SetNX Support: Added atomic
SetNX(Set if Not Exists) operations seamlessly synchronized with Redis. - Automatic Redis Fetching: Enabled automatic Redis fetching for local cache misses and expired items, enhancing multi-instance synchronization.
- Configurable Timeouts: Fine-tune Redis L2 operation timeouts for all cache types.
- Manual Sync Support: Added
Syncmethod to force refresh items from Redis across all cache types. - SetCache & ShardedSetCache: Introduced sets for tracking unique members with individual TTLs.
- Numeric Convenience Methods: Added
IncrandDecrmethods for numeric cache operations. - Performance Improvements: Enhanced Redis synchronization logic and added comprehensive performance benchmarks.
import (
"fmt"
"time"
"github.com/mysamimi/go-cache/v3"
)
func main() {
// Create a cache with a default expiration of 5 minutes,
// purging expired items every 10 minutes.
c := cache.New[string](5*time.Minute, 10*time.Minute)
c.Set("foo", "bar", cache.DefaultExpiration)
// SetNX: Only sets the key if it does not already exist.
// Seamlessly integrates with Redis SetNX if configured.
set := c.SetNX("foo", "baz", cache.DefaultExpiration)
fmt.Println(set) // false, since "foo" already exists
foo, found := c.Get("foo")
if found == cache.Found {
fmt.Println(foo) // bar
}
}ShardedCache automatically partitions keys into multiple buckets to reduce lock contention.
// 16 shards, 5-min default expiry, 10-min janitor interval
c := cache.NewShardedCache[string](16, 5*time.Minute, 10*time.Minute)
c.Set("foo", "bar", cache.DefaultExpiration)
val, found := c.Get("foo")Writes (Set/Delete/ModifyNumeric) are asynchronous to Redis. However, modification operations like ModifyNumeric and AddMember will automatically synchronize with Redis before updating the local cache to ensure consistency across multiple instances.
Supports both go-redis/v8 and go-redis/v9 via adapters.
import (
"github.com/redis/go-redis/v9"
"github.com/mysamimi/go-cache/v3"
redisv9 "github.com/mysamimi/go-cache/v3/redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
c := cache.NewShardedCache[MyStruct](16, 5*time.Minute, 10*time.Minute)
c.WithRedis(redisv9.New(rdb))
defer c.Close() // flush pending Redis writes before exit
c.Set("key", MyStruct{Val: 1}, cache.DefaultExpiration)
}Control how the cache interacts with Redis:
// Set a 500ms timeout for Redis operations (optional)
c.WithRedisTimeout(500 * time.Millisecond)
// Force-refresh a key from Redis (e.g. when another service has updated it)
// Available for all cache types (Cache, NumericCache, SetCache)
val, err := c.Sync("key")By default the cache is read-through: a Get first checks local memory, and on a local miss (or a locally-expired item) it transparently falls back to Redis. If the key exists in Redis, the value is pulled back into local memory and returned. This keeps multiple instances loosely synchronized — a value written by one worker becomes visible to others on their next miss.
The Redis read fetches both the value and its remaining TTL. When the configured adapter implements RedisGetTTLClient (the bundled redisv8 / redisv9 adapters do, via a pipeline), this is a single round-trip; otherwise it falls back to a separate GET + TTL.
For latency-critical, high-throughput workloads you can make memory the source of truth and demote Redis to a pure async write-behind layer. In this mode the hot path is never blocked by a synchronous Redis read.
Enable it with WithWriteThroughOnly() (available on Cache, NumericCache, ShardedCache, and ShardedNumericCache):
c := cache.NewShardedCache[MyStruct](16, 5*time.Minute, 10*time.Minute)
c.WithRedis(redisv9.New(rdb)).
WithRedisTimeout(500 * time.Millisecond).
WithWriteThroughOnly()
defer c.Close()What changes in this mode:
- Reads stay in memory.
Get,ModifyNumeric,SetNX, and theSetCachegetters no longer fall back to Redis on a local miss — a miss is a miss. After warmup, the hot path is 100% in-memory. - Writes still persist.
Set,Delete,Incr/Decr, etc. are still propagated to Redis asynchronously through the write-behind worker. SetNXbecomes local-authoritative. Atomicity is guaranteed by the local shard lock (exactly one winner per process), and the RedisSETNXis sent asynchronously. The signature and return value are unchanged. Note: cluster-wide uniqueness is no longer enforced synchronously — use the default mode if you need that guarantee.- Explicit reads still hit Redis.
Sync(key)andWarmFromRedis(keys)deliberately bypass this mode, since they are not on the hot path.
To get durability across restarts without paying for synchronous reads at runtime, load your state from Redis once at boot:
// Rebuild in-memory state for known keys (e.g. after a restart).
loaded, err := c.WarmFromRedis([]string{"user:1", "user:2", "user:3"})
fmt.Printf("warmed %d keys from Redis\n", loaded)Keys missing from Redis are skipped silently; loaded is the number of keys actually restored. For ShardedCache, keys are automatically routed to the correct shard. An error is returned only if Redis is not configured.
Async Redis writes flow through a buffered queue. If writes are produced faster than the Redis worker can drain them, the queue fills up. By default an overflowing write is dropped (to avoid stalling the application), but it is now counted rather than silently lost:
// Number of async Redis writes dropped because the queue was full.
// On ShardedCache this is summed across all shards.
dropped := c.DroppedWrites()
if dropped > 0 {
log.Printf("WARNING: %d cache writes were dropped before reaching Redis", dropped)
}If losing a write is unacceptable (for example, an item about to be evicted from memory), configure the producer to block briefly instead of dropping:
// When the queue is full, block for up to 50ms waiting for room.
// If still full after the timeout, the write is dropped and counted.
c.WithBlockOnFull(50 * time.Millisecond)Trade-off: blocking applies back-pressure to the calling goroutine (and, for
Set/Delete, briefly while holding the shard lock), so keep the timeout short. A zero or negative duration restores the default drop-immediately behavior.
// Evict when more than 1 000 items are held (split across shards)
c.WithCapacity(1000)
c.OnEvicted(func(k string, v MyStruct) {
fmt.Printf("evicted: %s\n", k)
})NumericCache / ShardedNumericCache provide atomic increment and decrement, persisted to Redis.
nc := cache.NewShardedNumeric[int64](16, 5*time.Minute, 10*time.Minute)
nc.WithRedis(redisv9.New(rdb))
newVal, err := nc.Incr("counter", 1) // +1
newVal, err = nc.Decr("counter", 5) // -5
// Low-level form:
newVal, err = nc.ModifyNumeric("counter", 5, false)SetCache and ShardedSetCache track a set of unique members per key, where each member has its own TTL. This is useful for counting active sessions, devices, or viewers — anything where membership is time-bounded and keyed by a parent entity.
This pattern is the Go equivalent of the Redis
SMEMBERS+ per-keyEXISTScleanup pattern commonly used in Node.js.
sc := cache.NewSetCache(5*time.Minute, 10*time.Minute)// setKey = the parent key (e.g. user ID)
// member = the unique member (e.g. device/session ID)
// memberTTL = how long this member is considered active
// setTTL = TTL of the parent set key itself
count, isNew := sc.AddMember("user:42", "device-abc", 30*time.Second, 5*time.Minute)
// count=1, isNew=true
// Re-add the same device (heartbeat / refresh)
count, isNew = sc.AddMember("user:42", "device-abc", 30*time.Second, 5*time.Minute)
// count=1, isNew=false ← same member, TTL refreshed
// Add a second device
count, isNew = sc.AddMember("user:42", "device-xyz", 30*time.Second, 5*time.Minute)
// count=2, isNew=trueCheckAndClean is the Go port of the Node.js checkValueInListAndCleanUp function. It:
- Checks whether the member is already active.
- Returns the (virtual) count immediately without cleanup if
count ≤ limit. - Otherwise prunes every expired member, then re-counts.
Important:
CheckAndCleandoes not write the member to the set. CallAddMemberseparately once you decide to allow the session.
// Example: allow at most 3 simultaneous devices per user
// Suppose user:42 already has device-abc (alive) and device-old (expired).
count, isNew := sc.CheckAndClean("user:42", "device-new", 3)
// Projected count (2+1) = 3 ≤ limit=3 → no cleanup, fast return.
// count=3, isNew=true
// If there were 3 alive + 1 expired and we add a 4th:
// count=5 > 3 → prune expired → count=4 → still count=4, isNew=true
// Caller can decide to reject or allow.
if count <= 3 {
// Allow and record the new session
sc.AddMember("user:42", "device-new", 30*time.Second, 5*time.Minute)
} else {
fmt.Println("too many active devices")
}func canWatch(sc *cache.SetCache, userID, deviceID string) (bool, int) {
const maxDevices = 3
count, isNew := sc.CheckAndClean(userID, deviceID, maxDevices)
if !isNew {
// Device already registered — renew its heartbeat TTL
sc.AddMember(userID, deviceID, 30*time.Second, 5*time.Minute)
return true, count
}
if count > maxDevices {
return false, count // limit exceeded even after cleanup
}
// New device, within limit — register it
sc.AddMember(userID, deviceID, 30*time.Second, 5*time.Minute)
return true, count
}// Check if a member is alive
alive := sc.HasMember("user:42", "device-abc") // true / false
// Get all live member IDs
members := sc.Members("user:42") // []string{"device-abc", "device-xyz"}
// Count without cleanup
n := sc.Count("user:42") // 2
// Prune expired members in-place and return remaining live count
n = sc.CleanAndCount("user:42")
// Remove a specific member (e.g. on explicit logout)
sc.RemoveMember("user:42", "device-abc")
// Remove all members for a key
sc.DeleteSet("user:42")
// Clear everything
sc.Flush()ShardedSetCache wraps multiple SetCache shards; different set keys are handled by independent shards, eliminating cross-key lock contention.
// 16 shards, 5-min parent-key TTL, 10-min janitor interval
ssc := cache.NewShardedSetCache(16, 5*time.Minute, 10*time.Minute)
// All SetCache methods are available on ShardedSetCache
count, isNew := ssc.AddMember("user:42", "device-abc", 30*time.Second, 5*time.Minute)
count, isNew = ssc.CheckAndClean("user:42", "device-new", 3)
members := ssc.Members("user:42")
ssc.RemoveMember("user:42", "device-abc")
ssc.DeleteSet("user:42")
// Redis backend (all shards)
ssc.WithRedis(redisv9.New(rdb))
defer ssc.Close()When Redis is attached, the setData (the member→expiry map) is persisted as JSON. Shared across instances, the set automatically synchronizes with Redis during modification operations (like AddMember), ensuring that updates from one worker are visible to others.
You can also manually synchronize a set:
// Fetches latest set data from Redis, merges change, then writes back asynchronously
count, err := sc.Sync("user:42")