Привет, Хабр! Это Александр Коваль, я разработчик IoT-сервисов в МТС Web Services. При работе с данными часто возникает вопрос: как быстро система может вернуть результат по определенным параметрам? Не является исключением и ScyllaDB.

Для ответа нужны инструменты измерения и возможность настраивать систему. Java-драйвер для ScyllaDB умеет передавать информацию о своей внутренней работе, и ему можно настроить отдельные компоненты. Звучит как отличный план — в этом материале я поделюсь результатами экспериментов с java-драйвером для ScyllaDB при различных запросах к данным.

Код, ссылки и ресурсы располагаются в GitHub.

Данные и запросы

Для примера создадим в ScyllaDB таблицу с помощью Cql-скрипта:

CREATE TABLE IF NOT EXISTS property (
group text,
name text,
date timestamp,
value_string text,
PRIMARY KEY((group,name),date)) WITH CLUSTERING ORDER BY (date DESC);

Она хранит изменения (date) одного свойства с именем (name) и принадлежит определенной группе (group). Само значение хранится в текстовом виде (value_string). Group и name задают основной ключ (primary key). Date — кластерный (cluster key).

Примечание: в скриптах и примерах для простоты нет информации о keyspace.

Запрос:

SELECT group, name, date, value_string FROM property WHERE group=:group AND name=:name AND date>=:start AND date<:end

Он выполняется через вызов сервиса, реализованного связкой Spring Boot 3, и асинхронного api java-драйвера.

Код запроса в контроллере:

@GetMapping("/find")
public CompletionStage<Stream<Property>> find(
       @RequestParam(name = "group") String group,
       @RequestParam(name = "name") String name,
       @RequestParam(name = "start") Instant start,
       @RequestParam(name = "end") Instant end,
       @RequestParam(name = "offset") int offset,
       @RequestParam(name = "limit") int limit
) {
   return propertyService.findByData(group, name, start, end, offset, limit);
}

И в репозитории:

public CompletionStage<Stream<Property>> findByData(
       String group,
       String name,
       Instant start,
       Instant end,
       long offset,
       long limit
) {
   long startTime = Instant.now().toEpochMilli();
   BoundStatement bound = findByDataPreparedStatement.bind(group, name, start, end);
   CompletionStage<AsyncResultSet> stage = session.executeAsync(bound);
   return stage
           .thenCompose(first -> new RowCollector(first, offset, limit))
           .thenApply(rows -> rows.stream().map(rowMapper))
           .whenComplete((propertyOpt, exception) -> {
               if (exception == null) {
                   metrics.propertyFindByDataTimerRegister(startTime);
               } else {
                   metrics.propertyFindByDataWithErrorTimerRegister(startTime);
               }
           });
}

Несколько слов о методе получения данных в репозитории. Сначала создается команда на исполнение с привязкой ко входным данным. Затем у сессии из java-драйвера асинхронно вызывается подготовленная команда, а результат возвращается в контроллер в виде готового списка свойств. Регистрация метрик вызывается при получении результата от ScyllaDB.

Пример вызова запроса через curl:

curl -l 'localhost:8080/api/v1/property/find?group=g&name=a_0&start=20250101000000000&end=20250101000000900&offset=0&limit=10'

Все эксперименты я запускал локально на компьютере с CPU 1,4GHz и 8GB RAM.

Настройка инструментов

Я использовал:

Я не буду описывать работу и настройку этих инструментов — приведу лишь некоторые детали.

Для запуска используем docker-compose.yaml. Тут стоит выделить параметры valumes, которые применяются для связки grafana и prometheus. Установку jmeter можно провести по этой инструкции.

Для базовой отправки метрик и телеметрии добавим следующие зависимости:

<dependency>
   <groupId>io.micrometer</groupId>
   <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
   <groupId>io.opentelemetry</groupId>
   <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
   <groupId>io.micrometer</groupId>
   <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Подробности есть тут: spring-boot-3-observability, jaeger.

Для самих данных используем jmeter с вызовом метода создания свойства:

curl -l 'localhost:8080/api/v1/property' \
--header 'Content-Type: application/json' \
--data-raw '{"group": "g", "name": "a_0", "date": "20250101000000000", "valueString": "data_1"}'

С помощью тестового плана добавляем данные. При вызове случайно создается свойство с именем a_[0..9] с разбросом дат. В приведенных результатах для каждого имени сохранены от 30 до 50 значений.

Настройка java-драйвера

К созданию конфигурации драйвера подключимся через наследование DriverConfigLoaderBuilderCustomizer. Для этого реализуем метод void customize(ProgrammaticDriverConfigLoaderBuilder builder), дающий доступ к нужным настройкам. Рассмотрим лишь некоторые из них.

