В процессе работы над небольшими проектами часто возникает необходимость в кешировании данных и бывает так, что нет возможности использовать 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)


  1. RPG18
    24.05.2018 11:51

    sync.RWMutex? Я для таких вещей использую sync.Map


    1. Maxchagin Автор
      24.05.2018 12:01

      Да, действительно можно использовать sync.Map, но для небольших проектов разницы скорее всего вы не почувствуете.


      1. claygod
        24.05.2018 21:12

        sync.Map удобный, но не имеет метода возврата количества хранящихся в нём сущностей. Можно конечно их учитывать снаружи, но… Ну и довольно медленный он, по крайней мере по сравнению с мьютексами (не всем конечно нужно считать наносекунды, но всё же учитывать некоторый оверхед нужно).


        1. bat
          25.05.2018 06:49

          Ну и довольно медленный он, по крайней мере по сравнению с мьютексами

          Сам бенчмарков не делал, столь категорично утверждать не буду, но как универсальное решение тоже бы не посоветовал.
          Документация описывает 2 конкретных случаях, когда sync.Map выгоднее мапки с мьютексом, но в общем случае для реализации кеша может сыграть как в плюс так и в минус.


  1. korjavin
    24.05.2018 12:16

    func (c *Cache) StartGC() error {
    go c.GC()
    return nil
    }

    error и return nil можно смело убрать.


    1. Maxchagin Автор
      24.05.2018 12:16

      Спасибо за сообщение! Поправил.


      1. WebProd
        24.05.2018 13:25

        Для чего в Get bool? Ведь если элемент не найден, то мы и так nil возвращаем, по нему и понятно


        1. DorianPeregrim
          24.05.2018 14:14

          Как вариант, по ключу может храниться nil.


    1. bat
      25.05.2018 07:47

      здесь один метод вообще лишний, оставить только GC и стартовать его в функции New

      еще 5 копеек
      — не стоит светить все функции наружу, тот же GC, есть интерфейс Get/Set/Delete остальные функции не должны быть доступны снаружи
      — defer не бесплатен, когда функции делает много под блокировкой, это оправдано, для чтения/записи в map это избыточно
      — не надо держать блокировку без необходимости, получил блокировку, прочитал/записал в мапку, отпустил блокировку и дальше уже разбираешься с тем что получил (это про Get)
      — зачем delete возврат ошибки? она бесполезна + ради этого двойной поиск в мапке
      — сборка мусора вообще странно сделана, зачем в два этапа? нет гарантии что между expiredKeys и clearItems значения не будут обновлены


      1. 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


    1. JekaMas
      25.05.2018 09:47

      Del


  1. Sobic
    24.05.2018 12:47

    А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним? Еще вопрос: если время жизни по-умолчанию будет 10 и при добавлении нового элемента я захочу, чтоб он не протухал и установлю duration 0, правильно ли я понимаю, что желаемого я не получу? (в go просто новичок)


    1. Maxchagin Автор
      24.05.2018 14:19

      А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним?

      Duration необходим для вычисления значения expiration, которое используется в методах Get и GC


      Еще вопрос: если время жизни по-умолчанию будет 10 и при добавлении нового элемента я захочу, чтоб он не протухал и установлю duration 0, правильно ли я понимаю, что желаемого я не получу? (в go просто новичок)

      Прошу прощение, не уточнил этот момент в статье: что бы кеш не протухал необходимо установить значение duration равное -1, в этом случае expiration будет равен 0.


      1. Sobic
        24.05.2018 14:41

        Как раз в статье есть упоминание: «defaultExpiration — время жизни кеша по-умолчанию, если установлено значение меньше или равно 0 — время жизни кеша бессрочно.»
        А насчет duration: он нужен будучи параметром метода Set, но не параметром item, который нигде не используется


      1. anjensan
        24.05.2018 16:01

        Duration необходим для вычисления значения expiration, которое используется в методах Get и GC
        Хоть убейте, не могу найти строчку, где вы считываете это поле (не путайте его с аргументом функции duration).


        1. Maxchagin Автор
          24.05.2018 16:10

          Да, согласен, в примере он действительно не нужен. Исправил. Спасибо.


  1. 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


  1. freezlite
    24.05.2018 15:48

    Я думаю для столь тривиальной реализации можно было добавить механизм снепшотов на диск.


  1. anjensan
    24.05.2018 15:56

    А зачем в исходниках так много пустых строк? Например

    // StartGC start Garbage Collection
    func (c *Cache) StartGC() {
    
    	go c.GC()
    
    }


  1. el777
    24.05.2018 16:15

    Смотрели имеющиеся реализации встраиваемых кешей, например, groupcache?
    Интересно бы узнать, почему не подошло, какие дополнительные фичи потребовались.


    1. Maxchagin Автор
      24.05.2018 16:28
      +1

      На проде в последнем проекте использовал go-cache, по функционалу все устраивает. Данный материал исключительно для академических целей, старался максимально не усложнять.


      1. el777
        24.05.2018 17:05

        Я видел дисклеймер про учебные цели, и горячо это одобряю )
        Были интересны другие реализации и практический отзыв по работе с ними.


  1. pixelcube
    24.05.2018 16:48

    Для GC лучше использовать очередь с приоритетом


  1. void_one
    24.05.2018 17:21
    +1

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


  1. JekaMas
    24.05.2018 20:06

    Баловался этой темой. Если нужна скорость, то надо меньше локов и отказываться от interface. Поэтому приходится прощаться с map, sync.map.
    В итоге решение было с генератором кэша под заданную структуру. https://github.com/JekaMas/nicecache
    На чтение и запись в несколько горутин получалось выйти в 60-80нс.