Выпуск 22 · подкаст «Тысяча фичей»

#22: Архитектура баз данных: компоненты и классификация

24:10
↓ скачать mp3

Второй выпуск сезона про базы данных. Через развёрнутую аналогию с сортировочным складом Александр Пахомов разбирает верхнеуровневую архитектуру почти любой СУБД — транспортный уровень, обработчик запросов с оптимизатором, подсистему выполнения и подсистему хранилища (диспетчеры транзакций, блокировок, буфера, восстановления и средства доступа), — а затем классифицирует базы данных по методам хранения: OLTP против OLAP, in-memory против дисковых, строковые против колоночных. В финале — идея композируемости: современные СУБД собираются из переиспользуемых блоков, но абстракции между слоями неизбежно текут ради скорости.

Главное

  • Почти любая СУБД состоит из четырёх слоёв: транспортный уровень, обработчик запросов (парсер + оптимизатор), подсистема выполнения и подсистема хранилища.
  • Транспортный уровень двусторонний: одна подсистема общается с клиентом, другая — между нодами кластера, и они могут переиспользовать общий код.
  • Обработчик запросов сперва парсит и валидирует запрос (включая проверку доступа к объектам), а затем оптимизатор строит план запроса — дерево исполнения, которое видно через `EXPLAIN`.
  • Подсистема выполнения бывает локальной (`JOIN`-ы, сортировки, агрегации, чтение и запись данных) и удалённой (передать запрос или его часть на другую ноду).
  • Подсистема хранилища — самая сложная: диспетчер транзакций, диспетчер блокировок, средства доступа (`LSM`- или `B-Tree`-деревья), диспетчер буфера и диспетчер восстановления.
  • Большинство СУБД не полагаются на виртуальную память ОС для кэширования страниц, а реализуют собственный диспетчер буфера — это осознанное решение.
  • OLTP-базы (Postgres, Oracle) оптимизированы под частые одиночные чтения и записи и обычно строковые; OLAP-базы оптимизированы под редкие тяжёлые аналитические запросы и обычно колоночные.
  • Колоночное хранение выигрывает на агрегатах по одному полю (векторные `SIMD`-инструкции, лучшее сжатие однотипных данных), но проигрывает строковому на выборке всех полей одной записи.
Расшифровка

[00:20] Здорово! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Вы слушаете подкаст, в котором разработчик современной базы данных изучает то, как они работают, и делится знаниями со слушателями. В прошлом выпуске мы изучали историю баз данных и немного освежили знания по языку запросов SQL. Сегодня поговорим о том, из каких основных компонентов устроена почти любая база данных: это транспортный уровень, оптимизаторы запросов, система выполнения и система хранения. А ещё рассмотрим, чем базы данных могут различаться между собой — OLAP или OLTP, in-memory или персистентные, строковые или колоночные. Мы погружаемся в мир баз данных. Поехали!

[01:10] Представьте, что вы приехали на склад. Это такой снаружи ничем не примечательный ангар — он выглядит как наполовину закопанный в землю, лежащий на боку цилиндр. К ангару стоит очередь из грузовиков, которые привозят и отвозят посылки. Это всё, что мы видим снаружи. Давайте заглянем внутрь и посмотрим на устройство склада. Первое, что мы видим, — это грузчики, которые забирают посылки из грузовиков и несут их на распределительную ленту. Дальше вдоль ленты стоят роботы с камерами: камеры позволяют им определить тип посылки. Это может быть большая коробка с велосипедом, стопка писем или кроссовки. Под каждый тип посылки существует своя лента, и роботы раскладывают их на соответствующие ленты. Это процесс сортировки посылок.

[01:50] В конце каждой ленты стоят погрузочные машины, которые ездят туда-сюда и отвозят посылки на специальные высоченные стеллажи с полками. Но делают они это не по одной посылке за раз, а сразу нагружают целую стопку — так быстрее. Некоторые машинки едут быстро в самый конец полки и складывают пачки посылок одна за одной. Другие находят специальные места в середине полки, пододвигают соседние посылки и вставляют туда ещё одну. Таким образом на одной полке все посылки отсортированы по дате доставки, и погрузка на отправку будет гораздо быстрее. Пока оператор машины стоит возле полки, остальные выстраиваются в очередь и ждут — это плата за скорость отправки.

