Представление иерархии и выполнение иерархических запросов в ClickHouse с использованием хешей

Привет, Хабр! Достаточно часто используются иерархические фильтры или отчеты с иерархией, и представление иерархии может быть актуально как для UI (например, иерархических фильтров), так и для отчетов или дашбордов. Если рассматривать только структуру запроса с иерархией, без расчета промежуточных итогов и т.д., то сохранение структуры иерархического UI элемента при большом уровне вложенности, а также передача этой иерархии с UI на бэкенд и дальше, например, в виде SQL запроса в СУБД может быть относительно нетривиальной задачей. При относительно большом уровне вложенности (например, иерархия в 10 уровней), при решении «в лоб» и сохранении всех 10 выбранных значений на последнем уровне иерархии, станет неудобно хранить и передавать в качестве параметров с UI на бэкенд (для 1000 строк и 10 уровней вложенности может быть уже условно 10000 параметров), также растет и количество параметров в SQL, и проблемы усугубляются в случае микросервисной архитектуры, когда запрос SQL не сразу отправляется, например, в ClickHouse, а ещё эти 10000 параметров «путешествуют» из UI в один или несколько микросервисов, пока не попадут в ClickHouse. В связи с этим хочу рассмотреть одно из возможных решений проблемы с помощью хеширования на примере C# и ClickHouse, но это «не идеи, проверенные на продакшене», больше тема к обсуждению. Тем, кому интересно решение проблем иерархических запросов на примере C# и ClickHouse — добро пожаловать под кат :)

Рассмотрим пример иерархического контрола (например, иерархического фильтра), причем у нас есть 2 продукта, 3 клиента, 2 склада, всего 12 записей, из них 3 записи выбраны.

cad0881e4341b2fb94cbc30ae3735630.png

Для нагляжности создадим схему данных ClickHouse и 12 строк тестовых данных:

CREATE OR REPLACE TABLE sales
(
   order_number Int64,
   amount       Float64,
   order_date   Date,
   product_id   Int64,
   customer_id  Int64,
   store_id     Int64
) ENGINE = Log;

INSERT INTO sales
SELECT toString(1000000000 + number)   AS order_number,
      number % 100                     AS amount,
      dateAdd(number % 700, toDate('2023-01-01')),
      number / 6 % 2 + 1               AS product_id,
      number / 2 % 3 + 1               AS customer_id,
      number % 2 + 1                   AS store_id
FROM system.numbers
LIMIT 12;

ClickHouse поддерживает murmurHash2_32, и здесь используется MurmurHash2 для иллюстрации, т.к. он быстрый, и более сложный для примера и не нужен, и коллизии достаточно просто разрешить проверкой реальных значений из запроса. По поводу объединения хешей — предлагается использовать ещё более простой подход, чем в ClickHouse, считаем, что для иллюстрации этого достаточно, т.е. обычный XOR для объединения двух хешей. Конечно, для использования в продакшене нужно учесть разные типы данных, выбрать подходящую инкрементальную хеш функцию и т.д.

Предлагается рассчитывать хеши вида:

SELECT murmurHash2_32(product_id)                          AS product_id_hash,
      murmurHash2_32(bitXor(customer_id, product_id_hash)) AS customer_id_hash,
      murmurHash2_32(bitXor(store_id, customer_id_hash))   AS store_id_hash,
      order_number,
      amount,
      order_date,
      product_id,
      customer_id,
      store_id
FROM sales;

Это дает результат запроса такого вида (в выделенных строках — то, что соответствует иерархическому фильтру из UI, пока без фильтрации):

08739e871f84ae890edf50e077827219.png

В качестве иллюстрации подсчитаем MurmurHash2 на C# для первых двух выбранных строк, т.е. для Product #1 и Customer #1 (что соответствует двум складам, Store #1 и Store #2).

using System;

public class MurmurHash2
{
    public static uint Hash(byte[] data, uint seed = 0)
    {
        const uint m = 0x5bd1e995;
        const int r = 24;        

        int length = data.Length;
        uint h = seed ^ (uint)length;        

        int i = 0;
        while (length >= 4)
        {
            uint k = BitConverter.ToUInt32(data, i);
            k *= m;
            k ^= k >> r;
            k *= m;
            h *= m;
            h ^= k;
            i += 4;
            length -= 4;
        }        

        switch (length)
        {
            case 3:
                h ^= (uint)(data[i + 2] << 16);
                goto case 2;
            case 2:
                h ^= (uint)(data[i + 1] << 8);
                goto case 1;
            case 1:
                h ^= data[i];
                h *= m;
                break;
        }        

        h ^= h >> 13;
        h *= m;
        h ^= h >> 15;
        return h;
    }

    public static void Main(string[] args)
    {
		const UInt64 product1 = 1;
		const UInt64 customer1 = 1;
		var bytesProduct1 = BitConverter.GetBytes(product1);
        UInt64 hashProduct1 = Hash(bytesProduct1);
		var combinedProduct1Customer1 = (hashProduct1 ^ customer1);
		var bytesCombinedProduct1Customer1 = BitConverter.GetBytes(combinedProduct1Customer1);
		UInt64 hashCombinedProduct1Customer1 = Hash(bytesCombinedProduct1Customer1);
        Console.WriteLine($"Hash value: {hashProduct1}, {hashCombinedProduct1Customer1}");
    }
}

Этот C# код возвращает следующие хеши для уровня Product и для уровня Customer: Hash value: 3340017859, 1304444856. Для Store значение не вычисляется в C#, т.к. нас устраивают оба склада Store #1 и Store #2.

SQL решения в лоб (конечно, можно упростить первые два условия в WHERE, здесь простейшее решение, для наглядности):

SELECT order_number,
      amount,
      order_date,
      product_id,
      customer_id,
      store_id
FROM sales
WHERE product_id = 1 AND customer_id = 1 AND store_id = 1
  OR product_id = 1 AND customer_id = 1 AND store_id = 2
  OR product_id = 1 AND customer_id = 3 AND store_id = 2;
67fc2ffcef47846fa62aa6101fc8652b.png

Видно, что даже для 12 строк и трех уровней иерархии уже 9 параметров.

SQL решения через хеши для трех выбранных строк, соответствующих выбранным элементам из UI фильтра, причем фильтрация по хешу на уровне Customer дает две записи (со Store #1 и Store #2), и ещё одна фильтрация на уровне Store дает одну запись:

SELECT murmurHash2_32(product_id)                          AS product_id_hash,
      murmurHash2_32(bitXor(customer_id, product_id_hash)) AS customer_id_hash,
      murmurHash2_32(bitXor(store_id, customer_id_hash))   AS store_id_hash,
      order_number,
      amount,
      order_date,
      product_id,
      customer_id,
      store_id
FROM sales
WHERE customer_id_hash = 1304444856
OR store_id_hash = 3547175208;
3d782b936b2959d2a31c313c31c03fc9.png

Видно, что в WHERE с хешами всего два параметра, а в предыдущем решении 9.

Таким образом, видны преимущества работы с хешами, особенно если сохранить иерархические результаты во временную таблицу. Что касается коллизий — их легко разрешить, т.к. в результатах запроса есть все данные для этого. Также легко хранить структуру запроса (всего 2 хеша вместо 9 параметров WHERE), и наблюдаем снижение количества параметров — это удобнее для микросервисной архитектуры. Использование хешей может быть плюсом и при логировании параметров с точки зрения их шифрования. Конечно, для использования в продакшене нужно учесть разные типы данных, выбрать подходящую инкрементальную хеш функцию и т.д.

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

© Habrahabr.ru