Skip to content
ClickHouse Docs
ClickHouse DocsClickHouse Docs

Хранимые процедуры и параметры запроса в ClickHouse

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

Альтернативы хранимым процедурам в ClickHouse

ClickHouse не поддерживает традиционные хранимые процедуры с логикой управления потоком выполнения (IF/ELSE, циклы и т. д.). Это осознанное решение, обусловленное архитектурой ClickHouse как аналитической базы данных. В аналитических базах данных циклы не рекомендуются, поскольку выполнение O(n) простых запросов обычно медленнее, чем выполнение меньшего числа более сложных запросов.

ClickHouse оптимизирован для:

  • Аналитических нагрузок - Сложных агрегаций на больших наборах данных
  • Пакетной обработки - Эффективной работы с большими объёмами данных
  • Декларативных запросов - SQL-запросов, которые описывают, какие данные нужно получить, а не как их обрабатывать

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

Пользовательские функции (UDFs)

Пользовательские функции позволяют инкапсулировать повторно используемую логику без использования конструкций управления потоком. ClickHouse поддерживает два типа:

UDF на основе лямбда-выражений

Создавайте функции с помощью SQL-выражений и синтаксиса лямбда-выражений:

Тестовые данные для примеров
-- Создание таблицы products
CREATE TABLE products (
    product_id UInt32,
    product_name String,
    price Decimal(10, 2)
)
ENGINE = MergeTree()
ORDER BY product_id;

-- Вставка тестовых данных
INSERT INTO products (product_id, product_name, price) VALUES
(1, 'Laptop', 899.99),
(2, 'Wireless Mouse', 24.99),
(3, 'USB-C Cable', 12.50),
(4, 'Monitor', 299.00),
(5, 'Keyboard', 79.99),
(6, 'Webcam', 54.95),
(7, 'Desk Lamp', 34.99),
(8, 'External Hard Drive', 119.99),
(9, 'Headphones', 149.00),
(10, 'Phone Stand', 15.99);
-- Простая функция вычисления
CREATE FUNCTION calculate_tax AS (price, rate) -> price * rate;

SELECT
    product_name,
    price,
    calculate_tax(price, 0.08) AS tax
FROM products;
-- Условная логика с использованием if()
CREATE FUNCTION price_tier AS (price) ->
    if(price < 100, 'Budget',
       if(price < 500, 'Mid-range', 'Premium'));

SELECT
    product_name,
    price,
    price_tier(price) AS tier
FROM products;
-- Работа со строками
CREATE FUNCTION format_phone AS (phone) ->
    concat('(', substring(phone, 1, 3), ') ',
           substring(phone, 4, 3), '-',
           substring(phone, 7, 4));

SELECT format_phone('5551234567');
-- Результат: (555) 123-4567

Ограничения:

  • Нет циклов и сложного управления потоком
  • Нельзя изменять данные (INSERT/UPDATE/DELETE)
  • Рекурсивные функции не поддерживаются

Полный синтаксис см. в CREATE FUNCTION.

Исполняемые UDF

Для более сложной логики используйте исполняемые UDF, которые вызывают внешние программы:

<!-- /etc/clickhouse-server/sentiment_analysis_function.xml -->
<functions>
    <function>
        <type>executable</type>
        <name>sentiment_score</name>
        <return_type>Float32</return_type>
        <argument>
            <type>String</type>
        </argument>
        <format>TabSeparated</format>
        <command>python3 /opt/scripts/sentiment.py</command>
    </function>
</functions>
-- Использование исполняемой UDF
SELECT
    review_text,
    sentiment_score(review_text) AS score
FROM customer_reviews;

Исполняемые UDF могут реализовывать произвольную логику на любом языке программирования (Python, Node.js, Go и т. д.).

Подробности см. в разделе Исполняемые UDF.

Параметризованные представления

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