[02:29] Если мы посмотрим, как организованы стеллажи, то увидим, что ближайшие к выходу — те, на которых хранятся короткосрочные посылки: их погрузят в ближайшее время, и нет смысла увозить их далеко. А те, которые будут лежать на складе долго, отправляются в самый конец. Некоторые из коробок, что мы видим близко, иногда перемещаются подальше, потому что место на ближайшей полке ограничено, и посылка с самым коротким временем хранения вытесняет остальные. Сами по себе стеллажи тоже отличаются друг от друга. Одни хранят посылки одного размера в едином боксе — так экономится место, ведь одинаковые коробки не оставляют пустого пространства между собой; к тому же погрузчик может взять весь бокс целиком, а не возить посылки по одной. А рядом стоит стеллаж, на котором нет боксов и разные посылки лежат вперемешку, зато у всех них один и тот же получатель — и оператор может взять их пачкой и отвезти за раз. Хотя это не так эффективно с точки зрения перевозки, зато всё за один раз и для одного получателя.

[03:24] Отводим взгляд от стеллажей и полок и видим, что одна из машинок поехала сразу к выходу. Она кладёт посылки в грузовик, на котором написано имя другого склада: там хранят велосипеды, а на нашем складе под них нет подходящих полок. Всё это выглядит как хаос снаружи, но на самом деле склад — это хорошо отлаженный единый организм.

[04:04] Думаю, многие из вас уже догадались, к чему это было. Наш воображаемый склад — это то, как устроено большинство баз данных. Давайте пробежимся по основным блокам, держа в голове представление о складе. Аналогий будет много. Итак, грузовики между складами — это транспортный уровень: он отвечает за коммуникацию и снаружи базы данных, с её пользователями, и внутри, то есть между нодами (ангарами). Посылки — это наши данные. Люди и машинки внутри — это процессор, который работает на базе данных. Грузовики — это процессы или потоки со своей логикой, подсистема локального исполнения. Сортирующие роботы — это анализаторы запросов: они первыми парсят входящий запрос и принимают решение, куда его направить, а невалидные запросы просто отбрасывают. А тот факт, что машинки возят товары не по одному, а пачками с определённых лент, — это заслуга оптимизатора запросов.

[04:42] Стеллажи — это файлы с данными, а полки — это массивы байтов. Те стеллажи, что хранят посылки в единых боксах, очень похожи на колоночные хранилища, а те, что хранят посылки одного получателя, — на строковые хранилища типа Postgres. Когда оператор кладёт все посылки в конец стеллажа, это хранилище, оптимизированное под быструю запись, — его ещё иногда называют логом: туда мы быстро пишем, но найти нужную посылку там сложнее. А вот в отсортированном по дате отправки стеллаже найти посылку очень просто, но положить туда новую гораздо сложнее — так устроены отсортированные структуры данных, оптимизированные под быстрый поиск. Когда один оператор занимает часть полок на стеллаже — это блокировка: другие процессы должны ждать, даже несмотря на то, что иногда им нужно взять всего одну коробку с полки. А посылка с велосипедом, которая поехала в другой склад, раскрывает нам глаза на то, что склад на самом деле распределённый. В мире распределённых систем мы называем это кластером, а склад, который мы рассматривали, — это нода внутри кластера. Такая вот аналогия — думаю, некоторые из вас смогут её оценить.

[05:53] Если говорить уже без аналогии, то есть на техническом инженерном языке, то в целом мы имеем следующую архитектуру большинства баз данных: транспортный уровень, уровень обработчиков запросов, уровень подсистемы исполнения и подсистема хранилища. Транспортный уровень как бы делится на два. Первая — подсистема коммуникации внутри кластера, то есть то, как ноды между собой общаются. Вторая — подсистема коммуникации с клиентом. Это две разные транспортные системы, но они могут шерить между собой общий код или даже использовать одни и те же методы.

