Introduction

Remember that time you had to wait 10 seconds for your app to fetch data? Your users remember too, and they weren't happy about it. In the world of web applications, speed isn't just a nice-to-have feature—it's the difference between a successful product and an abandoned tab.

As an old-timer in the Go ecosystem (I've been writing Go since it wore diapers), I've seen countless projects brought to their knees by database load or API bottlenecks. Today, we're going to solve that with the programming equivalent of a supermarket express lane: in-memory caching.

Let's build a memory cache system in Go that will make your applications zippy and your users happy!

1. Memory Cache 101: Fundamentals of Go Caching

A memory cache is like that friend who remembers every embarrassing story about you—quick to recall, but sometimes needs to forget things to make room for new embarrassing moments. In technical terms, it's a data store that keeps frequently accessed information in RAM for lightning-fast retrieval.

Why Cache in Go?

Using a database for every request is like going to the grocery store every time you need a single ingredient. A cache is your kitchen pantry—it keeps frequently used items close at hand. In Go, implementing a cache is surprisingly straightforward thanks to the language's built-in maps and concurrency primitives.

💡 Lesser-known fact: In Go 1.9, sync.Map was introduced specifically to address high lock contention issues with mutexes guarding maps in concurrent scenarios. However, it's actually not always faster than a mutex-protected map for write-heavy workloads!

Let's start with the simplest possible cache implementation:

type SimpleCache struct {
    data map[string]interface{}
    mu   sync.RWMutex
}

func NewSimpleCache() *SimpleCache {
    return &SimpleCache{
        data: make(map[string]interface{}),
    }
}

func (c *SimpleCache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

func (c *SimpleCache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, exists := c.data[key]
    return val, exists
}

This works! But it's like using a hammer to build an entire house—technically possible, but not exactly optimal. Our simple cache has no way to expire old data, handle memory pressure, or provide performance metrics.

2. Building a Thread-Safe Cache Engine in Go

Dealing with cache concurrency issues is like trying to coordinate toddlers sharing toys—without proper supervision (mutexes), there will be tears (race conditions). Let's level up our cache with TTL support and automatic cleanup.

Adding TTL Support

Every good cache needs an expiration mechanism. Otherwise, your cache is like a refrigerator without cleaning—eventually, you'll end up with mysterious objects from the distant past taking up valuable space.

type CacheItem struct {
    Value      interface{}
    Expiration int64
}

type Cache struct {
    items map[string]CacheItem
    mu    sync.RWMutex
}

func NewCache() *Cache {
    cache := &Cache{
        items: make(map[string]CacheItem),
    }
    // Start the janitor
    go cache.startJanitor()
    return cache
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()

    expiration := time.Now().Add(ttl).UnixNano()
    c.items[key] = CacheItem{
        Value:      value,
        Expiration: expiration,
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    item, exists := c.items[key]
    if !exists {
        return nil, false
    }

    // Check if the item has expired
    if item.Expiration > 0 && time.Now().UnixNano() > item.Expiration {
        return nil, false
    }

    return item.Value, true
}

func (c *Cache) startJanitor() {
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        c.DeleteExpired()
    }
}

func (c *Cache) DeleteExpired() {
    now := time.Now().UnixNano()
    c.mu.Lock()
    defer c.mu.Unlock()

    for k, v := range c.items {
        if v.Expiration > 0 && now > v.Expiration {
            delete(c.items, k)
        }
    }
}

🔍 Pro tip: The janitor goroutine in this example runs every minute, which might be too infrequent for some applications. Adjust the ticker interval based on your specific needs and the typical TTL values you set.

Understanding the Tradeoffs

⚠️ Lesser-known fact: The sync.Map implementation in Go uses a clever "read-mostly" optimization technique with two internal maps—one for read operations and another for writes. This reduces contention but adds complexity and memory overhead, making it worse than a regular mutex-protected map for many common caching scenarios!

For a read-heavy cache, where items are written once and read many times, a sharded approach might be better:

type ShardedCache struct {
    shards     []*Cache
    shardCount int
    shardMask  int
}

func NewShardedCache(shardCount int) *ShardedCache {
    // Ensure shard count is a power of 2
    if shardCount <= 0 || (shardCount&(shardCount-1)) != 0 {
        shardCount = 16 // Default to 16 shards
    }

    sc := &ShardedCache{
        shards:     make([]*Cache, shardCount),
        shardCount: shardCount,
        shardMask:  shardCount - 1,
    }

    for i := 0; i < shardCount; i++ {
        sc.shards[i] = NewCache()
    }

    return sc
}

func (sc *ShardedCache) getShard(key string) *Cache {
    // Simple hash function
    h := fnv.New32a()
    h.Write([]byte(key))
    return sc.shards[h.Sum32()&uint32(sc.shardMask)]
}

func (sc *ShardedCache) Set(key string, value interface{}, ttl time.Duration) {
    shard := sc.getShard(key)
    shard.Set(key, value, ttl)
}

func (sc *ShardedCache) Get(key string) (interface{}, bool) {
    shard := sc.getShard(key)
    return shard.Get(key)
}

Sharding a cache is like having multiple refrigerators in your house—sure, it's more efficient, but now you can't remember if the milk is in the kitchen fridge or the garage fridge. The key benefit is reducing lock contention by distributing keys across multiple underlying caches.

3. Advanced Optimization Techniques: Eviction Policies & Benchmarking

Choosing the right eviction policy is like deciding which items to throw out when your closet is full—it's all fun and games until you realize you just tossed something you needed tomorrow.

Implementing LRU Eviction

Let's implement a Least Recently Used (LRU) cache using Go's container/list package:

type LRUCache struct {
    capacity int
    items    map[string]*list.Element
    list     *list.List
    mu       sync.RWMutex
}

type entry struct {
    key   string
    value interface{}
}

func NewLRUCache(capacity int) *LRUCache {
    return &LRUCache{
        capacity: capacity,
        items:    make(map[string]*list.Element),
        list:     list.New(),
    }
}

func (c *LRUCache) Get(key string) (interface{}, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if element, exists := c.items[key]; exists {
        c.list.MoveToFront(element)
        return element.Value.(*entry).value, true
    }
    return nil, false
}

func (c *LRUCache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // If key exists, update value and move to front
    if element, exists := c.items[key]; exists {
        c.list.MoveToFront(element)
        element.Value.(*entry).value = value
        return
    }

    // Add new element
    element := c.list.PushFront(&entry{key, value})
    c.items[key] = element

    // Evict least recently used if capacity exceeded
    if c.list.Len() > c.capacity {
        c.removeOldest()
    }
}

func (c *LRUCache) removeOldest() {
    element := c.list.Back()
    if element != nil {
        c.list.Remove(element)
        entry := element.Value.(*entry)
        delete(c.items, entry.key)
    }
}

🎯 Lesser-known fact: The default Go map implementation uses a randomized hash seed to protect against hash-flooding attacks, which is a clever security feature but can lead to non-deterministic iteration order that surprises developers trying to debug cache behavior.

Benchmarking Your Cache

A cache without benchmarks is like a sports car without a speedometer—you have no idea if it's actually fast or just looks fast. Let's write a simple benchmark for our cache implementations:

func BenchmarkCacheGet(b *testing.B) {
    // Create a cache with 1000 items
    cache := NewCache()
    for i := 0; i < 1000; i++ {
        key := fmt.Sprintf("key-%d", i)
        cache.Set(key, i, time.Hour)
    }

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            key := fmt.Sprintf("key-%d", i%1000)
            _, _ = cache.Get(key)
            i++
        }
    })
}