Тестовые данные для примера
-- Создание таблицы sales
CREATE TABLE sales (
  date Date,
  product_id UInt32,
  product_name String,
  category String,
  quantity UInt32,
  revenue Decimal(10, 2),
  sales_amount Decimal(10, 2)
)
ENGINE = MergeTree()
ORDER BY (date, product_id);

-- Вставка тестовых данных
INSERT INTO sales VALUES
('2024-01-05', 12345, 'Laptop Pro', 'Electronics', 2, 1799.98, 1799.98),
('2024-01-06', 12345, 'Laptop Pro', 'Electronics', 1, 899.99, 899.99),
('2024-01-10', 12346, 'Wireless Mouse', 'Electronics', 5, 124.95, 124.95),
('2024-01-15', 12347, 'USB-C Cable', 'Accessories', 10, 125.00, 125.00),
('2024-01-20', 12345, 'Laptop Pro', 'Electronics', 3, 2699.97, 2699.97),
('2024-01-25', 12348, 'Monitor 4K', 'Electronics', 2, 598.00, 598.00),
('2024-02-01', 12345, 'Laptop Pro', 'Electronics', 1, 899.99, 899.99),
('2024-02-05', 12349, 'Keyboard Mechanical', 'Accessories', 4, 319.96, 319.96),
('2024-02-10', 12346, 'Wireless Mouse', 'Electronics', 8, 199.92, 199.92),
('2024-02-15', 12350, 'Webcam HD', 'Electronics', 3, 164.85, 164.85);
-- Создание параметризованного представления
CREATE VIEW sales_by_date AS
SELECT
    date,
    product_id,
    sum(quantity) AS total_quantity,
    sum(revenue) AS total_revenue
FROM sales
WHERE date BETWEEN {start_date:Date} AND {end_date:Date}
GROUP BY date, product_id;
-- Запрос к представлению с параметрами
SELECT *
FROM sales_by_date(start_date='2024-01-01', end_date='2024-01-31')
WHERE product_id = 12345;

Распространённые варианты использования

-- Более сложное параметризованное представление
CREATE VIEW top_products_by_category AS
SELECT
    category,
    product_name,
    revenue,
    rank
FROM (
    SELECT
        category,
        product_name,
        revenue,
        rank() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank
    FROM (
        SELECT
            category,
            product_name,
            sum(sales_amount) AS revenue
        FROM sales
        WHERE category = {category:String}
            AND date >= {min_date:Date}
        GROUP BY category, product_name
    )
)
WHERE rank <= {top_n:UInt32};

-- Использование
SELECT * FROM top_products_by_category(
    category='Electronics',
    min_date='2024-01-01',
    top_n=10
);

Дополнительные сведения см. в разделе Параметризованные представления.

Materialized views

Materialized views идеально подходят для предварительного вычисления ресурсоёмких агрегаций, которые в традиционных базах данных обычно выполняются в хранимых процедурах. Если вы привыкли к традиционным базам данных, воспринимайте materialized view как INSERT trigger, который автоматически преобразует и агрегирует данные по мере их вставки в исходную таблицу:

-- Исходная таблица
CREATE TABLE page_views (
    user_id UInt64,
    page String,
    timestamp DateTime,
    session_id String
)
ENGINE = MergeTree()
ORDER BY (user_id, timestamp);

-- Materialized view для поддержания агрегированной статистики
CREATE MATERIALIZED VIEW daily_user_stats
ENGINE = SummingMergeTree()
ORDER BY (date, user_id)
AS SELECT
    toDate(timestamp) AS date,
    user_id,
    count() AS page_views,
    uniq(session_id) AS sessions,
    uniq(page) AS unique_pages
FROM page_views
GROUP BY date, user_id;