[06:23] Дальше первый, кто начинает обрабатывать запрос и встречается с ним непосредственно, — это обработчик запросов. Там тоже есть разделение: синтаксический анализатор запросов и оптимизатор запросов. Анализатор, во-первых, парсит запрос на том языке, на котором он пришёл, — скорее всего, это будет SQL, как мы уже поняли из прошлого выпуска. Он его валидирует: там могут быть какие-то невалидные конструкции. А если всё валидно и нормально, и такие объекты действительно есть, то он может, например, проверить доступ к объектам: если у пользователя нет доступа к какой-то таблице, из которой читает запрос, то запрос на этом уровне уже обрубится. Кстати, если вам интересно, как вообще устроена система ролей, юзеров и пермиссий, — можете послушать мой выпуск RBAC Special, я там про это всё рассказываю.

[07:10] Итак, синтаксический анализатор запросов делает эту работу и передаёт её оптимизатору. Оптимизатор строит своё внутреннее представление, которое называется планом запроса; вы можете его посмотреть, например, выполнив EXPLAIN на каком-нибудь Postgres, — и увидите, как это выглядит. Оптимизатор запросов — вообще сложная штука, и мы их обязательно рассмотрим в следующих выпусках. Но чтобы иметь представление: этот план — некое дерево исполнения. Оптимизатор отсекает ненужное, может делать эквивалентные преобразования — например, перевернуть дерево, выстроить индексы. У него есть статистика, он знает мощность (cardinality) индексов и может понять, когда какой индекс лучше использовать — хэш-индекс или другой. Всю эту работу он выполняет и в конце концов передаёт оптимизированное представление запроса подсистеме выполнения.

[08:04] Подсистема выполнения тоже может быть двух типов — локальной и удалённой. Локальная представляет наибольший интерес, потому что именно там происходят все эти джойны, сортировки, агрегации: вычитать данные, положить данные, посчитать — вся эта работа происходит там. А удалённое выполнение — это просто запрос на другую ноду. Причём тут тоже возможны варианты: мы можем передать туда запрос и сказать «дай мне данные, я сейчас посчитаю локально», а можем передать часть запроса, попросить посчитать удалённо и получить уже готовый результат. Это два разных подхода, и мы их все рассмотрим. Вот такая система выполнения.

[08:44] И самое низкое, самое ближайшее к железу, к дискам, — это подсистема хранилища. Внутри себя она устроена, наверное, сложнее всего; может, по сложности с ней и способны посоревноваться оптимизаторы запросов, но всё-таки подсистема хранилища — отдельная обособленная штука. Она состоит, например, из диспетчера транзакций, диспетчера блокировок, средств доступа, диспетчера буфера и диспетчера восстановления. Каждая из этих систем выполняет вполне определённую задачу. Диспетчер транзакций, например, следит за тем, чтобы все операции — чтения, записи — были согласованы в рамках одной транзакции; все эти уровни изоляции транзакций как раз лежат на нём. Делает он это с помощью диспетчера блокировок, который, когда нужно, некоторые записи или некоторые страницы в памяти просто блокирует, никому не даёт прочитать и предоставляет эксклюзивный доступ какому-то одному процессу, какому-то запросу.

[09:36] Средства доступа тоже бывают разные — например, это могут быть LSM-деревья или какие-то B-Tree-деревья. От этого зависит, как быстро будет выполнен запрос и как быстро будет выполнена вставка; как раз средства доступа за это и отвечают. Также есть буферизация: некоторые страницы буферизируются в памяти, чтобы не бегать каждый раз на диск, и за это отвечает отдельная подсистема — у каждой базы данных она своя. Кто-то может сказать, что за нас это может сделать операционная система с помощью виртуальной памяти, но большинство баз данных виртуальную память не используют — это плохая идея. Возможно, мы и про это поговорим, как оно всё устроено; сейчас же просто рассматриваем верхний уровень. Это диспетчер буферизации. И ещё есть диспетчер восстановления. Это на случай, когда что-то пойдёт не так: например, выдернут электричество, моргнёт сеть или просто случится out of memory — короче, приложение внезапно упадёт. Тогда, когда мы будем стартовать после этого нежданного падения, диспетчер восстановления начнёт работать: какие-то транзакции откатит, какие-то доведёт до валидного состояния, какие-то записи отвергнет и скажет, что они были неудачными, что-то вычитает из логов. В общем, у него там тоже огромная работа, которую мы рассмотрим в дальнейшем.

