
Привет, Хабр! Это Александр Коваль, я разработчик 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.
Настройка инструментов
Я использовал:
Grafana и prometheus.io для визуализации данных;
Jmeter для создания нагрузки;
Micrometer для метрик и телеметрии.
Я не буду описывать работу и настройку этих инструментов — приведу лишь некоторые детали.
Для запуска используем 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), эти значения легко подобрать экспериментально. Можно отталкиваться от количества ядер, потоков и таймаутов.
На этом у меня все. Если возникли вопросы, отвечу на них в комментах.