Производительность Redis и атомарность в Golang. Возможности конвейеров, транзакций и Lua-скриптов

Redis  —  технология, применяемая во многих продуктах. Начать работу с ней и интегрировать в кодовую базу просто, но имеется в Redis функционал и посложнее: конвейеры, транзакции и Lua-скрипты  —  для повышения производительности и надежности.

На бесплатном уровне Redis-кластера Upstash можно использовать до 10 000 запросов в день  —  неплохое начало.

Кэшируем блог на Redis

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

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

Сначала для имитации поведения БД создаем простую функцию:

type Post struct {
Slug string // уникальный идентификатор
Content string
}

func GetLastNPostsFromDatabase(n int) []Post {
var posts []Post
for i := 0; i < n; i++ {
posts = append(posts, Post{
Slug: fmt.Sprintf("post-%d-slug", i),
Content: fmt.Sprintf("Some random content for the post #%d", i),
})
}
return posts
}

ََЗатем сохраняем статьи одну за другой:

func FillCacheWithPostsOneByOne(ctx context.Context, rdb *redis.Client, posts []Post) error {
for _, post := range posts {
// сохраняем одну за другой каждую статью
if err := rdb.Set(ctx, fmt.Sprintf("post:%s", post.Slug), post.Content, 0).Err(); err != nil {
return err
}
}
return nil
}

Функции FillCacheWithPostsOneByOne требуется клиент Redis. Чтобы подключиться, устанавливаем Redis на компьютер с помощью Docker, автономной установки или бесплатного Redis-кластера Upstash.

К локальному экземпляру Redis подключаемся таким кодом:

rdb := redis.NewClient(&redis.Options{})

А с Upstash код подключения легко копируется с дашборда:

func main() {
rdb := ...

startTime := time.Now()
posts := GetLastNPostsFromDatabase(100)
if err := FillCacheWithPostsOneByOne(context.Background(), rdb, posts); err != nil {
log.Printf("error while filling the cache: %v\n", err)
}
fmt.Println("took ", time.Since(startTime))
}

Время выполнения рекомендуется замерять инструментом бенчмаркинга Golang. Но, чтобы не загромождать статью, мы воспользовались пакетом time.

Вот полный код.

В зависимости от того, как далеко компьютер от сервера Redis, это примерно 100 мс  —  не так уж много. Но в БД этого примера всего 100 статей. Что, если их будет миллион? Если на каждый запрос требуется одна миллисекунда, на все  —  около 16 минут.

Почему так медленно?

  • Задержка между экземпляром Redis и клиентом.
  • В Redis для каждого запроса должен выполняться системный вызов.
  • Затраты клиента на ввод-вывод.

Обсудим решения этих проблем.

Конвейеры  —  отправка сразу всех запросов

В Redis одним запросом отправляются сразу все. Такой подход называется конвейером: get— и/или set-запросы все вместе отправляются в экземпляр Redis. Когда все выполнены, обратно отправляются результаты.

В Redis отправляются сразу все команды, после их выполнения все результаты вместе возвращаются  —  идеально для нашего сценария:

func FillCacheWithPostsInBatches(ctx context.Context, rdb *redis.Client, posts []Post) error {
_, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
for _, post := range posts {
// сохраняем одну за другой каждую статью
if err := pipe.Set(ctx, fmt.Sprintf("post:%s", post.Slug), post.Content, 0).Err(); err != nil {
return err
}
}
return nil
})
return err
}

С go-redis методом Pipelined записываем код в функции или, чтобы добавлять новые команды в конвейер и потом выполнять их, методом Pipeline возвращаем экземпляр конвейера.

Результат: на 100 статей потребовалось 7 мс, на 1000  —  те же 7 мс, на 10 000  —  всего 24 мс. Сравним это с первым подходом:

На графике представлен логарифмический рост

На миллион статей потребуется не 16 минут, а гораздо меньше  —  всего примерно 2 сек. Если по какой-то причине кэш опустошается, он почти мгновенно заполняется снова.

В этом сценарии мог использоваться MSet, ведь в Redis команда с несколькими значениями выполняется M-командами: MSet, MGet и HMset. Одним запросом разные команды здесь не запускаются, зато есть конвейер.

Ограничения конвейера

  • Значение ключа получается после выполнения всех команд. Нельзя реализовать логику, по которой значения одних ключей требуются для изменения значений других. Обсудим это позже.
  • Конвейер  —  не транзакция: между командами отправляются и выполняются запросы других клиентов Redis.

Во время выполнения транзакции другие команды не выполняются

Redis  —  одноядерное приложение. Здесь об атомарности не задумываешься, ведь Redis атомарен по природе. С командами вроде INCRBY не нужно получать, изменять и задавать значение, чреватое несогласованностью.

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

