Привет, меня зовут Георгий Ломакин, и я инженер по нагрузочному тестированию в компании Picodata — разработчике одноимённой NewSQL СУБД. В этой статье я поделюсь своим опытом нагрузочного тестирования и расскажу, как мы строили эту практику с нуля.

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

Цель создания инструмента для нагрузочного тестирования

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

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

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

  2. Наладить сбор, хранение и анализ результатов тестов производительности.

  3. Сделать процесс тестирования непрерывным и автоматизированным.

Выбор

Разработка собственного инструмента с нуля требует значительных ресурсов и времени. Естественно, на старте проекта перед нами стояла цель максимально сократить собственные затраты и не заниматься изобретением велосипеда. Требования к инструменту мы для себя сформулировали следующие:

  • Cluster-Aware Testing: балансировка нагрузки между узлами распределённой системы.

  • Handling coordinated omission: генерация и поддержка постоянной нагрузки, несмотря на задержки ответа системы. В противном случае при увеличении задержек утилита будет неявно реагировать на это снижением нагрузки. Поскольку невозможно одновременно держать активным бесконечное количество исходящих запросов, утилита будет ждать ответа от сервера перед отправкой следующего запроса. Нагрузка снижается естественным образом, результаты тестирования искажаются. Больше можно узнать в этой статье Scylla и из доклада Gil Tene.

  • Поддержка пользовательских сценариев тестирования: создание собственных сценариев тестирования, отражающих реальные ситуации использования системы.

  • Поддержка Fault Injection: вытекает из предыдущего пункта — возможность внедрять команды с преднамеренными ошибками в сценарий тестирования для наблюдения за реакцией системы.

  • Поддержка Error Threshold Abortion: автоматическое завершение тестирования, если количество ошибок превышает заданный порог.

  • Мониторинг, сбор и анализ метрик после тестирования: иметь или дать поддержку инструментов для анализа результатов.

  • Feedback loop: способность динамически адаптировать нагрузку в реальном времени на основе собранных метрик.

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

  • К первому классу относятся утилиты с конфигурируемыми запросами — wrk2, stroppy, yandex.tank и другие.

  • Ко второму классу относятся инструменты, реализующие стандарты Transaction Processing Performance Council — TPC-A/B/C — либо индустриальные NoSQL-стандарты, такие как YCSB и другие.

Из позитивного: оказалось, что стандарты из второго пункта можно реализовать через утилиты первого.

Стандарты нагрузочного тестирования

В мире нагрузочного тестирования важно не только измерять производительность систем, но и делать это с соблюдением общепринятых стандартов. Такие стандарты позволяют объективно сравнивать различные системы, предоставляя унифицированные метрики и сценарии для оценки. Благодаря этому, разработчики могут принимать обоснованные решения о выборе архитектуры или инструмента. Один из ключевых игроков в этой области — организация TPC (Transaction Processing Performance Council), которая разработала множество бенчмарков для оценки производительности систем управления базами данных (СУБД).

Существуют различные стандарты, такие как TPC-A, TPC-B и TPC-C, которые помогают измерить, насколько эффективно система справляется с обработкой транзакций — последовательностей операций, которые должны быть выполнены полностью или не выполнены вовсе. TPC-C особенно актуален, потому что моделирует реальную работу крупного предприятия, например оптового склада или большого магазина. Представьте, что множество клиентов одновременно делают и оплачивают заказы, а сотрудники их обрабатывают и управляют запасами. Каждая из этих операций — это транзакция, и TPC-C проверяет, насколько быстро и надёжно система может обработать все эти транзакции одновременно.

В случае распределённой базы данных данные хранятся на нескольких серверах, или узлах. Для выполнения теста TPC-C в такой системе необходимы распределённые транзакции, которые обеспечивают согласованность данных между всеми узлами: транзакция должна либо успешно завершиться на всех узлах, либо полностью откатиться. Без поддержки распределённых транзакций невозможно использовать TPC-C тесты. Не получится использовать и разработанную в компании stroppy — утилиту на языке Go, предназначенную для генерации транзакционной нагрузки.

Следующий широко используемый стандарт тестирования YCSB — бенчмарк, разработанный компанией Yahoo! для оценки производительности NoSQL баз данных. Он фокусируется на основных операциях, таких как SELECT/INSERT/UPDATE, имитируя рабочие нагрузки, типичные для веб-приложений. YCSB предоставляет набор предопределённых конфигураций нагрузок: workload-a/b/c/d/e/f, которые отличаются соотношением пишущих и читающих команд. Поскольку YCSB создавался для NoSQL-систем, он направлен на оценку производительности без строгой необходимости соблюдать требования ACID, которые чаще присущи реляционным базам данных. В случае тестирования кластерного SQL YCSB-тесты не совсем подходят для его всестороннего тестирования. Стандарт не предусматривает поддержку сложных SQL-запросов, таких как операции JOIN, подзапросы и агрегатные функции. Поскольку YCSB делает упор на простые операции «ключ — значение», он не совсем точно отражает реальные рабочие нагрузки и сложности кластерной среды SQL.