[10:54] Вот так плюс-минус выглядит верхнеуровневая архитектура среднего по больнице, среднего по рынку продукта — базы данных. Также, если смотреть на детали, на какие-то конкретные вещи, — их тоже реализует большинство баз данных. Например, это JDBC-доступ, это ODBC. У большинства современных баз данных есть какой-то REST. Есть CLI — я, кстати, тоже писал REST и CLI для баз данных. Есть нативные клиенты языков программирования: если вы хотите подключить себе библиотечку в Java и работать напрямую, красиво и компилируемо, вместо того чтобы писать запросы строками и пользоваться JDBC, — такая возможность тоже есть, некоторые базы данных её предоставляют. То есть это уже конкретные реализации конкретных методов доступа.

[11:39] Но помимо этой общей архитектуры, этих общих вещей, которые мы рассмотрели, базы данных, конечно же, между собой различаются. Иначе какая вообще была бы у разработчиков баз данных мотивация разрабатывать такое их количество? В основном различия — это методы хранения данных и методы распределения. Если говорить про методы хранения, то, как правило, начинают с OLTP и OLAP. Про это я, наверное, тоже запишу отдельный выпуск — или в рамках какого-то выпуска мы глубоко рассмотрим, что это такое. Но если коротко, то OLTP — это Online Transactional Processing, а OLAP — Online Analytical Processing. «Online» отсюда можно вообще убрать, да и «Processing» тоже — оставить только Transactional и Analytical: по сути, различаются они именно этим.

[12:27] Transactional — это, например, какой-нибудь Postgres. Он хорошо работает транзакционно, то есть хорошо делает много одиночных чтений и записей. Возьмём банально какой-нибудь интернет-магазин с товарами, пользователями, корзинами. Там нет ничего такого, что можно было бы аналитически запрашивать, — если мы говорим именно про веб-сайт (какие-то системы рекомендаций и прочее не берём). Вот берём классический интернет-магазин: нам нужно уметь создать пользователя, удалить пользователя, изменить у него какие-то поля. У него есть список полей, десяток штук — адрес, e-mail, имя, номер телефона, способ оплаты, — какие-то стандартные, банальные вещи. И паттерн доступа к бэкенду такой: дай мне вот этого пользователя, дай мне список его корзин, дай мне его историю, дай мне вот это. То есть всё такое одиночное — поэтому и называется transactional. Под одиночные, но частые запросы и оптимизированы OLTP-базы данных: Postgres, Oracle, например.

[13:28] А вот OLAP, или аналитические базы данных, оптимизированы под другое — под большие, действительно аналитические запросы. Это когда какой-нибудь аналитик или продакт сидит и не через основную морду веб-сайта смотрит товары, а подключается с другого края, через свою BI-систему, и думает: «А за февраль у нас покупок по вот этому товару в среднем насколько больше или меньше, чем за январь?» Такой запрос уже сам по себе формулирует аналитику, но под ним лежат данные как минимум за два месяца, по всем — то есть огромное количество данных, если у нас большой интернет-магазин. И чтобы с этим справиться, нужна как раз заточенная под такие большие запросы аналитическая база данных. Ей не обязательно обеспечивать транзакционность такого запроса — вообще не нужно её обеспечивать. И скорость ответа тоже не так критична: аналитик в целом может подождать минуту или секунд тридцать, а вот пользователь столько ждать не будет. Поэтому аналитические базы данных заточены под такой паттерн использования. В этом и есть различие OLTP и OLAP.

[14:38] Помимо этого банального различия, можно, например, разделять базы данных на in-memory и персистентные. Вообще in-memory баз данных не так много, но они принципиально отличаются тем, как организуют у себя внутри, что есть первичные данные, а что — вторичные. Ведь in-memory базы данных тоже часто сбрасывают данные на диск, чтобы после рестарта можно было их подтянуть. А дисковые базы данных, наоборот, кэшируют большое количество данных к себе в память, чтобы работать быстро. Так вот, разница между ними в том, что дисковые в первую очередь пишут на диск, и источник правды для них — это диск: если они сказали, что записали, и транзакция отработала, то данные точно будут на диске. А потом это дисковое состояние кэшируется — например, диспетчером буферизации или ещё какой-то системой кэширования, — чтобы работать быстро. Но в первую очередь всё лежит на диске.