-- Вставка тестовых данных в исходную таблицу
INSERT INTO page_views VALUES
(101, '/home', '2024-01-15 10:00:00', 'session_a1'),
(101, '/products', '2024-01-15 10:05:00', 'session_a1'),
(101, '/checkout', '2024-01-15 10:10:00', 'session_a1'),
(102, '/home', '2024-01-15 11:00:00', 'session_b1'),
(102, '/about', '2024-01-15 11:05:00', 'session_b1'),
(101, '/home', '2024-01-16 09:00:00', 'session_a2'),
(101, '/products', '2024-01-16 09:15:00', 'session_a2'),
(103, '/home', '2024-01-16 14:00:00', 'session_c1'),
(103, '/products', '2024-01-16 14:05:00', 'session_c1'),
(103, '/products', '2024-01-16 14:10:00', 'session_c1'),
(102, '/home', '2024-01-17 10:30:00', 'session_b2'),
(102, '/contact', '2024-01-17 10:35:00', 'session_b2');

-- Запрос предварительно агрегированных данных
SELECT
    user_id,
    sum(page_views) AS total_views,
    sum(sessions) AS total_sessions
FROM daily_user_stats
WHERE date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY user_id;

Refreshable materialized views

Для пакетной обработки по расписанию (например, для ночных хранимых процедур):

-- Автоматическое обновление каждый день в 2:00
CREATE MATERIALIZED VIEW monthly_sales_report
REFRESH EVERY 1 DAY OFFSET 2 HOUR
AS SELECT
    toStartOfMonth(order_date) AS month,
    region,
    product_category,
    count() AS order_count,
    sum(amount) AS total_revenue,
    avg(amount) AS avg_order_value
FROM orders
WHERE order_date >= today() - INTERVAL 13 MONTH
GROUP BY month, region, product_category;

-- Запрос всегда возвращает актуальные данные
SELECT * FROM monthly_sales_report
WHERE month = toStartOfMonth(today());

См. Каскадные materialized view для более сложных сценариев.

Внешняя оркестрация

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

Использование прикладного кода

Ниже приведено наглядное сравнение того, как хранимая процедура MySQL реализуется в виде прикладного кода при работе с ClickHouse:

DELIMITER $$

CREATE PROCEDURE process_order(
    IN p_order_id INT,
    IN p_customer_id INT,
    IN p_order_total DECIMAL(10,2),
    OUT p_status VARCHAR(50),
    OUT p_loyalty_points INT
)
BEGIN
    DECLARE v_customer_tier VARCHAR(20);
    DECLARE v_previous_orders INT;
    DECLARE v_discount DECIMAL(10,2);

    -- Начало транзакции
    START TRANSACTION;

    -- Получение информации о клиенте
    SELECT tier, total_orders
    INTO v_customer_tier, v_previous_orders
    FROM customers
    WHERE customer_id = p_customer_id;

    -- Расчёт скидки на основе уровня
    IF v_customer_tier = 'gold' THEN
        SET v_discount = p_order_total * 0.15;
    ELSEIF v_customer_tier = 'silver' THEN
        SET v_discount = p_order_total * 0.10;
    ELSE
        SET v_discount = 0;
    END IF;

    -- Вставка записи о заказе
    INSERT INTO orders (order_id, customer_id, order_total, discount, final_amount)
    VALUES (p_order_id, p_customer_id, p_order_total, v_discount,
            p_order_total - v_discount);

    -- Обновление статистики клиента
    UPDATE customers
    SET total_orders = total_orders + 1,
        lifetime_value = lifetime_value + (p_order_total - v_discount),
        last_order_date = NOW()
    WHERE customer_id = p_customer_id;

    -- Расчёт баллов лояльности (1 балл за доллар)
    SET p_loyalty_points = FLOOR(p_order_total - v_discount);

    -- Запись транзакции баллов лояльности
    INSERT INTO loyalty_points (customer_id, points, transaction_date, description)
    VALUES (p_customer_id, p_loyalty_points, NOW(),
            CONCAT('Order #', p_order_id));

    -- Проверка необходимости повышения уровня клиента
    IF v_previous_orders + 1 >= 10 AND v_customer_tier = 'bronze' THEN
        UPDATE customers SET tier = 'silver' WHERE customer_id = p_customer_id;
        SET p_status = 'ORDER_COMPLETE_TIER_UPGRADED_SILVER';
    ELSEIF v_previous_orders + 1 >= 50 AND v_customer_tier = 'silver' THEN
        UPDATE customers SET tier = 'gold' WHERE customer_id = p_customer_id;
        SET p_status = 'ORDER_COMPLETE_TIER_UPGRADED_GOLD';
    ELSE
        SET p_status = 'ORDER_COMPLETE';
    END IF;

    COMMIT;
