Существует несколько уровней кэша в приложении. Например кэширование на уровне 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, но все равно в сервисах использовать интерфейсы чтобы не зависеть от реализации.
Дополнительные материалы
- Пишем простой менеджер кеша в памяти на Go
- beego cache
- patrickmn cache
- gadelkareem cachita
- hashicorp/golang-lru