[15:31] А in-memory базы данных, наоборот, всё сначала хранят в памяти, а потом какой-то асинхронный, неблокирующий процесс скидывает состояние на диск, чтобы в случае перезапуска поднять его и продолжить работать дальше. Тут очевидный минус: если in-memory база данных сказала, что запись прошла, и тут же закешировала её в памяти, а асинхронная подсистема не смогла записать это на диск, — то мы данные потеряем. Есть методы обхода и так далее, но основное концептуальное отличие между дисковыми и in-memory системами заключается именно в этом.

[16:07] Ещё одно популярное различие — колоночные против строковых баз данных. На самом деле, большинство аналитических баз данных, на мой взгляд, — это колоночные базы данных, а большинство транзакционных — строковые. То есть мир OLTP — это строковое хранилище, а мир OLAP — колоночное. Естественно, есть не то чтобы исключения — просто это не золотое правило. Могут быть и совмещённые базы данных, такие как HTAP (Hybrid Transactional Analytical Processing), которые сочетают в себе и то, и другое: там, естественно, идёт сочетание колоночного и строкового подходов. Вообще всё это не так однозначно и просто, но колоночные и строковые базы данных люди, как правило, между собой различают.

[16:50] А различие тут как раз в том, что строковые базы данных хранят данные построчно: вот эта строка так и представлена, как строка в табличке. Представим: имя, фамилия, год рождения и, например, e-mail — вот эта строка единым массивом хранится на диске, за ней идёт следующая строка, за ней ещё, — такое вот строчное хранилище. Преимущество в том, что если мы хотим вычитать всю информацию по одному пользователю, то идём по смещению в файле, забираем эту запись — и вот он, кусок памяти, в котором лежат все данные про нашего пользователя. Одно чтение, одна запись — супер. А вот если мы хотим выполнить аналитический запрос — например, посчитать количество уникальных семёрок в номерах телефонов всех пользователей, — то нам нужны только номера телефонов. Но считать только номера телефонов при такой системе хранения мы не можем: мы будем считывать всех пользователей — с именами, фамилиями, ещё кучей информации, которая лежит рядом. Мы всё это считаем, потом возьмём оттуда одно поле и уже по нему пойдём считать. Это накладные расходы на аналитические запросы. То есть строковые базы данных не так хороши, если речь про аналитику.

[17:58] Зато у строковых баз данных есть так называемая пространственная локальность. Если мы считали, например, имя пользователя, то с высокой вероятностью нам понадобится и его фамилия — а она будет тут же рядом, в той же странице. Про постраничную организацию, про кортежи (tuples), про слотированные страницы поговорим в следующем выпуске; пока просто «что-то там есть». Строковые базы данных кэшируют это всё вместе, поэтому если мы с одним пользователем делаем много операций, то данные, скорее всего, будут рядом. Вот эта пространственная локальность — преимущество строковых баз данных.

[18:28] В свою очередь, колоночные базы данных отлично подходят для тех самых агрегатов по каким-то одним полям. Потому что хранят они уже не одну строку — имя, фамилия, телефон, e-mail, — а имя, имя, имя, имя, потом фамилия, фамилия, фамилия, потом телефон, телефон, телефон. Ну, вы поняли: они просто делают пивот такой таблицы и хранят её совершенно по-другому. Преимущество в том, что если мы захотим посчитать все семёрки в номерах телефонов, то просто берём кусок памяти со всеми телефонами и гоним на него запрос. Помимо этого, все эти номера телефонов имеют один и тот же тип данных — а это очень хорошо сочетается с оптимизацией выполнения. Мы можем применять такие штуки, как векторные инструкции процессора: за одну инструкцию выполнять много операций, если они однотипные, — и как раз для такого хранения это идеально подходит. Также мы можем очень хорошо делать компакшн, потому что один и тот же тип данных, лежащий последовательно друг за другом, хорошо ложится на алгоритмы сжатия: они очень хорошо работают, когда одинаковые типы данных лежат рядом, — их можно сжать проще. Про это мы тоже поговорим дальше, но такое преимущество у колоночных баз данных есть.