Итак, мы рассмотрели разные стандарты нагрузочного тестирования, которые широко применяются в реальном мире. Выходит, что одни тесты фокусируются на сценарии работы универсальной СУБД, такой как PostgreSQL или Oracle, другие — на примитивных NoSQL-системах. И так как Picodata уже давно переросла класс NoSQL key/value store, но всё еще не реализует весь функционал универсальной СУБД, мы перешли к рассмотрению второго варианта — инструмента, который бы полностью взял на себя исполнение теста, но позволил бы нам использовать собственные команды для создания нагрузки.

Утилиты для нагрузочного тестирования

Для проведения нагрузочного тестирования баз данных существует множество утилит, каждая из которых ориентирована на определённые сценарии и типы СУБД. Эти инструменты позволяют моделировать реальные рабочие нагрузки, анализировать производительность и тестировать масштабируемость. Одно из таких решений — pgbench, популярная утилита нагрузочного тестирования для PostgreSQL. Благодаря гибкости настроек и поддержке пользовательских сценариев она стала стандартом для тестирования систем, совместимых с протоколом PostgreSQL. Поскольку PostgreSQL занимает одно из ведущих мест на рынке СУБД и широко используется в самых разных отраслях, рассмотрим этот инструмент.

Pgbench выполняет одну и ту же последовательность SQL-команд снова и снова, возможно, в нескольких параллельных сессиях базы данных, а затем вычисляет среднюю скорость транзакций (транзакций в секунду). По умолчанию pgbench тестирует сценарий, который в общих чертах основан на TPC-B, однако с помощью параметра -f пользователь может передать текстовый файл со своими сценариями, что позволит pgbench выполнять пользовательские запросы без использования оборачивания в транзакции в режиме simple.

C релизом PostgreSQL16 в libpq-psql и в pgbench появилась возможность включать балансировку нагрузки в процессе нагрузочного тестирования между инстансами базы данных с помощью опции load_balance_hosts. Нововведение отлично сочетается с Picodata, выполняя требование к cluster-aware тестированию.

Но когда мы начинали, Picodata поддерживала только протоколы IPROTO и HTTP, из-за чего использование pgbench было невозможно. Сейчас, с полноценной реализацией клиентского протокола PostgreSQL — pgproto, мы активно начали пользоваться для тестирования в том числе pgbench.

Утилита wrk2 — прямой наследник wrk — получила поддержку Coordinated Omission, гарантируя постоянную пропускную способность и поддержку сценариев на языке Lua. Утилита использует протокол HTTP. Чтобы использовать wrk2 напрямую для тестирования СУБД-протоколов, нам бы пришлось реализовывать REST-коннектор для вызова необходимых хранимых процедур внутри Picodata, что вносило бы дополнительные искажения в результаты.

«Яндекс Танк» удовлетворял почти всем требованиям для нагрузочного тестирования, выдвинутых в начале поиска, включая гибкость сценариев и поддержку распределения нагрузки. Однако разработка модулей-«патронов» влечёт за собой большое количество boilerplate-кода, который никак не относится к нагрузочному тестированию и специфичен для утилиты. Это делает «Яндекс Танк» чрезмерно ресурсоёмким в долгосрочной перспективе, особенно для команд, желающих быстро адаптировать тесты под новые требования. На эту тему есть исчерпывающая статья на Хабре.

Обзор остальных инструментов представлю в виде сводной таблицы 1. К сожалению, ни один не удовлетворил наши потребности полностью. 

pgbench

go-ysbc

wrk2

Yandex

Tank

k6

Protocol

pg wire protocol 

pg wire protocol 

HTTP

Any (golang connector)

Any (golang connector)

Cluster-Aware

+

-

-

+

+

Handling Coordinated Omission

+

-

+

+

+

Custom scenarios

+

-

+

+

+

Fault injection

+

-

+

+

+

Feedback loop

-

+

+

+

+

Таблица 1. Сравнение бенчмарк-утилит по пяти критериям, необходимым для тестирования распределённых систем

Итоговый выбор

Вот бы был инструмент, который совмещает все достоинства рассмотренных инструментов тестирования. Вы просите инструмент? Их есть у меня! Встречайте — k6!

На первый взгляд, выбор k6 для тестирования производительности распределённых SQL-систем может показаться нестандартным, ведь k6 в первую очередь ассоциируется с тестированием веб-приложений по HTTP. Однако гибкость конфигурации стадий тестирований k6 и расширяемость через модули xk6 делают его пригодным для гораздо более широкого спектра задач, включая нашу.

Ядро k6 предоставляет необходимые возможности из коробки: скриптинг, генерацию нагрузки и её поддержку на всей продолжительности теста, сбор метрик и модульность. Для получения утилиты мечты нам не пришлось реализовывать всё с нуля — достаточно было разработать xk6-модуль, который не отличается от обычной программы на языке Go и с минимальными затратами подключается к основной логике k6. В придачу мы получаем все преимущества экосистемы Go: go test, gofmt и другие. Вишенка на торте — факт, что утилита со всеми модулями компилируется в единый бинарный исполняемый файл.

В итоге k6 идеально подходит для нашей задачи по нескольким причинам:

  1. Расширяемость через xk6.