END$$

DELIMITER ;

-- Вызов хранимой процедуры
CALL process_order(12345, 5678, 250.00, @status, @points);
SELECT @status, @points;

Ключевые различия

  1. Управление потоком выполнения - Хранимые процедуры MySQL используют IF/ELSE и циклы WHILE. В ClickHouse эту логику следует реализовывать в прикладном коде (Python, Java и т. д.)
  2. Транзакции - MySQL поддерживает BEGIN/COMMIT/ROLLBACK для ACID-транзакций. ClickHouse — аналитическая база данных, оптимизированная для рабочих нагрузок с добавлением данных, а не для транзакционных обновлений
  3. Обновления - MySQL использует операторы UPDATE. В ClickHouse для изменяемых данных предпочтительнее INSERT с ReplacingMergeTree или CollapsingMergeTree
  4. Переменные и состояние - Хранимые процедуры MySQL могут объявлять переменные (DECLARE v_discount). В ClickHouse состоянием следует управлять в прикладном коде
  5. Обработка ошибок - MySQL поддерживает SIGNAL и обработчики исключений. В прикладном коде используйте встроенные в язык средства обработки ошибок (try/catch)

Использование инструментов оркестрации рабочих процессов

  • Apache Airflow - Планирование и мониторинг сложных DAG с запросами ClickHouse
  • dbt - Преобразование данных с помощью SQL-ориентированных рабочих процессов
  • Prefect/Dagster - Современные средства оркестрации на Python
  • Custom schedulers - задания cron, Kubernetes CronJobs и т. д.

Преимущества внешней оркестрации:

  • Полная мощь языков программирования
  • Более удобная обработка ошибок и логика повторных попыток
  • Интеграция с внешними системами (API, другими базами данных)
  • Контроль версий и тестирование
  • Мониторинг и оповещения
  • Более гибкое планирование

Альтернативы подготовленным операторам в ClickHouse

Хотя в ClickHouse нет традиционных «подготовленных операторов» в смысле СУБД, он поддерживает параметры запроса, которые выполняют ту же задачу: позволяют создавать безопасные параметризованные запросы и предотвращать SQL-инъекции.

Синтаксис

Существует два способа задать параметры запроса:

Способ 1: с помощью SET

Пример таблицы и данных
-- Создание таблицы user_events (синтаксис ClickHouse)
CREATE TABLE user_events (
    event_id UInt32,
    user_id UInt64,
    event_name String,
    event_date Date,
    event_timestamp DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, event_date);

-- Пример вставки данных для нескольких пользователей и событий
INSERT INTO user_events (event_id, user_id, event_name, event_date, event_timestamp) VALUES
(1, 12345, 'page_view', '2024-01-05', '2024-01-05 10:30:00'),
(2, 12345, 'page_view', '2024-01-05', '2024-01-05 10:35:00'),
(3, 12345, 'add_to_cart', '2024-01-05', '2024-01-05 10:40:00'),
(4, 12345, 'page_view', '2024-01-10', '2024-01-10 14:20:00'),
(5, 12345, 'add_to_cart', '2024-01-10', '2024-01-10 14:25:00'),
(6, 12345, 'purchase', '2024-01-10', '2024-01-10 14:30:00'),
(7, 12345, 'page_view', '2024-01-15', '2024-01-15 09:15:00'),
(8, 12345, 'page_view', '2024-01-15', '2024-01-15 09:20:00'),
(9, 12345, 'page_view', '2024-01-20', '2024-01-20 16:45:00'),
(10, 12345, 'add_to_cart', '2024-01-20', '2024-01-20 16:50:00'),
(11, 12345, 'purchase', '2024-01-25', '2024-01-25 11:10:00'),
(12, 12345, 'page_view', '2024-01-28', '2024-01-28 13:30:00'),
(13, 67890, 'page_view', '2024-01-05', '2024-01-05 11:00:00'),
(14, 67890, 'add_to_cart', '2024-01-05', '2024-01-05 11:05:00'),
(15, 67890, 'purchase', '2024-01-05', '2024-01-05 11:10:00'),
(16, 12345, 'page_view', '2024-02-01', '2024-02-01 10:00:00'),
(17, 12345, 'add_to_cart', '2024-02-01', '2024-02-01 10:05:00');
SET param_user_id = 12345;
SET param_start_date = '2024-01-01';
SET param_end_date = '2024-01-31';