Одно из преимуществ транзакций  —  команда WATCH. В Redis изменения применяются, если во время транзакции не изменился ключ, к которому задействована эта команда.

Обсудим новый сценарий, где нужно ограничить количество просмотров страницы, например, десятью: только первые десять человек увидят код. Как реализовать это с Redis и транзакциями?

Оптимистическая блокировка с WATCH

Ключи, к которым применена команда WATCH, отслеживаются для выявления изменений в них. Если хотя бы один отслеживаемый ключ изменен до команды EXEC, вся транзакция прерывается и в EXEC возвращается Null  —  транзакция не выполнена.

То есть получив список отслеженных ключей и запустив логику, если ключи не изменились, сохраняем результат логики с транзакцией, в противном случае транзакция не выполняется:

func MakeNewPage(ctx context.Context, rdb *redis.Client, slug string, viewLimit int) error {
if err := rdb.Set(ctx, fmt.Sprintf("page:%s:views", slug), 0, 0).Err(); err != nil {
return fmt.Errorf("error while saving page default view: %v", err)
}
if err := rdb.Set(ctx, fmt.Sprintf("page:%s:viewLimit", slug), viewLimit, 0).Err(); err != nil {
fmt.Errorf("error while setting page view limit: %v", err)
}
return nil
}

Сначала пишем функцию, которой для каждой статьи создается два элемента: page:slug:views для текущих просмотров со значением по умолчанию 0 и page:slug:viewLimit для предельного параметра просмотров:

func CheckIfCanVisitPageWithoutTransaction(ctx context.Context, rdb *redis.Client, slug string) (bool, error) {
limit, err := rdb.Get(ctx, fmt.Sprintf("page:%s:viewLimit", slug)).Int()
if err != nil {
return false, fmt.Errorf("error while getting page view limit: %v", err)
}

currentViews, err := rdb.Get(ctx, fmt.Sprintf("page:%s:views", slug)).Int()
if err != nil {
return false, fmt.Errorf("error while getting page's current views: %v", err)
}

// страницей достигнуто ограничение просмотра
if currentViews >= limit {
return false, nil
}

// добавление нового просмотра
if err := rdb.Set(ctx, fmt.Sprintf("page:%s:views", slug), currentViews+1, 0).Err(); err != nil {
// в случае ошибки просмотр не добавляется, страница пользователю не показывается
return false, fmt.Errorf("error while saving page default view: %v", err)
}
return true, nil
}

Затем в этой же функции проверяем, видит ли пользователь страницу: получаем ограничение просмотра и текущий просмотр страницы. Если пользователь ее видит, просмотры увеличиваются на 1.

Но безопасна ли эта функция? Нет, если приложение популярно.

Рассмотрим пограничный случай:

А что, если два пользователя оказываются на странице одновременно? Итоговое значение посещений будет не 11, а 10.

Как это предотвратить? У этой проблемы два решения.

  1. Запускаем всю логику в Lua-скрипте, они в Redis выполняются атомарно. Поэтому второй пользователь ждет, когда первый завершит подсчет, затем посещений станет 11 и второй не увидит страницу.
  2. Запускаем обоих одновременно. Но, если во время этого выполнения значение ключа visits изменено, новое его значение в Redis не задается и транзакция прерывается.

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

Исправим ограничитель просмотров с WATCH

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

func CheckIfCanVisitPageWithoutTransaction(ctx context.Context, rdb *redis.Client, slug string) (bool, error) {
viewLimitKey := fmt.Sprintf("page:%s:viewLimit", slug)
viewsKey := fmt.Sprintf("page:%s:views", slug)

canView := false
return canView, rdb.Watch(ctx, func(tx *redis.Tx) error {
// если эти значения изменятся, с «tx» вместо «rdb» транзакция гарантированно не выполнится
limit, err := tx.Get(ctx, viewLimitKey).Int()
if err != nil {
return fmt.Errorf("error while getting page view limit: %v", err)
}

currentViews, err := tx.Get(ctx, viewsKey).Int()
if err != nil {
return fmt.Errorf("error while getting page's current views: %v", err)
}

// страницей достигнуто ограничение просмотра
if currentViews >= limit {
return nil
}

_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// добавление нового просмотра
if err := pipe.Set(ctx, viewsKey, currentViews+1, 0).Err(); err != nil {
// в случае ошибки просмотр не добавляется, страница пользователю не показывается
return fmt.Errorf("error while saving page default view: %v", err)
}
return nil
})

if err != nil {
return fmt.Errorf("error while executing the pipeline: %v", err)
}
canView = true
return nil

}, viewsKey)

}

