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.
Как это предотвратить? У этой проблемы два решения.
- Запускаем всю логику в Lua-скрипте, они в Redis выполняются атомарно. Поэтому второй пользователь ждет, когда первый завершит подсчет, затем посещений станет 11 и второй не увидит страницу.
- Запускаем обоих одновременно. Но, если во время этого выполнения значение ключа 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.
Читайте также:
- Сравниваем эффективность Redis, Kafka и RabbitMQ
- Как ускорить отклик и повысить производительность при помощи кэширования Redis
- Создание оркестратора для событийно-ориентированного приложения с Golang и RabbitMQ
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mohammad Hoseini Rad: Redis Performance and Atomicity in Golang: Unleash the Power of Pipelines, Transactions, and Lua Scripts