k6 предоставляет возможность расширения функционала, что позволило нам разработать собственный xk6-модуль для взаимодействия с Picodata через её нативный (iproto) и PostgreSQL совместимый (pgproto) протокол. Такая система модулей означает наличие гибкости в масштабировании функционала и лёгкое добавление новой логики в модуль.

  1. Поддержка пользовательских сценариев.

В k6 можно описывать сложные сценарии тестирования на языке JavaScript, что даёт полную свободу в моделировании рабочих нагрузок. В нашем xk6-модуле мы дали возможность настраивать сценарии нагрузки и писать свои SQL-запросы в yml конфиге, что позволяет воспроизводить как простые запросы, так и сложные SQL-операции, включая JOIN, агрегаты и выборки по индексам, в отличие от YCSB, где сценарии ограничены набором простых CRUD-операций.

  1. Решение проблемы Coordinated Omission.

Одна из ключевых особенностей k6 — это встроенная поддержка методов тестирования, гарантирующих constant throughput load. Это позволяет производить точные измерения метрик в условиях реальной нагрузки, что критически важно для оценки производительности. Достигается это за счёт возможностей языка Go (горутин) и использования специальной стратегии нагрузки — constant-arrival-rate. Этот сценарий позволяет контролировать количество запросов, отправляемых в единицу времени, независимо от скорости ответа тестируемой системы.

  1. Лёгкость интеграции с CI/CD.

Благодаря тому, что утилита компилируется в единый исполняемый файл, её и пользовательские сценарии можно легко интегрировать в процесс автоматической сборки и развёртывания, что позволяет проводить тестирование производительности на постоянной основе и обнаруживать узкие места для каждого коммита и полноценного релиза.

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

Архитектура Picostress

Picostress — это утилита на Go, которая состоит из модуля для xk6, самого k6 и Cobra-обёртки. Нагрузочная утилита использует скрипты JavaScript для создания настраиваемых и повторяемых нагрузочных тестов с последующей генерацией отчётов c метриками.

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

Требования к нагрузочным тестам

К нагрузочным тестам были предъявлены следующие требования:

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

  2. Повторяемость. Тест обязан генерировать одинаковые значения или данные для параметров запросов, используя псевдослучайную генерацию с возможностью инициализации параметром (целым числом), при каждом запуске программы, обеспечивая возможность правдивого сравнения производительности в различных сценариях и версиях Picodata.

  3. Возможность задавать распределение данных по столбцам в соответствии с заданными статистическими законами, например равномерное, нормальное, распределение и ципфа.

Архитектура модуля xk6 на Go

xk6-модуль Picostress состоит из шести основных частей, написанных на языке Go. Основной компонент, называемый instance, служит входной точкой в систему xk6 и предоставляет публичное API, через который JavaScript-скрипты могут вызывать необходимые функции, определённые в этом пакете.

Подключение xk6-модуля состоит всего из нескольких строк:

package picostress
import (
  "picostress/instance"	
  "go.k6.io/k6/js/modules"
)
var _ modules.Module = new(RootModule)
func init() {	
  modules.Register("k6/x/picostress", new(RootModule))
}
type RootModule struct{}func (r *RootModule) NewModuleInstance(vu modules.VU) modules.Instance {	
  return instance.New(vu, vu.Runtime().NewObject())
}

Go-пакеты

Пакеты сonfig и validator предоставляют логику для парсинга и валидации конфигурационного файла в формате YAML, который содержит схемы таблиц, индексов, а также тела SQL-запросов с описанными параметрами для них (пример конфига будет показан позже). На вход принимает два файла — YAML-файл конфигурации и JSON-схему. Сначала файл конфигурации проверяется на соответствие схеме, затем парсится в Go-шные структуры и валидируется по некоторому набору правил на наличие логических ошибок. Ознакомимся с некоторыми из них:

  • соответствие количества значений в заданном диапазоне и требуемого количества строк в таблице;

  • валидность задания диапазона (левая граница <= правой границе);

  • переполнения суммы значений диапазона для работы с нормальным распределением;

  • установление флага уникальности для колонки первичного ключа.

Благодаря этому модулю Picostress гарантирует, что все поля будут заданы корректно перед началом нагрузочного тестирования.

Пакет proto содержит реализации драйверов для подключения к базе данных по разным протоколам. На данный момент Picostress поддерживает IPROTO и PG Wire protocol. Для добавления в нашу утилиту других протоколов в будущем достаточно будет взять популярную библиотеку и реализовать Connection Pool, удовлетворяющий общему интерфейсу. Такой подход имеет очевидные плюсы, среди которых возможность гибко адаптироваться к различным условиям тестирования.

Пакет distribution предоставляет реализации псевдослучайных генераторов по некоторым законам распределения: нормального, равномерного и ципфа. Используя целое беззнаковое число для инициализации (SEED), генератор будет возвращать одинаковую последовательность значений. Приятная новость: стандартная библиотека языка Go math/rand/v2 уже содержит необходимую логику псевдослучайных генераторов. Типы данных в пакете types используют генератор распределения и возвращают детерминированную последовательность значений в заданном диапазоне — по одному значению при каждом вызове соответствующего метода.

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