SELECT
    event_name,
    count() AS event_count
FROM user_events
WHERE user_id = {user_id: UInt64}
    AND event_date BETWEEN {start_date: Date} AND {end_date: Date}
GROUP BY event_name;

Метод 2: с использованием параметров CLI

clickhouse-client \
    --param_user_id=12345 \
    --param_start_date='2024-01-01' \
    --param_end_date='2024-01-31' \
    --query="SELECT count() FROM user_events
             WHERE user_id = {user_id: UInt64}
             AND event_date BETWEEN {start_date: Date} AND {end_date: Date}"

Синтаксис параметров

Для обращения к параметрам используется запись: {parameter_name: DataType}

  • parameter_name - имя параметра (без префикса param_)
  • DataType - тип данных ClickHouse, к которому нужно привести параметр

Примеры типов данных

Таблицы и тестовые данные для примера
-- 1. Создать таблицу для тестов строк и чисел
CREATE TABLE IF NOT EXISTS users (
    name String,
    age UInt8,
    salary Float64
) ENGINE = Memory;

INSERT INTO users VALUES
    ('John Doe', 25, 75000.50),
    ('Jane Smith', 30, 85000.75),
    ('Peter Jones', 20, 50000.00);

-- 2. Создать таблицу для тестов дат и временных меток
CREATE TABLE IF NOT EXISTS events (
    event_date Date,
    event_timestamp DateTime
) ENGINE = Memory;

INSERT INTO events VALUES
    ('2024-01-15', '2024-01-15 14:30:00'),
    ('2024-01-15', '2024-01-15 15:00:00'),
    ('2024-01-16', '2024-01-16 10:00:00');

-- 3. Создать таблицу для тестов массивов
CREATE TABLE IF NOT EXISTS products (
    id UInt32,
    name String
) ENGINE = Memory;

INSERT INTO products VALUES (1, 'Laptop'), (2, 'Monitor'), (3, 'Mouse'), (4, 'Keyboard');

-- 4. Создать таблицу для тестов Map (аналогов структур)
CREATE TABLE IF NOT EXISTS accounts (
    user_id UInt32,
    status String,
    type String
) ENGINE = Memory;

INSERT INTO accounts VALUES
    (101, 'active', 'premium'),
    (102, 'inactive', 'basic'),
    (103, 'active', 'basic');

-- 5. Создать таблицу для тестов Identifier
CREATE TABLE IF NOT EXISTS sales_2024 (
    value UInt32
) ENGINE = Memory;

INSERT INTO sales_2024 VALUES (100), (200), (300);
SET param_name = 'John Doe';
SET param_age = 25;
SET param_salary = 75000.50;

SELECT name, age, salary FROM users
WHERE name = {name: String}
  AND age >= {age: UInt8}
  AND salary <= {salary: Float64};

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

Ограничения параметров запроса

Параметры запроса — не универсальные текстовые подстановки. У них есть определённые ограничения:

  1. Они в первую очередь предназначены для операторов SELECT — лучше всего поддерживаются в запросах SELECT
  2. Они работают как идентификаторы или литералы — ими нельзя подставлять произвольные фрагменты SQL
  3. У них ограниченная поддержка DDL — они поддерживаются в CREATE TABLE, но не в ALTER TABLE

