
Привет, Хабр! Мое имя Александр Коваль, я разработчик IoT-сервисов в МТС Web Services. Сейчас ScyllaDB поддерживает ограниченное количество функций, в том числе агрегационных. В стандартном наборе: min, max, count, avg. Но ее функциональность расширяется двумя типами пользовательских функций: скалярными (scalar functions) и агрегационными (aggregate functions). Первые работают со значениями одной строки, а вторые — нескольких. Реализовать такие функции можно на Lua или Rust.
В процессе работы с агрегационными функциями можно столкнуться с тем, что ScyllaDB и Java-драйвер по-разному обрабатывают пустые значения. В этом посте я расскажу, как это можно решить относительно просто и без сложных дополнительных телодвижений. Для примера возьму код на Lua и покажу, как он реализуется в виде функции ScyllaDB.
Дисклеймер: этот материал написан на основе личного опыта — все решения получены методом проб и ошибок. Конструктивные предложения и советы по их улучшению приветствуется. Код с примерами и ссылки на ресурсы можно найти у меня в репозитории GitHub.
Исходная задача
Итак, нужно среди заданных элементов найти самый часто повторяющейся. В терминах ScyllaDB эта задача звучит так:
«Среди элементов одной колонки, при заданных ограничениях найти тот, который встречается чаще других».
Допустим, нам надо хранить информацию обо всех изменениях одного свойства. У него есть: группа, имя, время изменения и значение. Для этого будем использовать таблицу с именем property и такими колонками: group, name, date и value_string. У всех них тип «текст», кроме date, у нее «время и дата». Group и name определяют основной ключ (primary key). Date — кластерный (cluster key).
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);
Отмечу, что для простоты изложения в скриптах и примерах отсутствует информация о keyspace.
Пусть в таблице будут заданы следующие строки и значения:
group | name | date | value_string
------+------+---------------------------------+--------------
g | a | 2025-03-11 00:15:17.000000+0000 | data_5
g | a | 2025-03-11 00:15:16.000000+0000 | data_3
g | a | 2025-03-11 00:15:15.000000+0000 | data_2
g | a | 2025-03-11 00:15:14.000000+0000 | data_2
g | a | 2025-03-11 00:15:13.000000+0000 | data_1
В этом примере видим, что самое часто повторяющееся значение — data_2.
Пишем код на Lua
Предположим, что есть функция most_common_text(text), которая ищет самое частое значение. Она принимает и выдает значение типа текст.
Тогда 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'
Такая задача, к примеру, решается с использованием словаря. Давайте напишем ее код решения на Lua.
Функция для обработки одного значения:
function accumulate(storage, val)
if storage == nil then
storage = {}
end
if val == nil then
return storage
end
if storage[val] == nil then
storage[val] = 1
else
storage[val] = storage[val] + 1
end
return storage
end
Функция для нахождения результата, которая работает со всеми накопленными значениями от первой функции:
function calculate(storage)
if storage == nil
then
return nil
end
local value = nil
local count = 0
for v, c in pairs(storage) do
if c > count then
value = v
count = c
end
end
return value
end
Функция запуска процесса накопления и вычисления результата:
function most_common(data)
if data == nil
then
return nil
end
local storage = {}
for k, v in pairs(data) do
storage = accumulate(storage, v)
end
return calculate(storage)
end
Пример вызова:
function most_common_test()
local samples = {'data_1', 'data_2', 'data_2', 'data_3', 'data_5'}
local result = most_common(samples)
print(result)
end
Такая функция в итоге выведет нам искомое значение — data_2.
Функция для ScyllaDB
ScyllaDB позволяет использовать для функций различные языки. Ограничения вводятся на уровне самой БД. А указание языка, на котором производится реализация функции, указывается при создании самой функции через ключевое слово LANGUAGE. Они могут выглядеть следующим образом.
Функция для обработки одного значения:
CREATE OR REPLACE FUNCTION most_common_text_accumulate(storage _type_, val text)
RETURNS NULL ON NULL INPUT
RETURNS _type_
LANGUAGE lua
AS $$
...
$$;
Функция для нахождения результата:
CREATE OR REPLACE FUNCTION most_common_text_calculate(storage _type_)
RETURNS NULL ON NULL INPUT
RETURNS text
LANGUAGE lua AS $$
...
$$;
Функция запуска процесса накопления и вычисления результата:
CREATE OR REPLACE AGGREGATE most_common_text(text)
SFUNC most_common_text_accumulate
STYPE _type_
FINALFUNC most_common_text_calculate
INITCOND _default_;
Тут стоит остановиться на двух моментах: типе параметра, который используется в качестве словаря, и начальном значении для него.
Предположим, что для типа параметра используется встроенный типа map<text, bigint>. Тогда для начального значения можно задать null. Если так сделать, то при создании функции ScyllaDB выдает следующую ошибку.
CREATE OR REPLACE AGGREGATE most_common_text_map(text)
... SFUNC most_common_text_accumulate_map
... STYPE map<text, bigint>
... FINALFUNC most_common_text_calculate_map
... INITCOND null;
ServerError: <Error from server: code=0000 [Server error] message="marshaling error: read_simple - not enough bytes (expected 4, got 0) Backtrace: 0x415a30e ...libreloc/libc.so.6+0x100352
--------
seastar::lambda_task<seastar::execution_stage::flush()::$_5>">
Результаты других попыток создания можно посмотреть у меня в GitHub.
К примеру, если не задавать INITCOND:
CREATE OR REPLACE AGGREGATE most_common_text_map(text)
... SFUNC most_common_text_accumulate_map
... STYPE map<text, bigint>
... FINALFUNC most_common_text_calculate_map;
ServerError: <Error from server: code=0000 [Server error] message="conjunctions are not yet reachable via term_raw_expr::prepare() Backtrace: 0x415a30e .../libreloc/libc.so.6+0x100352
--------
seastar::lambda_task<seastar::execution_stage::flush()::$_5>">
ScyllDB использует «@» для пустых значений, но Java-драйвер его не может стандартно обработать. Кажется, что эту проблему невозможно решить без кода.
Решаем проблему с пустыми значениями в ScyllaDB
Методом проб и ошибок я пришел к следующему решению. Я использовал для словаря пользовательский тип, который содержит поле со встроенным типом. И в качестве начального значения применил в нем пустое значение для словарей «{}»:
Тип:
CREATE TYPE IF NOT EXISTS most_common_text_data_map
( text_data map<bigint, bigint> )
Начальное значение этой функции выглядит так:
{text_data: {}}
Но она все еще не работает. При попытке выполнить select выдается ошибка:
InvalidRequest: Error from server: code=2200 [Invalid query] message="value is not a number"
Нужно внести поправку в новый тип: добавить «frozen» на поле. Более подробно этот момент объяснен в документации. Например, тут и тут.
Итак, обновляем тип:
CREATE TYPE IF NOT EXISTS most_common_text_data
( text_data frozen<map<bigint, bigint>> );
Работает!
SELECT most_common_text(value_string) FROM property WHERE date >= '2025-03-11 00:00:00' AND date < '2025-03-11 23:00:00';
most_common_text(value_string)
--------------------------------------------
data_2
(1 rows)
Вместо заключения
Пользовательские функции расширяют функциональность ScyllaDB: их можно быстро реализовать и встроить в запросы. Но какие тут могут быть ограничения и на что нужно обратить внимание?
В первую очередь — на производительность. Навскидку можно предположить, что использование языка Lua — не самый производительный вариант, хотя и простой и понятный в реализации. Недаром разработчики предлагают ему альтернативу в виде Rust. Есть много статей, где описывается ускорение запросов в десятки раз с помощью Rust. Но использование своих функций необходимо проверять на собственных данных и в своем окружении.
Также возможно, что решение конкретной задачи можно упростить и воспользоваться встроенными функциями, которые в свою очередь работают без посредников. На вопрос производительности, в этом случае, в большей части будет отвечать сама ScyllaDB.