Рассмотрим пакет query, класс Queue. Для достижения детерминированности тестирования Queue строит очередь из запросов и перемешивает её, используя принцип seeded shuffling. При одинаковом SEED порядок запросов будет оставаться одним и тем же от запуска к запуску теста. Перемешивание очереди — важный шаг в процессе нагрузочного тестирования, поскольку оно помогает имитировать реальные условия эксплуатации продукта.

Добавление команды для процессинга метрик

Мы разработали xk6-модуль с необходимой логикой для подключения к базе, генерации и заполнения таблиц данными и подключили модуль к основной логике k6. Всё готово к нагрузочному тестированию. Однако без обработки результатов серии тестов сложно анализировать и строить гипотезы об улучшении или регрессе производительности нашей базы данных.

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

Для расчёта доверительного интервала используют разные методы. Когда мы проводим серию тестов, то получаем ограниченный набор значений. При этом стандартное нормальное распределение, которое используется для расчётов в случае больших выборок (свыше 30 наблюдений), не всегда адекватно для малых выборок. Распределение Стьюдента учитывает это, корректируя интервалы с учётом бо́льшей неопределённости при малом размере выборки. В результате можно оценить с требуемой погрешностью диапазон значений, в котором лежит истинное математическое ожидание значения метрики.

Основная идея проста: мы берём среднее значение результатов серии тестов и, используя специальный коэффициент (t-коэффициент), определяем диапазон, в который с высокой вероятностью попадает истинное среднее значение. Этот коэффициент зависит от размера выборки и процентиля (обычно 95% или 99%).

k6, хоть и позволяет реализовывать свои модули, не имеет механизма встраивания собственных команд в бинарник, а удобная реализация расчёта доверительных интервалов требовала именно этого — команды intervals. Итак, ещё один способ добавить собственную логику в приложение — использовать прокси, обернув бинарный файл k6 в приложение на базе Cobra. Встраивание файлов в Go-приложение с помощью директивы go:embed позволит включить бинарь k6 непосредственно в исполняемый файл Picostress:

import _ "embed" ...
import _ "embed"

//go:embed k6
var embeddedFiles embed.FS
func RunEmbFile(binaryName string) error {	
  // Read emb file from embedded FS	
  data, err := embeddedFiles.ReadFile(binaryName)	
  if err != nil {		
  return fmt.Errorf("Failed to read embedded binary: %v\n", err)	
  }
  // Create temp file for embedded file
  tempFile, err := os.CreateTemp("", "embedded_binary-*")	
  if err != nil {	
    return fmt.Errorf("Failed to create temporary file: %v\n", err)	
  }	
  // ATTENTION: Remove temp file after programm execution	
  defer os.Remove(tempFile.Name())	
  // Copy emb file data into temp file
  if _, err := tempFile.Write(data); err != nil {	
    return fmt.Errorf("Failed to write binary to temp file: %v\n", err)	
  }	
  if err := tempFile.Close(); err != nil {	
    return fmt.Errorf("Failed to close temp file: %v\n", err)
  }	
  // Change permissions to execute file	
  if err := os.Chmod(tempFile.Name(), 0755); err != nil {	
    return fmt.Errorf("Failed to make temp file executable: %v\n", err)	
  }	
  // Run extracted binary file	
  cmd := exec.Command(tempFile.Name())	
  cmd.Stdout = os.Stdout	
  cmd.Stderr = os.Stderr	
  if err := cmd.Run(); err != nil {	
    return fmt.Errorf("Error executing embedded binary: %v\n", err)	
  }
  return nil
}

Мы рассмотрели минимальный пример использования директивы go:embed для встраивания одного бинарного файла в Go-приложение. Функционал пакета embed довольно обширный, предлагаю ознакомиться с ним самостоятельно. Могу лишь сказать, что глобальная переменная embeddedFiles типа embed.FS представляет собой виртуальную файловую систему внутри приложения. Тем самым мы можем встраивать не только единичные файлы, но и папки с файлами, причём они могут быть различной вложенности. Также мы можем читать файлы из этого виртуального файлового пространства.

Главный недостаток такого подхода — необходимость копировать встроенный файл во временный файл для дальнейшего выполнения, используя пакет os/exec. Следовательно, очень важно правильно освобождать ресурс, то есть удалять временные папки и файлы, которые были созданы во время использования приложения.

Возвращаясь к теме: когда пользователь вводит команду, Picostress перехватывает её и сравнивает на соответствие: если это пользовательская команда Cobra-приложения, выполнение продолжается в среде Cobra. Иначе все соответствующие параметры передаются в предварительно разархивированный k6. Такой подход позволяет интегрировать дополнительные уровни логики, не выходя из единой экосистемы приложения.

Разрабатывая Picostress, мы пошли дальше и реализовали полноценную Cobra-обёртку над k6, которая соединяет все части приложения. Она выполняет следующие функции:

  • встраивание файла k6, файлов js-сценариев и файла json схемы конфига в приложение;

  • безопасную работу по извлечению и удалению временных файлов;

  • команды для создания и удаления тестовых таблиц, заполнения их данными;

  • команды запуска сценариев k6;

  • обработку флагов и приведение их к формату, требуемого k6;

  • удобную команду help для каждой реализованной команды.