В методе watch получается обратный вызов и список ключей для отслеживания:

Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error

В метод watch здесь передан viewsKey, а в обратном вызове использован TxPipelined, которым в Redis обертываются команды блока MULTI/EXEC. Прежде чем получить значения из Redis, указываем ключ views. Получив ключи и выполнив кое-какую работу, с помощью транзакции сохраняем в Redis новое значение. Если транзакция внутри команды WATCH и значение изменено, она не выполнится.

Теперь смоделируем сценарий, где ключ, к которому применена команда WATCH, изменен:

package main

import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"sync"
"time"
)

func MakeNewPage(ctx context.Context, rdb *redis.Client, slug string, viewLimit int) error {
if err := rdb.Set(ctx, fmt.Sprintf("page:%s:views", slug), 0, 0).Err(); err != nil {
return fmt.Errorf("error while saving page default view: %v", err)
}
if err := rdb.Set(ctx, fmt.Sprintf("page:%s:viewLimit", slug), viewLimit, 0).Err(); err != nil {
fmt.Errorf("error while setting page view limit: %v", err)
}
return nil
}

func CheckIfCanVisitPageWithoutTransaction(ctx context.Context, rdb *redis.Client, slug string) (bool, error) {
viewLimitKey := fmt.Sprintf("page:%s:viewLimit", slug)
viewsKey := fmt.Sprintf("page:%s:views", slug)

canView := false
return canView, rdb.Watch(ctx, func(tx *redis.Tx) error {
// если эти значения изменятся, с «tx» вместо «rdb» транзакция гарантированно не выполнится
limit, err := tx.Get(ctx, viewLimitKey).Int()
if err != nil {
return fmt.Errorf("error while getting page view limit: %v", err)
}

currentViews, err := tx.Get(ctx, viewsKey).Int()
if err != nil {
return fmt.Errorf("error while getting page's current views: %v", err)
}

<-time.After(time.Second) // добавлена ручная задержка

// страницей достигнуто ограничение просмотра
if currentViews >= limit {
return nil
}

_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// добавление нового просмотра
if err := pipe.Set(ctx, viewsKey, currentViews+1, 0).Err(); err != nil {
// в случае ошибки просмотр не добавляется, страница пользователю не показывается
return fmt.Errorf("error while saving page default view: %v", err)
}
return nil
})

if err != nil {
return fmt.Errorf("error while executing the pipeline: %v", err)
}
canView = true
return nil

}, viewsKey)

}

func main() {
rdb := redis.NewClient(&redis.Options{})

if err := MakeNewPage(context.Background(), rdb, "test-1", 10); err != nil {
panic(err)
}

var wg sync.WaitGroup
wg.Add(2)
go func() {
can, err := CheckIfCanVisitPageWithoutTransaction(context.Background(), rdb, "test-1")
if err != nil {
panic(err)
}
fmt.Println("Can #1", can)
wg.Done()
}()
go func() {
<-time.After(time.Millisecond * 500) // чтобы первым гарантированно изменить значение второго
can, err := CheckIfCanVisitPageWithoutTransaction(context.Background(), rdb, "test-1")
if err != nil {
panic(err)
}
fmt.Println("Can #2", can)
wg.Done()
}()
wg.Wait()

}

Запускаем код:

Can #1 true
panic: error while executing the pipeline: redis: transaction failed

Транзакция не выполнилась. Но у страницы лишь один просмотр, и пользователю нужно увидеть ее, а не страницу «500».

Код необходимо повторить:

func TransactionWithRetry(callback func() error, maxRetries int) error {
retries := 0
for {
err := callback()
// транзакция выполнена
if err == nil {
return nil
}

if errors.Is(err, redis.TxFailedErr) {
retries++
fmt.Println("> retry happened.")
if retries > maxRetries {
return ErrMaxRetriesReached
}
continue
}

// случилось нечто неожиданное
return err

}
}

Мы создали эту простую функцию для повтора транзакций. Вот весь код.

Командой MONITOR логируем каждую команду, вот что происходит:

1696485643.305621 [0 172.17.0.1:46678] "watch" "page:test-1:views"
1696485643.306484 [0 172.17.0.1:46678] "get" "page:test-1:viewLimit"
1696485643.307711 [0 172.17.0.1:46678] "get" "page:test-1:views"
1696485644.311047 [0 172.17.0.1:46678] "multi"
1696485644.311065 [0 172.17.0.1:46678] "set" "page:test-1:views" "1"
1696485644.311069 [0 172.17.0.1:46678] "exec"
1696485644.312144 [0 172.17.0.1:46678] "unwatch"

Код разделен на две части: WATCH и MULTI/EXEC.

