Golang - полезный язык программирования, который может эффективно решать повседневные проблемы. Его легко освоить, и для его хорошей работы не нужно писать много кода.

Давайте посмотрим, как Golang может помочь в простом и практичном случае, связанном с копированием большого количества ключей Redis.

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

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

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

Внутренне он работает, выполняя команды DUMP + DEL в исходном экземпляре и создавая их в целевом экземпляре с помощью RESTORE. Однако версия Amazon в то время не поддерживала эту команду.

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

Шаг 1. Давайте напишем простой код

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

Подготовка

Для этой задачи я выбрал две базовые библиотеки:

  • Radix для подключения к Redis API, и
  • Кобра, чтобы упростить создание интерфейса командной строки для инструмента.

Интерфейс готов, он поддерживает параметр «шаблон» для сопоставления ключей и параметр «limit» для определения максимального количества ключей. Источник и место назначения указываются в качестве аргументов и также являются обязательными.

Основной цикл

Radix поддерживает создание структуры «сканера», которая помогает вам перебирать ключи:

Петля готова. Осталось прочитать и восстановить данные в целевом объекте. Я присоединился к командам PTTL и DUMP, чтобы получить время жизни и значение ключа в конвейере, чтобы сэкономить время выполнения.

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

Полный код можно найти здесь: https://github.com/obukhov/go-redis-migrate/blob/v1.0/cmd/copy.go

Но так ли это хорошо?

Давайте запустим несколько тестов производительности, быстро создав два экземпляра Redis локально с помощью Docker и заполнив источник данными (всего 453 967 ключей, но мы копируем только часть из них, сопоставляя шаблон).

Затем мы запускаем каждый тест трижды, чтобы увидеть случайное отклонение:

10000 keys to copy: 17.79s 18.01s 17.98s 
367610 keys to copy: 8m57.98s 8m44.98s 8m58.07s

Это неплохо, но давайте посмотрим, сможем ли мы это улучшить.

Шаг 2. Используйте параллелизм

Представим себе последовательность операций в текущей реализации:

Что мы можем сделать здесь, чтобы улучшить производительность?

Мы четко видим следующие недостатки:

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

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

Есть один процесс, который нельзя распараллелить - сканирование базы данных. Он полагается на курсор сканирования, и нет возможности выполнять сканирование в несколько потоков.

Разобьем процесс на три этапа:

  • Сканирование,
  • Выгрузка данных и
  • Восстановление данных

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

Вот пример визуализации:

Суслики, бегущие по кругу, представляют собой циклы в горутинах, считывающие из канала, обрабатывающие данные и отправляющие обработанные данные в другой канал.

Реализация

Для простоты мы начнем реализовывать сканер и экспортер в одном пакете, начиная с объявления структур:

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

Структура KeyDump содержит всю необходимую информацию о простых значениях Redis: ключ, значение и TTL.

Первые горутины

Следующая функция организует горутины для сканирования и экспорта данных:

Как видите, он порождает одну процедуру сканирования и количество экспортируемых горутин, определенное параметром PullRoutineCount. Обратите внимание на переменную с именем wgPull типа WaitGroup, удобный инструмент, который гарантирует, что наш код не завершится до завершения процесса.

WaitGroup ждет завершения набора горутин. Основная горутина вызывает Add, чтобы установить количество ожидаемых горутин. Затем каждая из горутин запускается и по завершении вызывает Done. В то же время Wait можно использовать для блокировки выполнения до завершения всех горутин.

Группа ожидания инициализируется методом Add общим количеством горутин. Переменная предоставляется каждой горутине в качестве аргумента, и когда горутина завершает свою работу, она вызывает метод Done. Add увеличивает внутренний счетчик иDone уменьшает его. Wait метод блокирует выполнение, пока счетчик не достигнет нуля.

Структура сканера горутины аналогична той, что была у нас в первой версии:

Все говорит само за себя, но есть несколько вещей, о которых стоит упомянуть:

  • <- отправляет данные в канал
  • В самом конце мы закрываем канал, чтобы горутины знали, что больше данные отправляться не будут.

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

Чтение из канала осуществляется с помощью ключевого слова range, которое автоматически выходит из цикла for, когда канал (s.keyChannel) закрыт. wg.Done() в последней строке помогает гарантировать, что все ключи, переданные через s.keyChannel, были сброшены и отправлены через s.dumpChannel.

Как вы, возможно, знаете, поля структуры, начинающиеся со строчной буквы, считаются внутренними для пакета, поэтому мы должны предоставить геттер, чтобы другие пакеты могли читать поле dumpChannel. Это также возможность объявить тип возвращаемого значения как канал, предназначенный только для чтения (с использованием <-chan вместо простого типа chan):

func (s *RedisScanner) GetDumpChannel() <-chan KeyDump {
  return s.dumpChannel
}

Горутины для восстановления экспортированных данных

Pusher также может быть настроен и использует WaitGroup для оркестровки горутин:

И pushRoutine использует аналогичную практику для чтения из канала и выхода:

Здесь нужно отметить одну важную вещь: dumpChannel закрывается сканером только после выхода всех экспортеров. Это гарантирует, что никакие данные не будут потеряны в самом конце. Это достигается с помощью wgPull и двух строк в Start() приемнике RedisScanner:

wgPull.Wait()
close(s.dumpChannel)

Соединяем все вместе

Теперь давайте воспользуемся разработанными пакетами в команде Cobra, чтобы собрать все это воедино.

Во-первых, расширите определение команды, добавив дополнительные параметры:

Затем создайте сканер и толкатель (и WaitGroup для них). Не забудьте вызвать Wait(), иначе команда завершится немедленно:

Контрольный показатель

Самое интересное - увидеть разницу. Давайте посмотрим на одни и те же тестовые примеры и сравним их:

Тест №1

Исходная база данных: 453 967 ключей.
Ключи для копирования: 10 000 ключей.

Тест # 2

Исходная база данных: 453 967 ключей.
Ключи для копирования: 367 610 ключей.

При тестировании на локальном компьютере обработка в три-девять раз быстрее. Реальное выполнение в производственной инфраструктуре заняло менее двух минут, чтобы скопировать около миллиона ключей.

Заключение

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

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

Start copying
2021/02/14 13:11:42 Scanned: 29616 Exported: 29616 Pushed: 29616 after 1.000153648s
2021/02/14 13:11:43 Scanned: 59621 Exported: 59615 Pushed: 59615 after 2.000128223s
2021/02/14 13:11:44 Scanned: 89765 Exported: 89765 Pushed: 89765 after 3.0001194s
2021/02/14 13:11:44 Scanned: 100000 Exported: 100000 Pushed: 100000 after 3.347127281s
Finish copying

У вас есть примеры того, как Golang помог вам найти простое решение сложной проблемы?

Оставьте комментарий ниже, поделитесь своим опытом и мыслями!

Мы нанимаем старшего внутреннего инженера для создания, обслуживания и развития систем, которые приносят пользу нашим пользователям. Подайте заявку здесь!