Caching Strategies in Go: From In-Process to Distributed
Cache invalidation is one of the two hard problems in computer science. The other is naming. Here’s how to implement caching in Go without shooting yourself in the foot.
The Cache Hierarchy
Request → L1: In-Process (sync.Map / ristretto) → ns latency
→ L2: Redis (distributed) → ~1ms
→ L3: Database / External API → 10-100ms+
L1: In-Process Cache with Ristretto
import "github.com/dgraph-io/ristretto"
func NewLocalCache() (*ristretto.Cache, error) {
return ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 10M counters for frequency tracking
MaxCost: 100 << 20, // 100MB max memory
BufferItems: 64,
Metrics: true, // enable hit rate tracking
})
}
type UserCache struct {
local *ristretto.Cache
redis *redis.Client
db UserRepository
}
func (c *UserCache) GetUser(ctx context.Context, id string) (*User, error) {
// L1 check
if v, ok := c.local.Get("user:" + id); ok {
return v.(*User), nil
}
// L2 check
var user User
data, err := c.redis.Get(ctx, "user:"+id).Bytes()
if err == nil {
if err := json.Unmarshal(data, &user); err == nil {
// Backfill L1
c.local.SetWithTTL("user:"+id, &user, 1, 30*time.Second)
return &user, nil
}
}
// L3: database
u, err := c.db.FindByID(ctx, id)
if err != nil { return nil, err }
// Populate both caches
c.setRedis(ctx, "user:"+id, u, 5*time.Minute)
c.local.SetWithTTL("user:"+id, u, 1, 30*time.Second)
return u, nil
}
Cache-Aside vs Write-Through
// Cache-Aside (Lazy Loading): read → miss → load → cache
// Best for: read-heavy, tolerance for stale data
// Write-Through: write → update cache AND DB simultaneously
// Best for: write-heavy, strong consistency needed
func (c *UserCache) UpdateUser(ctx context.Context, user *User) error {
// Write-through pattern
if err := c.db.Update(ctx, user); err != nil {
return err
}
// Update cache immediately
data, _ := json.Marshal(user)
c.redis.Set(ctx, "user:"+user.ID, data, 5*time.Minute)
c.local.Del("user:" + user.ID) // invalidate L1, let it reload
return nil
}
Stampede Protection (Thundering Herd)
When cache expires, thousands of requests hit the DB simultaneously:
import "golang.org/x/sync/singleflight"
type CacheWithSingleFlight struct {
cache *redis.Client
db Repository
group singleflight.Group
}
func (c *CacheWithSingleFlight) Get(ctx context.Context, key string) (*Data, error) {
// Check cache first
if data := c.getFromCache(ctx, key); data != nil {
return data, nil
}
// Only ONE goroutine fetches from DB; all others wait and share the result
result, err, _ := c.group.Do(key, func() (any, error) {
data, err := c.db.Find(ctx, key)
if err != nil { return nil, err }
c.setCache(ctx, key, data, 5*time.Minute)
return data, nil
})
if err != nil { return nil, err }
return result.(*Data), nil
}
Probabilistic Early Expiration (XFetch)
A smarter alternative: refresh cache slightly before it expires, probabilistically:
// Based on the XFetch algorithm (Optimal Probabilistic Cache Stampede Prevention)
func (c *Cache) GetWithEarlyRefresh(ctx context.Context, key string, ttl time.Duration, fetch func() (any, error)) (any, error) {
type cachedValue struct {
Data any `json:"data"`
ExpiresAt time.Time `json:"expires_at"`
Delta float64 `json:"delta"` // time to compute the value
}
var cv cachedValue
raw, err := c.redis.Get(ctx, key).Bytes()
if err == nil && json.Unmarshal(raw, &cv) == nil {
// XFetch: should we proactively refresh?
beta := 1.0
earlyRefreshTime := cv.ExpiresAt.Add(-time.Duration(cv.Delta * beta * math.Log(rand.Float64()) * float64(time.Second)))
if time.Now().Before(earlyRefreshTime) {
return cv.Data, nil // still fresh, or not yet time to refresh
}
}
// Fetch fresh data
start := time.Now()
data, err := fetch()
if err != nil { return nil, err }
delta := time.Since(start).Seconds()
cv = cachedValue{Data: data, ExpiresAt: time.Now().Add(ttl), Delta: delta}
raw, _ = json.Marshal(cv)
c.redis.Set(ctx, key, raw, ttl)
return data, nil
}
Cache Invalidation Strategies
| Strategy | When to Use | Consistency |
|---|---|---|
| TTL expiry | Non-critical data | Eventual |
| Event-based invalidation | Domain events (Kafka/pub-sub) | Near-real-time |
| Write-through | Strong consistency | Immediate |
| Tag-based invalidation | Related keys (e.g., all user posts) | Manual but precise |
// Tag-based: cache a set of keys under a tag, then delete all at once
func (c *Cache) SetWithTag(ctx context.Context, key string, tag string, value any, ttl time.Duration) error {
pipe := c.redis.Pipeline()
data, _ := json.Marshal(value)
pipe.Set(ctx, key, data, ttl)
pipe.SAdd(ctx, "tag:"+tag, key)
pipe.Expire(ctx, "tag:"+tag, ttl+time.Minute)
_, err := pipe.Exec(ctx)
return err
}
func (c *Cache) InvalidateByTag(ctx context.Context, tag string) error {
keys, err := c.redis.SMembers(ctx, "tag:"+tag).Result()
if err != nil { return err }
if len(keys) == 0 { return nil }
pipe := c.redis.Pipeline()
pipe.Del(ctx, keys...)
pipe.Del(ctx, "tag:"+tag)
_, err = pipe.Exec(ctx)
return err
}
Monitoring Cache Effectiveness
Always track hit rate. Below 80% hit rate usually means your TTL or key strategy needs tuning:
type CacheMetrics struct {
hits prometheus.Counter
misses prometheus.Counter
}
func (m *CacheMetrics) HitRate() float64 {
h := m.hits.(prometheus.Gauge).Get()
miss := m.misses.(prometheus.Gauge).Get()
if h+miss == 0 { return 0 }
return h / (h + miss)
}
A well-tuned cache at 95% hit rate reduces database load by 20x. It’s worth the complexity.