Что РАБОТАЕТ:

-- ✓ Значения в предложении WHERE
SELECT * FROM users WHERE id = {user_id: UInt64};

-- ✓ Имена таблиц/баз данных
SELECT * FROM {db: Identifier}.{table: Identifier};

-- ✓ Значения в предложении IN
SELECT * FROM products WHERE id IN {ids: Array(UInt32)};

-- ✓ CREATE TABLE
CREATE TABLE {table_name: Identifier} (id UInt64, name String) ENGINE = MergeTree() ORDER BY id;

Что НЕ работает:

-- ✗ Имена столбцов в SELECT (используйте Identifier с осторожностью)
SELECT {column: Identifier} FROM users;  -- Ограниченная поддержка

-- ✗ Произвольные фрагменты SQL
SELECT * FROM users {where_clause: String};  -- НЕ ПОДДЕРЖИВАЕТСЯ

-- ✗ Команды ALTER TABLE
ALTER TABLE {table: Identifier} ADD COLUMN new_col String;  -- НЕ ПОДДЕРЖИВАЕТСЯ

-- ✗ Несколько команд
{statements: String};  -- НЕ ПОДДЕРЖИВАЕТСЯ

Рекомендации по безопасности

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

# ✓ БЕЗОПАСНО - Использует параметры
user_input = request.get('user_id')
result = client.query(
    "SELECT * FROM orders WHERE user_id = {uid: UInt64}",
    parameters={'uid': user_input}
)

# ✗ ОПАСНО - Риск SQL-инъекции!
user_input = request.get('user_id')
result = client.query(f"SELECT * FROM orders WHERE user_id = {user_input}")

Проверяйте типы входных данных:

def get_user_orders(user_id: int, start_date: str):
    # Проверяем типы перед выполнением запроса
    if not isinstance(user_id, int) or user_id <= 0:
        raise ValueError("Invalid user_id")

    # Параметры обеспечивают типобезопасность
    return client.query(
        """
        SELECT * FROM orders
        WHERE user_id = {uid: UInt64}
            AND order_date >= {start: Date}
        """,
        parameters={'uid': user_id, 'start': start_date}
    )

Подготовленные операторы в протоколе MySQL

Интерфейс MySQL в ClickHouse поддерживает подготовленные операторы (COM_STMT_PREPARE, COM_STMT_EXECUTE, COM_STMT_CLOSE) лишь в минимальном объёме — в первую очередь чтобы обеспечить совместимость с такими инструментами, как Tableau Online, которые оборачивают запросы в подготовленные операторы.

Ключевые ограничения:

  • Привязка параметров не поддерживается — нельзя использовать плейсхолдеры ? со связанными параметрами
  • Запросы сохраняются, но не разбираются на этапе PREPARE
  • Реализация минимальна и рассчитана на совместимость с конкретными BI-инструментами

Пример того, что не работает:

-- Этот подготовленный оператор в стиле MySQL с параметрами НЕ работает в ClickHouse
PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?';
EXECUTE stmt USING @user_id;  -- Привязка параметров не поддерживается

Подробнее см. документацию по интерфейсу MySQL и статью в блоге о поддержке MySQL.

Кратко

Альтернативы хранимым процедурам в ClickHouse

Традиционный шаблон хранимых процедур Альтернатива в ClickHouse
Простые вычисления и преобразования Пользовательские функции (UDF)
Переиспользуемые параметризованные запросы Параметризованные представления
Предварительно вычисленные агрегации Materialized Views
Пакетная обработка по расписанию Refreshable Materialized Views
Сложный многоэтапный ETL Цепочки materialized views или внешняя оркестрация (Python, Airflow, dbt)
Бизнес-логика с управляющими конструкциями Прикладной код

Использование параметров запроса

Параметры запроса можно использовать для:

  • Предотвращения SQL-инъекций
  • Параметризованных запросов с контролем типов
  • Динамической фильтрации в приложениях
  • Повторного использования шаблонов запросов
Navigation