[19:39] Недостатки у этого подхода, естественно, тоже есть. Если мы захотим прочитать по одному пользователю много данных — например, я хочу имя, фамилию и e-mail, — то подсистеме нужно пойти в три места и вычитать по одному и тому же оффсету (а возможно, и не оффсету — это зависит) по одному кусочку данных: из имён взять третий, из номеров — третий, из e-mail — третий. То есть три действия, чтобы вычитать информацию про одного пользователя, — в то время как в строковых базах данных мы просто идём и вычитываем всё за один раз. Поэтому аналитические базы данных не так хорошо подходят под такие паттерны доступа. То есть одни хороши в одном, другие — в другом.

[20:20] Позвольте мне немного вернуться к аналогии со складом и выразить такую мысль. Мы же знаем, что склады, хоть и могут быть по-разному организованы, снаружи имеют какие-то общие процессы и переиспользуют одинаковые штуки. То есть на разных складах — на складе Amazon, Azure и так далее — полки, возможно, будут одни и те же, от одного и того же производителя. Несмотря на то что работают они по-разному, какие-то вещи они переиспользуют: сортировочного робота, подавальщик лент, грузовики, все эти машинки. То есть у каждого склада нет собственных, эксклюзивных полок — ну, у каких-то есть узкозаточенные, но большинство заказывают одни и те же, просто различают их по типу. Это облегчает процесс постройки нового склада и вообще работу — операторам тоже проще, потому что это уже знакомые вещи.

[21:12] В базах данных всё то же самое. Раньше каждая база данных делала свой полностью проприетарный формат хранения — ну и сейчас такие есть, свои инхаус-разработки, — и переиспользовать это было практически невозможно. Но потом разработчики баз данных поняли, что во многих местах они по сути делают одно и то же: подсистема хранения, диспетчеры транзакций, подсистема выполнения, обработчики запросов, анализаторы запросов. Они всё это пишут, тратят огромное количество ресурсов, допускают баги и ошибки — в то время как можно вынести это в отдельные продукты, в отдельные библиотеки и переиспользовать между собой. Ну давайте честно: писать парсинг SQL самому с нуля — сомнительная затея, когда на рынке уже есть готовые парсеры, которые можно просто взять и использовать. Так вот, современные базы данных так и делают: берут готовые кусочки и собирают из них ту базу данных, ту конфигурацию, которая им нужна и выгодна.

[22:12] Но тут не стоит обольщаться, ведь переиспользование не такое простое, как может показаться на первый взгляд. Вот вы, например, если вы бэкендер, представляете это так: да, я могу взять библиотеку парсинга JSON — какой-нибудь ObjectMapper, — заиспользовать его, потом взять Jackson, потом ещё какой-то другой; то есть я могу их вот так менять, жонглировать ими, и в целом, если код хорошо написан, буду делать это без особых проблем. А вот в базе данных проблемы возникают часто, потому что там постоянно текут абстракции. Чтобы написать оптимальный, оптимизированный код, абстракция должна протечь: мы не можем на верхнем уровне так абстрагироваться от подсистемы хранения, чтобы не делать про неё каких-то ассампшенов. То есть эти слои не так идеально изолированы между собой, как в каком-нибудь стандартном бэкенде. Где-то протечка всё-таки есть, и она необходима — иначе без неё не было бы такого быстрого выполнения. Это просто плата за то, что базы данных — продукты, которые должны работать быстро, оптимально и не тормозить. Поэтому переиспользование возможно, и оно происходит, но оно не такое простое, как может показаться на первый взгляд.

[23:23] На этом мы завершаем верхнеуровневый обзор архитектуры баз данных. В следующем выпуске разберёмся с тем, как устроена память у современных компьютеров и какие абстракции инженеры используют, чтобы выжать максимум из того, что имеют. Не забывайте делиться подкастом с друзьями и коллегами — давайте прокачивать себя и людей вокруг. Ну а на этом всё. Услышимся!