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

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

Kubernetes Guides - наш канал о Kubernets

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.

Источник

Report Page