Метрики:

  • advanced.metrics.session.enabled содержит список метрик, которые описывают состояние сессий клиента и ScyllaDB. К примеру, cql-requests, характеризующий количество запросов в секунду в сессии к ScyllaDB.

  • advanced.metrics.node.enabled содержит список метрик, описывающих состояние взаимодействия клиента и узлов ScyllaDB. К примеру, pool.available-streams описывает количество доступных потоков работы между клиентом и узлами.

Настройки соединения (performance, pooling):

  • advanced.netty.io-group.size — количество потоков netty для запросов обмена данными с ScyllaDB и клиента;

  • advanced.connection.pool.local.size — число соединений в локальном пуле.

Настройка метрик сервиса

Для сбора метрик с запросов используется таймер из micrometer:

Timer.builder("propertyFindByDataTimer")
       .serviceLevelObjectives(DURATIONS)
       .tag(EXCEPTION_TAG_NAME, "none")
       .register(meterRegistry);

Таймер для учета ошибок:

Timer.builder("propertyFindByDataTimer")
       .serviceLevelObjectives(DURATIONS)
       .tag(EXCEPTION_TAG_NAME, "Error")
       .register(meterRegistry);

Пример вызова:

timer.record(Instant.now().toEpochMilli() - start, TimeUnit.MILLISECONDS);

Подробности можно найти тут: timers, SB + micrometer.

Настройка grafana

Для визуализации метрик в grafana необходимо создать панель с параметрами данных таймеров из prometheus-источника.

Примеры:

rate(propertyFindByDataTimer_seconds_count {exception="none"}[1m])
rate(propertyFindByDataTimer_seconds_count {exception="Error"}[1m])

Функция rate используется для вычисления значения запросов в секунду.

Эксперимент с запросом получения данных

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

Создадим нагрузку с помощью jmeter-плана. Будем отслеживать показатели FindByData и FindByDataError. Это micrometer-таймеры, отслеживающие время работы одного запроса. Для начала возьмем 400 потоков, 50 запросов при стандартных настройках.

Получим:

В результате FindByData ~300, FindByDataError = 0.

Теперь увеличим число потоков до 1 400:

В результате получаем FindByData ~700, FindByDataError ~20.

Видно, что при увеличении интенсивности запросов начали появляться запросы, которые не выполняются.

Пример ошибки:

com.datastax.oss.driver.api.core.AllNodesFailedException: All 1 node(s) tried for the query failed (showing first 1 nodes, use getAllErrors() for more): Node(endPoint=/127.0.0.1:9042…

Теперь меняем стандартные настройки на io-group.size = 4, pool.local.size = 2. Получаем:

В результате получаем FindByData ~1 000, FindByDataError ~10.

Видим, что количество ошибок снизилось, а число доступных потоков (pool.available-streams) увеличилось.

Увеличим io-group.size до 10, pool.local.size до 8. Получаем:

В результатах видно, что FindByData ~1 100 и FindByDataError = 0, а количество ошибок снизилось до 0. Соответственно, при таких настройках и условиях сервис может держать заданную нагрузку.

Эксперимент получения данных с помощью UDA

Рассмотрим функцию most_common_text(text), которая ищет самое частое значение в колонках с текстовым типом. Про UDA я писал в прошлом материале. Здесь уже другие micrometer-таймеры: MostCommonText, MostCommonTextError.

Пример cql-запроса:

SELECT most_common_text(value_string)
FROM property
WHERE date >= '2025-03-11 00:00:00' AND date < '2025-03-11 23:00:00'

Ставим количество потоков = 100, количество запросов = 50, io-group.size = 10, pool.local.size = 8.

Получаем:

В результате видно, что MostCommonText ~80 и MostCommonTextError = 0. Соответственно, при заданных условиях сервис может выполнять около 80 запросов в секунду для указанной функции.

Трассировка

Реализуем ее с помощью jaeger:

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

Что можно увидеть из моих экспериментов

Метрики java-драйвера включаются легко. Да, их много, и в документации они описаны не очень детально, но, меняя их через настройки, можно аккуратно тестировать и настраивать взаимодействие между своими сервисами, java-драйвером и самой ScyllaDB.

Отмечу, что сами эксперименты, хотя и являются искусственными, показывают возможность с помощью изменения настроек подобрать значения для нужной интенсивности запросов. Для регулирования пропускной способности можно изменять advanced.netty.io-group.size и advanced.connection.pool.local.size. Хотя точных формул этих параметров нет (см. performance, pooling), эти значения легко подобрать экспериментально. Можно отталкиваться от количества ядер, потоков и таймаутов.

На этом у меня все. Если возникли вопросы, отвечу на них в комментах.

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