Go: кеширование в микросервисах с использованием Memcached

Понимание нюансов того, когда и как использовать кеширование, важно при создании микросервисов. В этом посте мы разберем общие понятиях о кешировании, некоторых конкретных деталях о memcached и рассмотрим фактический пакет для использования memcached в Go: github.com/bradfitz/gomemcache.

Зачем необходимо кэширование?

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

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

С другой стороны, если кэширование используется неправильно, это может привести к дополнительной задержке (в случае распределенных хранилищ данных, таких как memcached), нехватке памяти (в случае локального внутрипроцессного кэширования), устаревшим результатам или даже внутренним ошибкам, которые делают наши услуги терпят неудачу.

Вот почему, прежде чем рассматривать возможность кэширования чего-либо, нам необходимо иметь четкие ответы на следующие вопросы:

  1. Можем ли мы ускорить получение результатов каким-либо другим способом?
  2. Знаем ли мы точно, как аннулировать результаты?
  3. Используем ли мы распределенное кэширование или внутрипроцессное кэширование? Плюсы/минусы очевидны?

Давайте еще немного расширим эти вопросы.

Можем ли мы ускорить получение результатов каким-либо другим способом?

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

Если мы говорим о сложном алгоритме, скажем, о вызове, требующем сортировки результатов, возможно, вместо этого мы могли бы изменить сам алгоритм.

В более конкретных случаях, например, при создании HTTP-сервиса, возвращающего ресурсы, использование CDN (сети доставки контента) имеет больше смысла.

Знаем ли мы точно, как аннулировать результаты?

При кэшировании мы меньше всего хотим возвращать устаревшие результаты, поэтому важно знать, когда их следует признать недействительными.

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

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

В конце концов, важно всегда иметь возможность аннулировать эти результаты.

Используем ли мы распределенное кэширование или внутрипроцессное кэширование? Плюсы/минусы очевидны?

Distributed caching является хорошим решением, когда микросервис имеет несколько экземпляров, тогда они могут ссылаться на одни и те же результаты, однако это добавляет еще один сетевой вызов к нашему сервису, который может замедлить работу. Измерение этих вызовов, а также знание того, какие ключи используются, помогает нам. определить, что изменить, если присутствуют горячие клавиши.

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

In-process caching это еще один способ управления кэшированием, однако из-за характера этих кэшированных значений мы должны четко знать, сколько памяти у нас есть и, следовательно, сколько данных мы можем хранить. Благодаря этому решению у нас нет возможности аннулировать результаты по всем направлениям без взаимодействия с экземплярами напрямую; но мы точно знаем, что дополнительного сетевого вызова не произойдет.

Кэширование с помощью memcached

Memcached — это хранилище значений ключей в памяти для небольших фрагментов произвольных данных (строк, объектов) из результатов вызовов базы данных, вызовов API или рендеринга страниц.

API memcached настолько прост, но настолько мощен, что большую часть времени мы будем использовать два метода: Get и Set; Прежде чем использовать эти методы, важно помнить, что нужно преобразовать данные в []byte, например, предполагая, что у нас есть тип структуры Name:

// server.go
type Name struct {
    NConst    string `json:"nconst"`
    Name      string `json:"name"`
    BirthYear string `json:"birthYear"`
    DeathYear string `json:"deathYear"`
}

При использовании NConst в качестве ключа для кэшированной записи мы сначала устанавливаем преобразование значения, используя encoding/gob:

// memcached.go
func (c *Client) SetName(n Name) error {
    var b bytes.Buffer

    if err := gob.NewEncoder(&b).Encode(n); err != nil {
        return err
    }

    return c.client.Set(&memcache.Item{
        Key:        n.NConst,
        Value:      b.Bytes(),
        Expiration: int32(time.Now().Add(25 * time.Second).Unix()),
    })
}

Затем аналогичный процесс используется при его получении, где значение преобразуется, если оно существует:

// memcached.go
func (c *Client) GetName(nconst string) (Name, error) {
    item, err := c.client.Get(nconst)
    if err != nil {
        return Name{}, err
    }

    b := bytes.NewReader(item.Value)

    var res Name

    if err := gob.NewDecoder(b).Decode(&res); err != nil {
        return Name{}, err
    }

    return res, nil
}

В примере кода у нас есть гипотетический HTTP-сервер, который возвращает значения, поступающие из постоянной базы данных. Фактическое его использование выглядит следующим образом:

// server.go
router.HandleFunc("/names/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := mux.Vars(r)["id"]

    val, err := mc.GetName(id)
    if err == nil {
        renderJSON(w, &val, http.StatusOK)
        return
    }

    name, err := db.FindByNConst(id)
    if err != nil {
        renderJSON(w, &Error{Message: err.Error()}, http.StatusInternalServerError)
        return
    }

    _ = mc.SetName(name) // XXX: consider error

    renderJSON(w, &name, http.StatusOK)
})

Рабочий процесс всегда один и тот же:

  1. Получите значение из memcached, если оно существует, верните его.
  2. Если он не существует, запросите исходное хранилище данных и сохраните его в memcached.

Последние мысли

Кэширование — отличный способ улучшить взаимодействие с пользователем наших сервисов, поскольку оно позволяет нам быстрее возвращать результаты нашим клиентам. В частности, memcached нам необходимо измерить использование, чтобы определить, когда следует масштабировать или, возможно, добавить дополнительные механизмы кэширования, чтобы обеспечить идеальную работу.