Кэширование в golang приложении

Существует несколько уровней кэша в приложении. Например кэширование на уровне http с помощью заголовков или кэширование в proxy например varnish или на уровне приложения - memcache, redis, lru или низкоуровневое кэширование на уровне операционной системы. В данной статье мы рассмотрим кэширование на уровне приложения в памяти, или отдельных key-value хранилищах.

В golang ничего не может быть проще чем кешировать в памяти, для этого достаточно сделать map типа map[string]User все остальное уже реализованно на уровне языка. Например проверка кэша user, ok := users["andrey"]. Но к сожалению этого в большинстве приложений не достаточно, особенно http, поскольку каждый запрос у нас выполняется параллельно и cache будет у каждого свой, соответственно эффективность такого кэша небольшая. Конечно если уменьшить количество запросов к базе данных, вы один раз получаете список объектов потом его передаете в методы, но читать такой код очень сложно. Также можно использовать свои типы, например:
 

package cache

type User struct {
    ID   int
    Name string
}

type Users map[int]User

func (u Users) Get(id int) (User, bool) {
    u, ok := u[id]
    return u, ok
}

Если придерживаться такого подхода, то приходится прокидывать через многие методы одни и те же структуры данных. Данное приложение тогда получается плохо расширяемое и очень много побочных эффектов. Бизнес логика приложения не должна зависеть от реализации, к примеру если в будущем вам не достаточно будет кэша в памяти, прийдется переписывать все методы где использовался такой подход. Можно сразу написать интерфейс для кэширования, например:
 

package cache

type Cache interface {
    Get(key interface{}) (interface{}, error)
}

Примерно такой подход используется в lru библиотеке. Тогда любое использование такого cache будет выглядеть так:
 

package user

import "fmt"

type User struct {
    ID   int
}

type Cache interface {
    Get(key interface{}) (interface{}, error)
}

func GerUser() error {
    var c Cache
    u, err := c.Get(1)
    if err != nil {
        return err
    }
    user,ok := u.(*User)
    if !ok{
        return fmt.Err
    }
    fmt.Print(user)
}

Что не на много отличается от обычно map, и не очень читабельно, также мы зависим от реализации. Например библиотеки работы с key-value хранилищами ничего не знают о типах в golang и обычно сохраняют у себя кэш как строку и тот же json.Unmarshal(data []byte, v interface{}) как аргумент получает ссылку на структуру, давайте этот факт учтем.
 

package user

import (
    "fmt"
)

type User struct {
    ID   int
    Name string
}

type Cache interface {
    Get(key, val interface{}) error
}

type UserService struct {
    cache Cache
}

func (u *UserService) Print() error {
    var user User
    if err := u.cache.Get(1, &user); err != nil {
        return err
    }

    fmt.Print(user)
    return nil
}

В данном случае сервис уже не знает какая у нас реализация будь то кэш в памяти или в key-value хранилище.

В большом приложении требований к кэшу немного больше чем просто хранить данные, также необходимо понимать эффективность кэша, и понимать где произошла ошибка, например через OpenTracing. Эти задачи есть практически у каждого живого проекта, даже если они появляются позже, приходится дорабатывать текущий интерфейс, вводить туда context.Context для передачи информации о запросе и тп. Хуже происходит когда проект в самом начале завязывается на реализации конкретного кэша и библиотеки будь то redis или memcache тогда еще сложнее что-либо модифицировать, обычно новый key-value не добавляют чтобы не было `зоопарка` технологий но и уйти от текущего будет достаточно сложно. Можно пойти путем пакета http когда в любой обработчик можно добавить промежуточный слой(middleware), на выходе мы получим:
 

package user

import (
    "context"
    "fmt"
    "time"
)

type User struct{}

type Cache interface {
    Get(ctx context.Context, key, val interface{}) error
    Set(ctx context.Context, key, val interface{}, ttl time.Duration) error
}

type UserService struct {
    cache Cache
}

func (u *UserService) Print(ctx context.Context) error {
    var user User
    if err := u.cache.Get(ctx, 1, &user); err != nil {
        return err
    }

    fmt.Print(user)
    return nil
}

Также в методе Set был добавлен time.Duration чтобы кэш мог устаревать. Тут рассмотрен пример уже интерфейса и его использования, опять же сервис которых использует кэш не должен знать о реализации, используются там метрики или нет его мало интересует, у него другая задача. Если рассматривать использование уже готовой библиотеки cache то получается:
 

package user

import (
    "context"
    "fmt"

    "gitoa.ru/go-4devs/cache"
)

type User struct{}

type UserService struct {
    cache *cache.Cache
}

func (u *UserService) Print(ctx context.Context) error {
    var user User
    if err := u.cache.Get(ctx, 1, &user); err != nil {
        return err
    }

    fmt.Print(user)
    return nil
}

Чтобы установить ttl для объекта то метод Set выглядит c.Set(ctx, 1, user, cache.WithTTL(time.Minute)). Можно использовать разные хранилища от memory до своей реализации на файлах. Для использования redis нужна библиотека redigo но в любом случае вы можете просто сменив провайдера поменять место хранения.
 

package user

