Application 2020-09-29

Implementing In-Memory Cache in Golang

Build a lightweight in-memory cache in Go with TTL expiration and thread-safe access using sync.Map. Covers design requirements, expiration logic, and trade-offs vs. library solutions.

Read in: ja
Implementing In-Memory Cache in Golang

Overview

There are some good libraries for in-memory caching in Golang, but since I needed something lightweight and simple, I decided to implement my own.

Implementation

Requirements

Initial Design

The initial implementation was done based on my first thoughts.

package main

import (
	"fmt"
	"log"
	"sync"
	"time"
)

// Cache is a struct for caching.
type Cache struct {
	value   sync.Map
	expires int64
}

// Expired determines if it has expired.
func (c *Cache) Expired(time int64) bool {
	if c.expires == 0 {
		return false
	}
	return time > c.expires
}

// Get gets a value from a cache. Returns an empty string if the value does not exist or has expired.
func (c *Cache) Get(key string) string {
	if c.Expired(time.Now().UnixNano()) {
		log.Printf("%s has expired", key)
		return ""
	}
	v, ok := c.value.Load(key)
	var s string
	if ok {
		s, ok = v.(string)
		if !ok {
			log.Printf("%s does not exists", key)
			return ""
		}
	}
	return s
}

// Put puts a value to a cache. If a key and value exists, overwrite it.
func (c *Cache) Put(key string, value string, expired int64) {
	c.value.Store(key, value)
	c.expires = expired
}

var cache = &Cache{}

func main() {
	fk := "first-key"
	sk := "second-key"

	cache.Put(fk, "first-value", time.Now().Add(2*time.Second).UnixNano())
	s := cache.Get(fk)
	fmt.Println(cache.Get(fk))

	time.Sleep(5 * time.Second)

	// fk should have expired
	s = cache.Get(fk)
	if len(s) == 0 {
		cache.Put(sk, "second-value", time.Now().Add(100*time.Second).UnixNano())
	}
	fmt.Println(cache.Get(sk))
}

I thought sync.Map was convenient because I didn't have to worry about locking, but it was rejected because it did not meet the requirements in terms of data structure and functionality.

Release Version

The version that meets the requirements is available at github.com - bmf-san/go-snippets/architecture_design/cache/cache_with_goroutine.go.

package main

import (
	"fmt"
	"log"
	"net/http"
	"sync"
	"time"
)

// item is the data to be cached.
type item struct {
	value   string
	expires int64
}

// Cache is a struct for caching.
type Cache struct {
	items map[string]*item
	mu    sync.Mutex
}

func New() *Cache {
	c := &Cache{items: make(map[string]*item)}
	go func() {
		t := time.NewTicker(time.Second)
		defer t.Stop()
		for {
			select {
			case <-t.C:
				c.mu.Lock()
				for k, v := range c.items {
					if v.Expired(time.Now().UnixNano()) {
						log.Printf("%v has expires at %d", c.items, time.Now().UnixNano())
						delete(c.items, k)
					}
				}
				c.mu.Unlock()
			}
		}
	}()
	return c
}

// Expired determines if it has expires.
func (i *item) Expired(time int64) bool {
	if i.expires == 0 {
		return true
	}
	return time > i.expires
}

// Get gets a value from a cache.
func (c *Cache) Get(key string) string {
	c.mu.Lock()
	var s string
	if v, ok := c.items[key]; ok {
		s = v.value
	}
	c.mu.Unlock()
	return s
}

// Put puts a value to a cache. If a key and value exists, overwrite it.
func (c *Cache) Put(key string, value string, expires int64) {
	c.mu.Lock()
	if _, ok := c.items[key]; !ok {
		c.items[key] = &item{
			value:   value,
			expires: expires,
		}
	}
	c.mu.Unlock()
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fk := "first-key"
		sk := "second-key"

		cache := New()

		cache.Put(fk, "first-value", time.Now().Add(2*time.Second).UnixNano())
		fmt.Println(cache.Get(fk))

		time.Sleep(10 * time.Second)

		if len(cache.Get(fk)) == 0 {
			cache.Put(sk, "second-value", time.Now().Add(100*time.Second).UnixNano())
		}
		fmt.Println(cache.Get(sk))
	})
	http.ListenAndServe(":8080", nil)
}

I wanted to use sync.Map because it is convenient, but it was difficult to check and delete expired cache data without specifying the cache key. Therefore, I decided to use map to hold the cache data.

The expiration check is done using a ticker to check at intervals. In the above implementation, the interval is set to one second. In this implementation, access to the cache can occur until one second after the cache expiration, so the actual cache expiration is the time specified in expires plus the interval.

Thoughts

This was a good opportunity to learn about concurrency and locking in Golang.

References

Tags: Golang Cache
Share: 𝕏 Post Facebook Hatena
✏️ View source / Discuss on GitHub
☕ Support

If you enjoy this blog, consider supporting it. Every bit helps keep it running!


Related Articles