Другие команды Redis не выполняются только из-за группы команд внутри MULTI/EXEC. Поэтому другие части кода выполняются с другими командами конкурентно. То есть производительность Redis в целом повышается.

Но не с Lua-скриптами: другие запросы Redis останутся неотвеченными, пока Lua-скрипт не выполнится полностью.

Lua-скрипты для полностью атомарной транзакции

С транзакциями в Redis разобрались, перейдем к Lua-скриптам и сравним их с MULTI/EXEC.

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

Но для простого использования они намного медленнее встроенных команд. Поэтому всегда старайтесь находить альтернативные решения со встроенными командами или транзакциями Redis и потом, если это целесообразно, применяйте Lua-скрипты.

Во время выполнения Lua-скриптов выполнение других команд в Redis прекращается. Конкурентное выполнение транзакций через систему многоверсионного управления конкурентным доступом поддерживается базой данных вроде PostgreSQL. Каждой транзакцией данные обрабатываются независимо.

Но в одноядерном Redis одномоментно выполняется только одна команда. Поэтому, пока выполнение не завершено, Lua-скриптами используется это единственное ядро.

Преобразуем логику ограничения просмотров в Lua-скрипт script.lua:

local viewLimit = redis.call("GET", "page:" .. KEYS[1] .. ":viewLimit")
local currentViews = redis.call("GET", "page:" .. KEYS[1] .. ":views")
-- преобразовываем их в число. В «redis.call("GET")» возвращается строка
viewLimit = tonumber(viewLimit)
currentViews = tonumber(currentViews)
-- достигнуто ограничение просмотра
if currentViews >= viewLimit then
return "no"
end
-- пользователь просматривает страницу, добавляем к ключу «views» новый просмотр
redis.log(redis.LOG_WARNING, currentViews)
redis.call("SET", "page:" .. KEYS[1] .. ":views", currentViews + 1)
return "yes"

Доступ к Redis получаем в скрипте с помощью API redis. Не забывайте: в redis.call возвращается строка, функцией tonumber преобразовываем ее в число.

func main() {
rdb := redis.NewClient(&redis.Options{})

if err := MakeNewPage(context.Background(), rdb, "test-1", 10); err != nil {
panic(err)
}

scriptContent, _ := os.ReadFile("./script.lua")
script := redis.NewScript(string(scriptContent))

var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(i int) {
can, err := script.Run(context.Background(), rdb, []string{"test-1"}).Result()
if err != nil {
panic(err)
}
fmt.Printf("can #%d = %v\n", i, can)
wg.Done()
}(i)
}
wg.Wait()

}

Вызвав функцию NewScript, создаем новый скрипт. В Redis не нужно загружать его при каждом выполнении. Пакетом go-redis скрипт преобразуется в sha-хеш, с помощью которого после первой загрузки скрипта выполняются последующие команды:

hash: hex.EncodeToString(h.Sum(nil)),

Lua-скрипт против WATCH в примере с ограничителем просмотров

Запускаем бенчмарк-тест для 200 одновременных запросов.

  • Lua-скрипт: 100 мс.
  • Транзакция с повтором: 4 сек.

Разница существенная. Почему? Проверим выполнение пошагово:

"evalsha" "0386194a77d3b727ec14ba5a257a667f2be4792d" "1" "test-1"

Lua-скрипт запускается одной командой, и скриптом внутри экземпляра Redis доступ к данным получается без какого-либо времени приема-передачи.

С другой стороны, транзакцией выполняется семь различных запросов к серверу Redis, чем добавляется немалая задержка:

1696488282.976886 [0 172.17.0.1:49286] "watch" "page:test-1:views"
1696488282.977527 [0 172.17.0.1:49286] "get" "page:test-1:viewLimit"
1696488282.978079 [0 172.17.0.1:49286] "get" "page:test-1:views"
1696488283.989333 [0 172.17.0.1:49286] "multi"
1696488283.989357 [0 172.17.0.1:49286] "set" "page:test-1:views" "2"
1696488283.989360 [0 172.17.0.1:49286] "exec"
1696488283.990405 [0 172.17.0.1:49286] "unwatch"

Это основная причина, почему для реализации ограничителя скорости go-redis используется Lua-скрипт. Подробнее об этом здесь.

Заключение

В Redis имеется функционал для значительного повышения производительности и надежности приложений. Поняв, когда и как использовать конвейеры, транзакции и Lua-скрипты, вы задействуете в проектах весь потенциал Redis.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Mohammad Hoseini Rad: Redis Performance and Atomicity in Golang: Unleash the Power of Pipelines, Transactions, and Lua Scripts

Предыдущая статьяНаписание кода как создание бестселлера
Следующая статьяШаблоны проектирования в React