- PVSM.RU - https://www.pvsm.ru -

База данных SQLite представляет собой хранящееся на диске B-дерево, состоящее из строк. Для выполнения запросов в этой БД используется виртуальная машина VDBE [2]. Она кроссплатформенная, однопоточная и работает почти на всём.
SQLite относится к БД общего назначения, но преимущественно выигрывает в задачах транзакционной обработки данных (OLTP). Однако в 2015 году исследователи из Университета Буффало выяснили, что большинство запросов в ней — это простые поиски пар ключ-значение и сложные задачи оперативной аналитической обработки (OLAP). Впоследствии другие исследователи, уже из Висконсинского университета в Мэдисоне, захотели ускорить этот её аспект аналитической обработки, так как он становится всё более актуальным. В качестве экспериментальной основы они использовали DuckDB, которую оценивали с помощью общепринятого бенчмарка Star Schema Benchmark (SSB).
SSB включает множество аналитических запросов, называемых Star Queries, в которых используется большая таблица fact и множество меньших таблиц dimension. Например, fact может содержать заказы клиентов, а dimension — информацию о клиенте, поставщике, партнёрах и так далее.

Вот простой запрос и план его выполнения. Будучи типичным аналитическим запросом, он включает операции соединения (JOIN), в данном случае между четырьмя таблицами.

Как и ожидалось, исследователи выяснили, что DuckDB в 30-50х быстрее SQLite. Причём DuckDB они использовали в однопоточном режиме.
Надо разобраться, почему SQLite оказалась столь медленной, тогда мы сможем понять, как её ускорить. У этой БД есть опция этапа компиляции, VDBE_PROFILE, которая измеряет количество тактов процессора, затраченных на выполнение VDBE каждой инструкции. Повторно прогнав бенчмарк, исследователи выявили два основных опкода:

Что эти операции делают?
SeekRowID — получает rowId и сканирует соответствующую строку в B-дереве.Column — извлекает из указанной записи столбец.Поскольку аналитические запросы состоят преимущественно из операций соединения, нужно разобрать, как эти операции реализованы.
Вот несколько способов реализации JOIN:
SQLite выполняет простейший из этих видов — соединение вложенными циклами. Вот анимация [3], которая показывает его в действии:

А вот образец псевдокода в стиле Python:
orders: {id, data} = [(1, obj...), (7, ...), (21, ...)] # Таблица fact
customers: {order_id, data} = [(2, obj...), (7, ...), (14, ...)] # Таблица dimension
selected = []
for order in orders:
for customer in customers:
# Отбрасывает данные, если id customer равен 2 или 14
if order.id == customer.order_id:
selected.append(customer)
Предположим, у вас есть две таблицы: заказы (orders) и клиенты (customers). Приведённый код производит соединение этих таблиц на основе столбца order_id. Внешний цикл перебирает каждый элемент в таблице customers и также перебором сопоставляет с ним все элементы таблицы orders.
В случае совпадения id он добавляет соответствующие записи в список результатов. Такие операции с циклами сопряжены с весьма затратным обращением к B-дереву.
Наша же цель — уменьшить число таких обращений. Задумайтесь, чем плох описанный механизм? Есть идеи, как реализовать его наиболее эффективно?
Порядок таблиц в процессе соединения тоже важен. Вот простой пример:
Представим, что у нас три таблицы: Orders, Customers и Date. Наш запрос совпадает с 20 элементами в Customers и с 10 в Date. При каждом совпадении строки мы выполняем поиск по B-дереву.
Одна только перестановка таблиц местами уже сократила процесс до 1М операций. Но оптимизированный план выполнения запроса придумать очень сложно, поэтому здесь мы имеем NP задачу.
Как оптимизировать соединение? Два других упомянутых выше алгоритма справляются лучше, чем вложенные циклы. Но авторы исследования утверждают, что соединение хэшированием требует много памяти, а SQLite чаще всего оперирует в средах с её ограниченным ресурсом. К тому же, добавление ещё одного алгоритма сильно усложнит планирование выполнения запроса.
orders = [(1, obj...), (7, ...), (21, ...)]
customers = [(2, obj...), (7, ...), (14, ...)]
selected = []
cache = {2: True, 7: True, 14: True}
for order in orders:
if order.id in cache:
# Проверяем таблицу customers только при обнаружении соответствия в кэше.
for customer in customers:
if order.id == customer.order_id:
selected.append(customer)
Вот возможное решение: прежде чем выполнять оба цикла, сначала создаём кэш данных из customers. Затем во внутреннем цикле первым делом проверяется этот кэш, и только в случае обнаружения совпадения выполняется перебор циклом.
Так и поступили исследователи. Они использовали фильтр Блума, который очень экономичен по ресурсам, вмещается в кэш-строку процессора и легко реализуется.
Они добавили два опкода: Filter и FilterAdd. Соединение начинается с перебора всех строк таблицы dimensions и установки в фильтре тех битов, которые соответствуют условию запроса. Это операция FilterAdd.
В ходе этого процесса мы на каждой стадии сначала проверяем, присутствует ли строка в фильтре Блума. Если да, выполняем поиск по B-дереву. Это операция Filter.
Вот оптимизированный план выполнения запроса.

А вот анализ количества тактов процессора после оптимизации — синие столбцы почти исчезли!

И каков результат? SQLite ускорилась в 7-10 раз!

Результаты исследования уже были внедрены в SQLite v3.38.0.
Почему фильтры Блума так хорошо себя показали? Потому что минимально нагружают память, прекрасно сочетаются с простой реализацией SQLite и использовались в имеющемся механизме обработки запросов.
Автор: Bright_Translate
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/bazy-danny-h/412447
Ссылки в тексте:
[1] SQLite: Past, Present, and Future (2022): https://www.vldb.org/pvldb/vol15/p3535-gaffney.pdf
[2] VDBE: https://www.sqlite.org/opcode.html
[3] анимация: https://bertwagner.com/posts/visualizing-nested-loops-joins-and-understanding-their-implications
[4] Источник: https://habr.com/ru/companies/ruvds/articles/885830/?utm_source=habrahabr&utm_medium=rss&utm_campaign=885830
Нажмите здесь для печати.