Пишем простой менеджер кеша в памяти на Go

  • Tutorial

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

Поделиться публикацией
Комментарии 30
    0

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

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

          Сам бенчмарков не делал, столь категорично утверждать не буду, но как универсальное решение тоже бы не посоветовал.
          Документация описывает 2 конкретных случаях, когда sync.Map выгоднее мапки с мьютексом, но в общем случае для реализации кеша может сыграть как в плюс так и в минус.
      0
      func (c *Cache) StartGC() error {
      go c.GC()
      return nil
      }

      error и return nil можно смело убрать.
        0
        Спасибо за сообщение! Поправил.
          0

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

            0

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

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

          еще 5 копеек
          — не стоит светить все функции наружу, тот же GC, есть интерфейс Get/Set/Delete остальные функции не должны быть доступны снаружи
          — defer не бесплатен, когда функции делает много под блокировкой, это оправдано, для чтения/записи в map это избыточно
          — не надо держать блокировку без необходимости, получил блокировку, прочитал/записал в мапку, отпустил блокировку и дальше уже разбираешься с тем что получил (это про Get)
          — зачем delete возврат ошибки? она бесполезна + ради этого двойной поиск в мапке
          — сборка мусора вообще странно сделана, зачем в два этапа? нет гарантии что между expiredKeys и clearItems значения не будут обновлены
            0
            по поводу 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
            0
            Del
            0
            А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним? Еще вопрос: если время жизни по-умолчанию будет 10 и при добавлении нового элемента я захочу, чтоб он не протухал и установлю duration 0, правильно ли я понимаю, что желаемого я не получу? (в go просто новичок)
              0
              А зачем в структуре Item Duration? Мы при добавлении нового элемента вычисляем когда он протухнет и в дальнейшем время жизни никак не используем, зачем тогда храним?

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


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

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

                0
                Как раз в статье есть упоминание: «defaultExpiration — время жизни кеша по-умолчанию, если установлено значение меньше или равно 0 — время жизни кеша бессрочно.»
                А насчет duration: он нужен будучи параметром метода Set, но не параметром item, который нигде не используется
                  0
                  Duration необходим для вычисления значения expiration, которое используется в методах Get и GC
                  Хоть убейте, не могу найти строчку, где вы считываете это поле (не путайте его с аргументом функции duration).
                    0
                    Да, согласен, в примере он действительно не нужен. Исправил. Спасибо.
                0
                Год назад писал аналогичный memcache сервер + клиент к нему.
                C типами данных: Strings/Lists of Strings/Dictionaries of Strings.
                И таким же TTL. :)

                github.com/genesem/kvdb/blob/master/docs_russian.md
                  0
                  Я думаю для столь тривиальной реализации можно было добавить механизм снепшотов на диск.
                    0
                    А зачем в исходниках так много пустых строк? Например
                    // StartGC start Garbage Collection
                    func (c *Cache) StartGC() {
                    
                    	go c.GC()
                    
                    }
                      0

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

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

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

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

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

                            0

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

                              0

                              В такой реализации будет катастрофа на большом количестве элементов. Под map будет выделяться и удерживаться избыточная память, даже когда все элементы протухнут. GC (тот, который родной гошный) будет каждый раз ходить по всем элементам. Чтобы избежать таких проблем используется sync.Map, и для него достаточно написать один time.Ticker.

                                0
                                согласен, кроме последнего предложения
                                sync.Map не решит проблему с gc, т.к. хранящиеся в мапке указатели никуда не денутся
                                здесь только один путь — уменьшать кол-во указателей, как, например, сделали вот эти ребята: allegro.tech/2016/03/writing-fast-cache-service-in-go.html
                                  –1

                                  а вот и нет! смотрим 96 строку sync/map.go: entry{p: unsafe.Pointer(&i)


                                  поэтому GC и не нагружается из-за хранения unsafe-ссылок на значения в sync.Map

                                    +2
                                    В этом есть какая-то магия? Просто не работал с пакетом unsafe.
                                    Есть какие-нибудь тесты, которые подтверждают облегчение жизни gc при использовании sync.Map?

                                    В коде вижу вот это:
                                    return &entry{p: unsafe.Pointer(&i)}
                                    создается ссылка на entry и далее она хранится в
                                    map[interface{}]*entry
                                    Сокращения кол-ва ссылок не вижу, плюс объект, ссылка на который сохранена в map, продолжает жить в куче. В чем профит.
                                      0
                                      Да, ты прав, unsafe.Pointer обычный указатель для GC

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое