В процессе работы над небольшими проектами часто возникает необходимость в кешировании данных и бывает так, что нет возможности использовать Redis или Memcache. В таких ситуациях подойдет простой и достаточно эффективный способ без использования дополнительных инструментов — кеширование в оперативной памяти.
В этой статье я расскажу, с чего начать, чтобы самостоятельно написать менеджер кеша в памяти на Go.
Внимание! Данная статья предназначена для начинающих разработчиков исключительно в академических целях и здесь не рассматриваются такие инструменты как Redis, Memcache и т.д
Кроме того мы не будем углубляться в проблемы выделения памяти.
Для простоты ограничимся тремя основными методами: установка Set
, получение Get
и удаление Delete
.
Данные будем хранить в формате ключ/значение.
Структура
Первое, что необходимо сделать, это создать структуру описывающую наш контейнер-хранилище:
type Cache struct {
sync.RWMutex
defaultExpiration time.Duration
cleanupInterval time.Duration
items map[string]Item
}
sync.RWMutex
— для безопасного доступа к данным во время чтения/записи (подробнее о мьютексах https://gobyexample.com/mutexes),defaultExpiration
— продолжительность жизни кеша по-умолчанию (этот параметр можно будет переопределить для каждого элемента)cleanupInterval
— интервал, через который запускается механизм очистки кеша (Garbage Collector, далее GC)items
— элементы кеша (в формате ключ/значение)
Теперь опишем структуру для элемента:
type Item struct {
Value interface{}
Created time.Time
Expiration int64
}
Value
— значение. Так как оно может быть любое (число/строка/массив и т.д) необходимо указать в качестве типаinterface{}
,Created
— время создания кеша,Expiration
— время истечения (в UnixNano) — по нему будем проверять актуальность кеша
Инициализация хранилища
Начнем с инициализации нового контейнера-хранилища:
func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
// инициализируем карту(map) в паре ключ(string)/значение(Item)
items := make(map[string]Item)
cache := Cache{
items: items,
defaultExpiration: defaultExpiration,
cleanupInterval: cleanupInterval,
}
// Если интервал очистки больше 0, запускаем GC (удаление устаревших элементов)
if cleanupInterval > 0 {
cache.StartGC() // данный метод рассматривается ниже
}
return &cache
}
Инициализация нового экземпляра кеша принимает два аргумента: defaultExpiration
и cleanupInterval
defaultExpiration
— время жизни кеша по-умолчанию, если установлено значение меньше или равно 0 — время жизни кеша бессрочно.cleanupInterval
— интервал между удалением просроченного кеша. При установленном значении меньше или равно 0 — очистка и удаление просроченного кеша не происходит.
На выходе получаем контейнер со структурой Cache
Будьте внимательны при установке этих параметров, слишком маленькие или слишком большие значения могут привести к нежелательным последствиям, например если установить cleanupInterval = 1 * time.Second
поиск просроченных ключей будет происходить каждую секунду, что негативно скажется на производительности вашей программы. И наоборот установив cleanupInterval = 168 * time.Hour
— в памяти будет накапливаться неиспользуемые элементы.
Установка значений
После того как контейнер создан, хорошо бы иметь возможность записывать в него данные, для этого напишем реализацию метода Set
func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
var expiration int64
// Если продолжительность жизни равна 0 - используется значение по-умолчанию
if duration == 0 {
duration = c.defaultExpiration
}
// Устанавливаем время истечения кеша
if duration > 0 {
expiration = time.Now().Add(duration).UnixNano()
}
c.Lock()
defer c.Unlock()
c.items[key] = Item{
Value: value,
Expiration: expiration,
Created: time.Now(),
}
}
Set
добавляет новый элемент в кэш или заменяет существующий. При этом проверка на существования ключей не происходит. В качестве аргументов принимает: ключ-идентификатор в виде строки key
, значение value
и продолжительность жизни кеша duration
.
Получение значений
С помощью Set
мы записали данные в хранилище, теперь реализуем метод для их получения Get
func (c *Cache) Get(key string) (interface{}, bool) {
c.RLock()
defer c.RUnlock()
item, found := c.items[key]
// ключ не найден
if !found {
return nil, false
}
// Проверка на установку времени истечения, в противном случае он бессрочный
if item.Expiration > 0 {
// Если в момент запроса кеш устарел возвращаем nil
if time.Now().UnixNano() > item.Expiration {
return nil, false
}
}
return item.Value, true
}
Get
возвращает значение (или nil
) и второй параметр bool
равный true
если ключ найден и false
если ключ не найден или кеш устарел.
Удаление кеша
Теперь когда у нас есть установка и получение, необходимо иметь возможность удалить кеш (если он нам больше не нужен) для этого напишем метод Delete
func (c *Cache) Delete(key string) error {
c.Lock()
defer c.Unlock()
if _, found := c.items[key]; !found {
return errors.New("Key not found")
}
delete(c.items, key)
return nil
}
Delete
удаляет элемент по ключу, если ключа не существует возвращает ошибку.
Сборка мусора
У нас есть добавление, получение и удаление. Осталось реализовать поиск просроченных ключей с последующей очисткой (GC)
Для этого напишем метод StartGC
, который запускается при инициализация нового экземпляра кеша New
и работает пока программа не будет завершена.
func (c *Cache) StartGC() {
go c.GC()
}
func (c *Cache) GC() {
for {
// ожидаем время установленное в cleanupInterval
<-time.After(c.cleanupInterval)
if c.items == nil {
return
}
// Ищем элементы с истекшим временем жизни и удаляем из хранилища
if keys := c.expiredKeys(); len(keys) != 0 {
c.clearItems(keys)
}
}
}
// expiredKeys возвращает список "просроченных" ключей
func (c *Cache) expiredKeys() (keys []string) {
c.RLock()
defer c.RUnlock()
for k, i := range c.items {
if time.Now().UnixNano() > i.Expiration && i.Expiration > 0 {
keys = append(keys, k)
}
}
return
}
// clearItems удаляет ключи из переданного списка, в нашем случае "просроченные"
func (c *Cache) clearItems(keys []string) {
c.Lock()
defer c.Unlock()
for _, k := range keys {
delete(c.items, k)
}
}
Пример использования
import (
memorycache "github.com/maxchagin/go-memorycache-example"
)
// Создаем контейнер с временем жизни по-умолчанию равным 5 минут и удалением просроченного кеша каждые 10 минут
cache := memorycache.New(5 * time.Minute, 10 * time.Minute)
// Установить кеш с ключем "myKey" и временем жизни 5 минут
cache.Set("myKey", "My value", 5 * time.Minute)
// Получить кеш с ключем "myKey"
i := cache.Get("myKey")
Что дальше?
Теперь у нас есть менеджер кеша с минимальным функционалом, его будет достаточно для самых простых задач. Если этого мало (а в 95% случаев так и есть) в качестве следующего шага можно самостоятельно реализовать методы:
Count — получение кол-ва элементов в кеше
GetItem — получение элемента кеша
Rename — переименования ключа
Copy — копирование элемента
Increment — инкремент
Decrement — декремент
Exist — проверка элемента на существование
Expire — проверка кеша на истечение срока жизни
FlushAll — очистка всех данных
SaveFile — сохранение данных в файл
LoadFile — загрузка данных из файла
Это далеко не полный список, но для базового функционала скорее всего хватит.
Исходники c примером на github
Если вам необходим готовый менеджер кеша в памяти рекомендую обратить внимание на следующие проекты:
Реализация go-cache от patrickmn
MemoryCache от beego
Комментарии (25)
korjavin
24.05.2018 12:16func (c *Cache) StartGC() error {
go c.GC()
return nil
}
error и return nil можно смело убрать.bat
25.05.2018 07:47здесь один метод вообще лишний, оставить только GC и стартовать его в функции New
еще 5 копеек
— не стоит светить все функции наружу, тот же GC, есть интерфейс Get/Set/Delete остальные функции не должны быть доступны снаружи
— defer не бесплатен, когда функции делает много под блокировкой, это оправдано, для чтения/записи в map это избыточно
— не надо держать блокировку без необходимости, получил блокировку, прочитал/записал в мапку, отпустил блокировку и дальше уже разбираешься с тем что получил (это про Get)
— зачем delete возврат ошибки? она бесполезна + ради этого двойной поиск в мапке
— сборка мусора вообще странно сделана, зачем в два этапа? нет гарантии что между expiredKeys и clearItems значения не будут обновленыbat
25.05.2018 07:49по поводу defer
i5-4670 CPU @ 3.40GHz
goos: linux
goarch: amd64
BenchmarkLockUnlock-4 100000000 15.0 ns/op
BenchmarkLockDeferUnlock-4 30000000 44.9 ns/op
PASS
Sobic
24.05.2018 12:47А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним? Еще вопрос: если время жизни по-умолчанию будет 10 и при добавлении нового элемента я захочу, чтоб он не протухал и установлю duration 0, правильно ли я понимаю, что желаемого я не получу? (в go просто новичок)
Maxchagin Автор
24.05.2018 14:19А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним?
Duration необходим для вычисления значения expiration, которое используется в методах Get и GC
Еще вопрос: если время жизни по-умолчанию будет 10 и при добавлении нового элемента я захочу, чтоб он не протухал и установлю duration 0, правильно ли я понимаю, что желаемого я не получу? (в go просто новичок)
Прошу прощение, не уточнил этот момент в статье: что бы кеш не протухал необходимо установить значение duration равное -1, в этом случае expiration будет равен 0.
Sobic
24.05.2018 14:41Как раз в статье есть упоминание: «defaultExpiration — время жизни кеша по-умолчанию, если установлено значение меньше или равно 0 — время жизни кеша бессрочно.»
А насчет duration: он нужен будучи параметром метода Set, но не параметром item, который нигде не используется
anjensan
24.05.2018 16:01Duration необходим для вычисления значения expiration, которое используется в методах Get и GC
Хоть убейте, не могу найти строчку, где вы считываете это поле (не путайте его с аргументом функцииduration
).Maxchagin Автор
24.05.2018 16:10Да, согласен, в примере он действительно не нужен. Исправил. Спасибо.
ddwu
24.05.2018 14:20Год назад писал аналогичный memcache сервер + клиент к нему.
C типами данных: Strings/Lists of Strings/Dictionaries of Strings.
И таким же TTL. :)
github.com/genesem/kvdb/blob/master/docs_russian.md
freezlite
24.05.2018 15:48Я думаю для столь тривиальной реализации можно было добавить механизм снепшотов на диск.
el777
24.05.2018 16:15Смотрели имеющиеся реализации встраиваемых кешей, например, groupcache?
Интересно бы узнать, почему не подошло, какие дополнительные фичи потребовались.Maxchagin Автор
24.05.2018 16:28+1На проде в последнем проекте использовал go-cache, по функционалу все устраивает. Данный материал исключительно для академических целей, старался максимально не усложнять.
el777
24.05.2018 17:05Я видел дисклеймер про учебные цели, и горячо это одобряю )
Были интересны другие реализации и практический отзыв по работе с ними.
void_one
24.05.2018 17:21+1Кэш без контроля размера это просто еще один способ организовать утечку памяти.
JekaMas
24.05.2018 20:06Баловался этой темой. Если нужна скорость, то надо меньше локов и отказываться от interface. Поэтому приходится прощаться с map, sync.map.
В итоге решение было с генератором кэша под заданную структуру. https://github.com/JekaMas/nicecache
На чтение и запись в несколько горутин получалось выйти в 60-80нс.
RPG18
sync.RWMutex? Я для таких вещей использую sync.Map
Maxchagin Автор
Да, действительно можно использовать sync.Map, но для небольших проектов разницы скорее всего вы не почувствуете.
claygod
sync.Map удобный, но не имеет метода возврата количества хранящихся в нём сущностей. Можно конечно их учитывать снаружи, но… Ну и довольно медленный он, по крайней мере по сравнению с мьютексами (не всем конечно нужно считать наносекунды, но всё же учитывать некоторый оверхед нужно).
bat
Сам бенчмарков не делал, столь категорично утверждать не буду, но как универсальное решение тоже бы не посоветовал.
Документация описывает 2 конкретных случаях, когда sync.Map выгоднее мапки с мьютексом, но в общем случае для реализации кеша может сыграть как в плюс так и в минус.