Про передачу флагов в k6 мы поговорим позже в статье. Одно из заметных преимуществ, которые мы отметили, — это уменьшение зависимостей при использовании утилиты: пользователю достаточно указать путь до конфигурационного файла и перечислить соответствующие флаги, а приложение само организует запуск k6.

Таким образом, мы добавили и команду intervals, которая принимает на вход процентиль, набор из N отчётов с метриками и на их основе рассчитывает доверительные интервалы RPS, время ответа базы данных и количество обработанных запросов за всё время нагрузочного теста.

Сценарии бенчмаркинга и методология тестирования

Конфигурационный файл

Ниже приведён пример конфигурационного файла в формате YAML:

config_name: CONST_QUEUE ...
config_name: QUEUE

tables_schema:
  - name: cars_q
    row_count: 100
    columns:
      - name: id
        type: Integer
        distribution: Normal
        range: [0, 100]
        is_nullable: false
        null_percentage: 0
        unique: true
      - name: brand
        type: String
        distribution: Zipf
        distribution_param: 1.1
        range: [1, 15]
        alphabet: ["en"]
        is_nullable: false
        null_percentage: 0
        unique: false
      - name: received
        type: datetime
        distribution: Uniform
        range: ['1999-10-26T09:00:00Z', '2015-10-21T07:28:00Z']
        is_nullable: false
        null_percentage: 0
        unique: true
      - name: stock
        type: Boolean
        distribution: Normal
        is_nullable: false
        null_percentage: 0
    primary_keys: [id, received]
    engine: memtx
    distribution_keys: [id, received]
    tier: default
    timeout: 3.0

indexes:
  - name: cars_brand_index
    unique: false
    table_name: cars_q
    columns_names: [brand]
    type: tree
    options: 
      - name: hint
        value: true
    timeout: 3.0

queries:
  - sql: select * from cars_q where id = $1::integer and brand = $2::string and stock = $3::boolean
    params: 
      - type: Integer
        distribution: Normal
        range: [10, 21]
        is_nullable: false
        null_percentage: 0
        unique: false
      - type: String
        distribution: Normal
        range: [1, 15]
        alphabet: ["en"]
        is_nullable: false
        null_percentage: 0
        unique: false
      - type: Constant
        value: true
    count: 100

Конфигурационный файл разбит на четыре части:

  • название конфига;

  • описание схемы таблиц;

  • описание схемы индексов;

  • перечисление SQL-запросов и количества каждого в очереди.

Если создание индексов не требуется, секцию indexes не требуется описывать. Picostress обладает полной документацией по запуску и написанию своего конфига, с описанием поддерживаемых типов и примерами, так что каждый пользователь сможет написать свой. Похожий путь конфигурирования прослеживается у популярных утилит cassandra-stress и sysbench, которые мы здесь не рассматривали в силу их сильной ориентированности на конкретного вендора СУБД.

Сценарный файл

Сценарный файл k6 состоит из 5 частей:

  • init контекст:

    • глобальные переменные;

    • объекты метрик;

    • опции сценария;

  • функция setup;

  • основная функция(и) тестирования (default function);

  • функция teardown;

  • функция handleSummary.

В репозитории Picostress уже есть две команды, реализующие два сценария, готовых для использования из коробки. Чтобы разобраться в жизненном цикле нагрузочного теста, разберём на примере одного из сценариев — const_queue.js.

В самом начале файла мы подключаем xk6-модуль, чтобы вызывать его функции внутри сценарного файла. Далее объявляются глобальные переменные — аргументы командной строки, которые передаются из строки запуска. Если обязательный флаг не был передан, выполнение скрипта прервётся с выводом ошибки.

import picostress from "k6/x/picostress";
import { Trend, Counter } from 'k6/metrics';
// Cli arguments
// Protocol name
const PROTOCOL = __ENV.proto === undefined ? "" : __ENV.proto
if (PROTOCOL.length === 0) { 
throw new Error("Please define protocol (-e proto=protocol_name)")
}
...

После мы инициализируем объект инстанса нашего xk6-модуля. Через него будут вызываться все реализованные команды:

const test = picostress.new();

Инициализируем объекты метрик, в которые будут аккумулировать данные и рассчитывать необходимые значения:

const setupTimeCounter = new Counter("setup_time")
const teardownTimeCounter = new Counter("teardown_time")
const stopTestCounter = new Counter('stop_test_counter');
const respTimeTrend = new Trend("resp_time");
const requestCounter = new Counter("total_requests");
const errorCounter = new Counter("total_errors");

Объект options очень важен: с помощью него обозначается, какой режим из представленных в k6 будет использоваться, а также ограничения для метрик, по достижению которых тест будет завершён с ошибкой:

export const options = {
  setupTimeout: "86400s",
  scenarios: { 
    const_queue: {  
      // K6 executor used in this mode 
      executor: "per-vu-iterations",   
      // Number of vus, that will be allocated and used   
      vus: PREALLOC_VU_COUNT, 
      // Number of iterations per vu   
      iterations: 1,  
      // How long the test lasts   
      maxDuration: "7200s"    
    }  
  }, 
  thresholds: {  
    total_errors: [   
      {      
        threshold: `count < ` + String(((ERROR_PERCENTAGE_TRESHOLD * 
test.config.queue_size) / 100)),   
        abortOnFail: true     
      }   
    ]  
  }
}
;

