Skip to Content
Course content

97: Building a Simple In-Memory Cache with TTL

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've had a few projects lately where I needed to avoid hitting a slow database for data that doesn't change every second. A simple in-memory cache usually does the trick. But if you just throw everything into a map, you're essentially building a memory leak. You need a way for data to expire—a Time-To-Live (TTL).

The Map that Never Forgets

My first instinct is always the simplest: a map. Let's say we're caching user session tokens. I'll start with a basic structure and a way to set values.

type Cache map[string]string

func (c Cache) Set(key, value string) {
    c[key] = value
}

func (c Cache) Get(key string) (string, bool) {
    val, ok := c[key]
    return val, ok
}

This works great for five minutes. But if this is a production server running for three months, that map just grows and grows. Even if a user logs out or their session should have ended hours ago, the data stays there. We need a way to mark when a piece of data is "stale."

Making Data Perishable

Instead of storing just the string, I'll wrap it in a struct that carries a timestamp. If I can record when the item expires, I can just check that time whenever I try to retrieve it.

type item struct {
    value      string
    expiresAt  time.Time
}

type Cache map[string]item

func (c Cache) Set(key, value string, ttl time.Duration) {
    c[key] = item{
        value:     value,
        expiresAt: time.Now().Add(ttl),
    }
}

Now, the Get method can't just return the value. It has to be smarter. If the current time is past the expiresAt time, the data is useless to us.

func (c Cache) Get(key string) (string, bool) {
    it, ok := c[key]
    if !ok {
        return "", false
    }

    if time.Now().After(it.expiresAt) {
        return "", false // Treat it as if it's not there
    }

    return it.value, true
}

This solves the "wrong data" problem, but it doesn't solve the "memory" problem. The expired item is still sitting in the map; we're just ignoring it. If we have a million expired sessions, they're still eating RAM.

Cleaning Up the Mess

To actually free up memory, we need a "janitor"—something that runs in the background and scrubs the map of expired entries. I'll use a goroutine and a time.Ticker for this. But wait, as soon as I try to do this, I run into a wall: Go maps are not thread-safe.

If my janitor is deleting keys while my main application is reading them, the program will panic with a concurrent map read and map write. I need a sync.RWMutex to coordinate access.

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

func NewCache(cleanupInterval time.Duration) *Cache {
    c := &Cache{
        items: make(map[string]item),
    }

    go func() {
        for range time.Tick(cleanupInterval) {
            c.DeleteExpired()
        }
    }()

    return c
}

func (c *Cache) DeleteExpired() {
    c.mu.Lock()
    defer c.mu.Unlock()

    for k, v := range c.items {
        if time.Now().After(v.expiresAt) {
            delete(c.items, k)
        }
    }
}

I'm using Lock() in the janitor because it's modifying the map. For the Get method, I'll use RLock(), which allows multiple readers to access the cache simultaneously as long as no one is writing to it. This is much more efficient than a full lock.

The Final Polish

Let's put it all together. I'll refine the Set and Get methods to use the mutex. I've noticed that in high-traffic apps, locking the entire map during a full cleanup can cause a "stop-the-world" feel for the API requests. In a real-world scenario, you might clean up in batches, but for our simple cache, this structure is a massive leap forward from the basic map.

func (c *Cache) Set(key, value string, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = item{
        value:     value,
        expiresAt: time.Now().Add(ttl),
    }
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    it, ok := c.items[key]
    if !ok || time.Now().After(it.expiresAt) {
        return "", false
    }
    return it.value, true
}

Now we have a system that is thread-safe, prevents stale data from being returned, and automatically manages its own memory footprint. It's not Redis, but for a local service, it's often all you need.




📋 Practical Task

Exercise: Implementing a Manual Purge for the Session Cache

Your task is to extend the Cache implementation from this lesson. While the background janitor handles automatic expiration, there are times when you need to force a cleanup—for example, during a system shutdown or a manual admin trigger.

Requirements:

  • Add a method called Purge() to the Cache struct.
  • The Purge() method should immediately remove all items from the cache, regardless of their expiration time.
  • Ensure the method is thread-safe using the existing mutex.
  • Write a small main function that:
    1. Initializes a cache.
    2. Adds three different items with varying TTLs.
    3. Calls Purge().
    4. Attempts to Get one of the items to verify it is gone.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.