import (
    "context"
    "time"

    redigo "github.com/gomodule/redigo/redis"
    "gitoa.ru/go-4devs/cache"
    "gitoa.ru/go-4devs/cache/provider/redis"
)

func NewCache(host string) *cache.Cache {
    client := &redigo.Pool{
        Dial: func() (redigo.Conn, error) {
            return redigo.Dial("tcp", host)
        },
    }

    return cache.New(redis.New(client.GetContext))
}

type User struct {
    ID int64
}

type UserService struct {
    cache *cache.Cache
}

func (u *UserService) Save(ctx context.Context, u *User) error {
    return u.cache.Set(ctx, u.ID, u, cache.WithTTL(time.Minute))
}

Если в будущем нам надо будет добавить метрики это делается достаточно просто при создании кэша::

package user

import (
    "context"
    "time"

    redigo "github.com/gomodule/redigo/redis"
    "gitoa.ru/go-4devs/cache"
    "gitoa.ru/go-4devs/cache/mw"
    "gitoa.ru/go-4devs/cache/mw/prometheus"
    "gitoa.ru/go-4devs/cache/provider/redis"
)

func NewCache(host string) *cache.Cache {
    client := &redigo.Pool{
        Dial: func() (redigo.Conn, error) {
            return redigo.Dial("tcp", host)
        },
    }

    return cache.New(redis.New(client.GetContext), mw.WithMetrics(prometheus.Metrics{}, mw.LabelName("user")))
}

Добавление namespace может быть при инициализации cache.New(memory.Map(), cache.WithDataOption(cache.WithNamespace("user", ":"))) или при вызове методов cache.Set(ctx, u.ID, u, cache.WithNamespace("user", ":")) если у вас нет желания/возможностей для каждого типа объектов делать свой кэш.

Получение данных у нас обычно будет выглядеть примерно одинаково:

package user

import (
    "context"
    "time"

    "gitoa.ru/go-4devs/cache"
    "gitoa.ru/go-4devs/cache/provider/memory"
)

type User struct {
    ID int64
}

type UserService struct {
    cache   *cache.Cache
    storage func(ctx context.Context, id int64) (User, error)
}

func (u *UserService) Get(ctx context.Context, id int64) (user User, err error) {
    if err = u.cache.Get(ctx, id, &user); err != nil {
        return u, nil
    }
    user, err = u.storage(ctx, id)
    if err != nil {
        return err
    }

    return user, u.cache.Set(ctx, id, user, cache.WithTTL(time.Minute))
}

Но так как есть механизм при котором мы можем модифицировать ответ можно воспользоваться им и получиться кэш со своей альтернативой получения данных(fallback).
 

package user

import (
    "context"
    "errors"

    "gitoa.ru/go-4devs/cache"
    "gitoa.ru/go-4devs/cache/mw"
    "gitoa.ru/go-4devs/cache/provider/memory"
)

func NewCache() *cache.Cache {
    var storage func(ctx context.Context, id int64) (User, error)
    return cache.New(memory.Map(), mw.WithFallback(
        mw.CachesFallbackSuccess(func(ctx context.Context, d *cache.Item) error {
            u, err := storage(ctx, d.Key.Key.(int64))
            if err != nil {
                return err
            }
            return cache.TypeAssert(u, d.Value)
        }),
        func(i *cache.Item, e error) bool {
            return errors.Is(e, cache.ErrCacheMiss) || errors.Is(e, cache.ErrCacheExpired)
        },
    ))
}

Теперь чтобы получить данные можно просто добавить сам кэш. Но необходимо учитывать минусы проверки на отсутствие кэша, например если мы получаем всех пользователей а потом в коде уже фильтруем, с одной стороны это уменьшает количество запросов к базе но с другой стороны возрастает пиковое значение, поскольку пока кэш не обновился то все запросы пойдут в базу. Есть много стратегий кэширования чтобы избежать данного варианта, например блокировать следующие запросы или обновлять когда ttl кэша почти истек. Блокировка запросов может выглядеть примерно так:
 

package user

import (
    "context"

    "gitoa.ru/go-4devs/cache"
    "gitoa.ru/go-4devs/cache/mw"
    "gitoa.ru/go-4devs/cache/provider/memory"
)

func NewCache() *cache.Cache {
    var storage func(ctx context.Context, id int64) (User, error)
    return cache.New(memory.Map(), mw.WithFallback(
        mw.CachesFallbackSuccess(
            mw.LockFallback(func(ctx context.Context, d *cache.Item) error {
                // load in db
                return nil
            }),
        ),
        mw.HandleByErr,
    ))
}

Одно дело когда вы обращаетесь к своей базе и можете это контролировать, но если у вас микросервисы то вы просто начинаете ddos что может повлиять на всю систему в целом.

Выводы

Кэширование в golang мало чем отличается от других языков. Тут скорее вопрос не языка, а реализации. Все достаточно просто и поддерживаемо, можно не привязываться к источнику данных если соблюдать некоторые правила. Реализация с библиотекой кэширования в каких то случаях может вам не подойти, тогда вам скорее надо подумать о кодогенерации, отказаться от провайдеров и middleware, но все равно в сервисах использовать интерфейсы чтобы не зависеть от реализации.

Дополнительные материалы