Переходим к функции setup. Она служит для предварительной инициализации данных и подготовки базы данных к тестированию. Рассмотрим наиболее важные части функции. Сначала инициализируется конфиг, полученный из YML-файла. Если инициализация завершилась с ошибкой, то тест завершится без последствий. То же самое происходит с инициализацией пула подключений к базе данных: 

export const setup = () => { 
  const seed = SEED //для копирования на стек функции setup 
  const startTime = Date.now(); 
  
  let err = test.initConfig(seed, CONFIG_FILE_PATH, SCHEMA_FILE_PATH) 
  if (err !== null) { 
    throw new Error(err) 
  }
...

Через аргумент командной строки мы можем контролировать выполнение стадий жизненного цикла теста — функций setup и teardown. Как видно, если в флаг no_setup передано значение true, этап создания таблиц будет пропущен. Это удобно, когда нужно разово заполнить базу большим количеством строк и далее проводить нагрузочные тесты. А через объект test вызываются те самые функции из Go-шного xk6-модуля:

if (no_setup === false) { 
let err = test.createTables() 
  if (err !== null) {
    console.error(err)  
    console.error("Skipping test...")  
    stopTestCounter.add(1); 
    return { skipTest: true, summaryDirPath: "", queue: [], seed: seed, version: 
PICODATA_VERSION }    
  }
...

Ошибкой будет вызов и исполнение "долгих" функций на стадии setup , так эта стадия имеет свой timeout. По истечению времени, k6 прервёт выполнение функции setup, что может привести к неопределенному результату. Как ранее упоминалось, в Picostress мы вынесли логику создания и заполнения таблиц данными в отдельную команду, которая выполняется вне среды k6.

Осталось сгенерировать очередь SQL-запросов. На каждого виртуального пользователя выделяется своя часть очереди: 

const queue = test.generateQueue(seed)
  const getPart = …
  const parts = Array.from({ length: PREALLOC_VU_COUNT }, (_, i) => getPart(queue, i, PREALLOC_VU_COUNT));
  setupTimeCounter.add(Date.now() — startTime)

Сам объект очереди — важная часть, так как она содержит запросы, которые будут использованы для нагрузочного тестирования. Количество запросов должно отражать ту логику, которая возлагает на конкретный сценарий тестирования.

В итоге возвращаем результат из функции:

  return { skipTest: false, summaryDirPath: summaryDirPath, queue: parts, seed: seed, version: PICODATA_VERSION }
};

Функция default — это логика самого нагрузочного теста. Имя функции можно задать в объекте options, в ином случае будет использоваться функция с ключевым словом default. Логика функции проста — каждый виртуальный пользователь проходит по своему участку очереди и отправляет его с последующим сбором метрик:

export default function(data) { 
  if (data.skipTest === true) { 
    return 
  } 
  for (let queryIndex = 0; queryIndex < data.queue[__VU — 1].length; ++queryIndex) { 
    const startTime = Date.now()  
    const err = test.call(data.queue[__VU — 1]
[queryIndex].Sql, data.queue[__VU — 1][queryIndex].Params)  
    const respTime = Date.now() — startTime;  
    requestCounter.add(1)   
    if (err) {   
      errorCounter.add(1) 
      console.error(err) 
    } else {   
      respTimeTrend.add(respTime)  
    }  
  }
}

Функция teardown делает обратные действия функции setup — удаляет созданные таблицы, индексы и закрывает подключения к базе данных:

export function teardown(data) {
  const st = Date.now()
  if (no_teardown === false) {
    let timeout = 3.0
    let err = test.dropIndexes(timeout)
    if (err !== null) {
      console.error(err)
    }
    err = test.dropTables(timeout)
    if (err !== null) {
      console.error(err)
    }
    test.closeConnPool()
  }
  teardownTimeCounter.add(Date.now() — st)
}

Последняя функция handleSummary выполняется после завершения всех прошлых стадий теста. Её возвращаемое значение — объект — переопределяет стандартный вывод в stdout, который предустановлен в k6, что позволяет выводить информацию не только в терминал, но и в файл. Единственный аргумент функции — объект summary_data, который хранит данные, возвращаемые из функции setup, а также данные объектов метрик, которые были инициализированы как глобальные переменные и использованы на протяжении всего жизненного цикла:

export function handleSummary(summaryData) { 
  const summary = {  
    picodataVersion: summaryData.setup_data.version,  
    configName: test["config"]["name"],

    ...  
    seed: summaryData.setup_data.seed,
    ...  
    rps: {   
      actual: Number((summaryData.metrics.total_requests.values.count / testDuration).toFixed(5)),
      ...
return { 
  stdout: JSON.stringify(summary, null, 2) 
   .replace(/"/g, "")  
   .replace(/[{}]/g, "") 
   .replace(/,/g, "")   
   .replace(/(\n\s*\n)+/g, "\n"),

     [`${summaryData.setup_data.summaryDirPath}/summary_${summaryData.setup_d  ata.seed}.json`]: JSON.stringify(summary, null, 2)  
  }
}

Мы рассмотрели только один из режимов — const_queue, реализованный в команде queue, так как он наиболее сложный из стандартных в Picostress. Команда подходит для тестирования SQL запросов, выстраивая очередь, с последующей отправкой в базу данных. Второй сценарий const_rps построен аналогичным способом и позволяет проводить нагрузочные тесты с заданным RPS. Вы можете ознакомиться с этим режимом самостоятельно в репозитории проекта.

Глобальные переменные в k6-JS runtime

Теперь поговорим о тонкостях k6. Каждая стадия жизненного цикла теста — setup, default и teardown — имеет собственный JavaScript runtime. До недавнего времени это был goja, теперь — sobek, который является форком goja. Это значит, что k6 пересоздаёт среду выполнения для каждой стадии отдельно, и глобальные переменные пересчитываются заново для каждой из них. Каждая стадия получает свою независимую копию переменных. Хранить пул подключений к базе данных как глобальную переменную — плохая практика. Для этого можно использовать Golang-память, определять глобальные переменные внутри xk6-модуля и хранить в объекте Instance:

var config   *cfg.Config      = new(cfg.Config)

var connPool *proto.ConnPool  = new(proto.ConnPool)

type Instance struct {	
  vu       modules.VU	
  exports  *goja.Object
  Config   *cfg.Config	
  ConnPool *proto.ConnPool
}

А в функции New просто возвращать указатель на этот объект:

func (i *Instance) New() *Instance {
   return i
}

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

«Ленивая» инициализация объектов метрик

Объекты метрик в k6 объявляются в init контексте скрипта как глобальные переменные. Тонкость состоит в том, что инициализация этих объектов происходит только при первом обращении к нему. «Ленивая» инициализация имеет очевидные плюсы, но и неочевидные минусы.

Объект метрики после создания имеет значение undefined. Попытка прочитать значения метрики до её первого использования (добавления первого значения) завершится ошибкой. Для избежания этого можно сделать следующее:

  1. Укажите threshold для метрики.

Указание thresholds в опциях сценария автоматически инициализируют метрику. Например:

let myMetric = new Counter("metric");
export let options = {
  thresholds: { 
    "metric": ["count<500"]  
 }
};
  1. Добавьте нулевое значение для инициализации.

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

let myMetric = new Counter("metric");
export const setup = () => {
  myMetric.add(0); 
  ...
}

Передача данных между стадиями теста

k6 позволяет передавать данные между стадиями, например, из setup в default, teardown и handleSummary. Однако передавать можно только базовые типы данных, такие как числа, строки и массивы примитивных типов. Это означает, что нельзя передавать объекты классов с полями или сложные структуры данных — такие объекты не будут корректно сериализованы, что ограничивает возможности передачи сложных состояний между этапами теста. Подробнее можно узнать на сайте документации k6.

Переменные окружения k6

k6 предоставляет ряд встроенных переменных, которые позволяют управлять поведением тестов. Среди них особенно выделяется __ENV, объект типа «ключ — значение», через который можно передавать параметры и конфигурационные данные в JavaScript-скрипты k6 во время выполнения тестов. Из коробки мы получаем встроенный механизм передачи параметров через консоль, а что самое важное — порядок передачи параметров не фиксирован, так как любое значение можно достать по ключу.

В Picostress объект __ENV используется для передачи ключевых параметров тестирования, таких как название протокола, адреса инстансов кластера и другие.

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

k6 run -e SEED=12345 -e PROTO=pgwire
k6 run -e PROTO=pgwire -e SEED=12345

В обоих случаях порядок передачи переменных окружения не имеет значения, что позволяет пользователю не думать о порядке параметров. Как вы уже знаете, в Picostress мы используем комбинированный подход, обрабатывая флаги внутри cobra-приложения, чтобы убрать необходимость в приписке -e у каждого флага:

numFlags := cmd.Flags().NFlag()

// Make a slice for flags

k6Args := make([]string, 0, numFlags)

...
// Append cobra flags in k6 env-style format
cmd.Flags().Visit(func(flag *pflag.Flag) {
  k6Args = append(k6Args, "-e"+flag.Name+"="+flag.Value.String())
})

Запуск тестирования

Перед запуском теста, создадим тестовые таблицы и наполним их данным:

./picostress schema up -c=./scripts/configs/queue.yml -u=stress -p=T0psecret --proto=iproto -a=localhost:3301,localhost:3302

Конечная строка запуска теста выглядит следующим образом:

./picostress queue -c=./scripts/configs/queue.yml -u=stress -p=T0psecret --proto=iproto -a=localhost:3301,localhost:3302 --vu=2

Описание каждой команды и используемых ею флагов можно получить, выполнив команду

picostress help [command]

Результаты бенчмарка для Picodata

Мы описали процесс создания утилиты нагрузочного тестирования и то, как именно мы «готовим» k6. В этой статье идёт фокус на используемых инструментах и методах тестирования, а не на исследовании производительности Picodata. Для наглядности давайте посмотрим, какие результаты показала Picodata при разных рабочих нагрузках.

Характеристики тестового стенда

Для проведения бенчмарка была развёрнута тестовая среда со следующими характеристиками:

  • Аппаратное обеспечение:

    • Количество виртуальных машин: 6

    • Процессор: AMD EPYC 7452 32-Core: x86, 10 ядер на каждую виртуальную машину

    • Оперативная память: 228 GiB на каждую виртуальную машину

  • Конфигурация кластера:

    • Количество экземпляров Picodata: 42

    • Репликация данных: отключена (без репликации)

    • Объём данных: 600 млн записей

Статистика запроса

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

  • 13 CTE (Common Table Expressions). Это 13 подзапросов, порождающие 13 временных таблиц, которые существуют только в контексте выполнения запроса и применяются для упрощения логики и повторного использования промежуточных результатов.

  • 335 строк текста. Это длина SQL-запроса.

  • 835 узлов логического плана. Это количество шагов, или операций, которые база данных выполняет для обработки запроса. Логический план описывает порядок выполнения операций, например выборка, фильтрация, сортировка, и определяет, как именно данные будут извлечены и обработаны.

Результаты нагрузочного тестирования

В k6 VU (Virtual User) — это виртуальный пользователь, который имитирует поведение реального пользователя, отправляя запросы и взаимодействуя с системой в процессе тестирования производительности. Каждый VU работает параллельно и отправляет запросы независимо от других. Для нагрузочного тестирования был выбран режим constant arrival rate: используя тестовый запрос, утилита поддерживает целевую нагрузку. В ходе тестирования были получены следующие результаты:

  • Количество VU: 400

  • Среднее количество запросов в секунду (RPS): 160

  • Время отклика:

    • Минимальное: 0,253 секунды

    • Максимальное: 3,397 секунды

    • Среднее: 2,470 секунды

Чтобы как-то интерпретировать результаты, нужно чуть глубже погрузиться в проблематику аналитических СУБД и текущий ландшафт продуктов на российском рынке. Всем нам известны такие колоночные хранилища данных, как Vertica, Teradata, Greenplum, ClickHouse. История с использованием западных аналитических СУБД в России напоминает роман «10 негритят»: Vertica, Teradata и им подобные покинули российский рынок. Открытые решения, такие как ClickHouse или Greenplum, не лучшим образом справляются с большим количеством соединений или высокой конкурентной нагрузкой на чтения. Да и сам колоночный формат хранения хорош, когда запрос сканирует большие объёмы данных, что в нашем сценарии не так — несмотря на большое количество соединений, данных, с которыми запрос взаимодействует, и которые, кстати, в Picodata хранятся построчно, как и в универсальных СУБД, не более 100 000 — 300 000 строк на запрос. Как мы видим из результатов, в уже насыщенном ландшафте аналитических решений есть ниша и для резидентных СУБД.

Дальнейшие планы по развитию утилиты

  1. Поддержать новые режимы тестирования:

    1. Spike Test: система подвергается резкому увеличению нагрузки за короткое время, а затем внезапному снижению. Цель такого теста — проверить устойчивость и поведение системы при резких всплесках нагрузки, выявляя возможные точки отказа. Для реализации spike test в k6 используется сценарий ramping arrival rate.

  2. Добавить новые режимы балансировки нагрузки:

    1. Random: запросы распределяются между инстансами системы в случайном порядке.

    2. Weighted Round Robin: вариант Round Robin, где каждому серверу присваивается «вес», указывающий на его производительность или приоритет. Серверы с бо́льшим весом получают больше запросов.

    3. Consistent Hashing: метод распределения запросов, который минимизирует перераспределение нагрузки при добавлении или удалении серверов, размещая их и запросы на кольце хеширования для равномерного распределения данных.

    4. Geolocation-Based: распределяет запросы на серверы, ближайшие к местоположению клиента, чтобы минимизировать задержки и улучшить производительность.

  3. Реализовать команду compare для сравнения двух отчётов с доверительными интервалами для демонстрации прогресса/регресса производительности.

  4. Интеграция с Influxdb и построение grafana-дашбордов, в том числе логирование нагрузки на ЦПУ и т. п.

  5. Добавить генерацию HTML-отчётов с результатами (показания ЦПУ, памяти, метрик и т. п.) после выполнения одного теста или серии тестов.

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

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

Заключение

Создание инструмента нагрузочного тестирования Picostress стало ключевым элементом в оценке и повышении производительности нашей распределенной NewSQL базы данных. Столкнувшись с ограничениями существующих стандартов и утилит, таких как TPC-C, YCSB, pgbench и Yandex Tank, мы выбрали k6 в качестве основы и разработали собственный xk6-модуль на Go. Это решение удовлетворило специфические требования к нагрузочному тестированию кластерного SQL, обеспечило детерминированность и повторяемость тестов, а также позволило интегрировать инструмент в процессы автоматизированного тестирования и непрерывной интеграции.

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

Комментарии (3)


  1. z4hrada
    12.12.2024 07:56

    очень красиво отрисована Архитектура Picostress


  1. RealLazyCat
    12.12.2024 07:56

    почему в сравнении утилит для НТ нет jmeter?