func BenchmarkShardedCacheGet(b *testing.B) {
    // Create a sharded cache with 1000 items
    cache := NewShardedCache(16)
    for i := 0; i < 1000; i++ {
        key := fmt.Sprintf("key-%d", i)
        cache.Set(key, i, time.Hour)
    }

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            key := fmt.Sprintf("key-%d", i%1000)
            _, _ = cache.Get(key)
            i++
        }
    })
}

Running these benchmarks on my machine yields some interesting results:

BenchmarkCacheGet-8            10000000    112 ns/op
BenchmarkShardedCacheGet-8     20000000     58 ns/op

That's nearly a 2x performance improvement with sharding! 🚀

Conclusion

We've gone from a simple map with a mutex to a sharded, LRU cache with TTL support—quite the journey! Here's what we've learned:

  1. Start simple - A basic mutex-protected map can be an excellent starting point
  2. Add features incrementally - TTL, cleanup, metrics can be added as needed
  3. Consider access patterns - Choose eviction policies that match your usage
  4. Measure, don't guess - Always benchmark to ensure your optimizations help

Remember, caching is both an art and a science. The perfect cache for your application depends on your specific workload, memory constraints, and performance requirements.

What parts of your application could benefit from an in-memory cache? The answer is probably "more than you think." Take the code examples from this post, adapt them to your use case, and share your results in the comments!

Until next time, happy caching! 🚀


buy me a coffee