Наступил Апокалипсис.
Нет, не стоит бежать запасаться банками с консервами и крышками отечественной бай-колы! Апокалипсис произошёл только в нашей фантазии и с определённой целью — чтобы проверить, а может ли человек, обладающий только книгами по теме и стандартной библиотекой языка, воссоздать инструмент, который будет служить ему верой и правдой?
Так родился учебный проект SicQL, реляционная СУБД, чей символ — сова — это олицетворение силы знаний и мудрости. Олицетворение тех знаний и той мудрости, которые мы получим, создав с нуля то, чем мы пользуемся каждый день, может, не осознавая всей сложности таких инструментов.
Приглашаю присоединиться к увлекательному путешествию!
Содержание
-
Как создать свою СУБД с нуля и не сойти с ума. Практическое пособие некроманту. Часть первая (данная статья)
Предисловие
То, что я не могу создать, я не понимаю.
Ричард Фейнман, 1988 г.
Иногда мы слишком просто принимаем за должное то, что было создано кровью и потом прошлых поколений. Используем в качестве вызываемых библиотек в нашем скриптовом коде, используем в качестве хранилища бесчисленного множества заказов… И это нельзя считать за грех ровно до того момента, пока мы знаем, что мы делаем.
Но современная парадигма обучения программированию не всегда предполагает знание основополагающих принципов инструментов, с которыми мы работаем каждый день. И не было бы это проблемой, если бы не то последствие, которое вытекает из такого отношения — что бы вы думали о спортсмене-бегуне, который не знает, как работает его организм? «Ну бегаю и бегаю, что пристали-то?» — а в решающий момент, за день до соревнования, потянет ногу каким-нибудь глупым упражнением. «Кто же знал, что так получится…» — а вот мы постараемся такого не допустить.
Глава 0. Новое начало истории
Итак — мы немного отвлеклись — грянул Судный день. Его часы оттикали полночь и снова пошли по кругу. В новом мире постапокалипсиса не существует ни того количества дистрибутивов Линукса, ни новых версий Айфона каждый год — в общем, всего того разнообразия любимых (или не очень) плодов существовавшей ранее цивилизации.
Но давайте заранее условимся — мы всё-таки создаём учебный проект, а не по-настоящему симулируем (как бы это странно не звучало) новый, отчасти пугающий, мир. Поэтому, думаю, простительно, если мы всё же воспользуемся некоторыми библиотеками в тех местах, в которых мы не хотим излишне повышать сложность. У учёбы есть задача — научиться, научиться чему-то конкретному, и мы подробно разберём эти задачи в следующей главе про архитектуру базы данных. Если по пути воплощения в жизнь нашей СУБД возникнут задачи, которые не попадают в изначально установленные критерии, то будет разумно воспользоваться уже готовыми решениями.
Для начала хочу донести пару важных принципов, по которым была написана данная статья:
Структура статьи составлена таким образом, чтобы даже в мире постапокалипсиса по её тексту можно было воссоздать без особых других знаний эту СУБД. Пусть даже не с помощью языка Rust, а с помощью какого-нибудь новоизобретённого Rusty или C**.
Процесс создания SicQL продолжается и по сей день — сначала создавалась статья, а затем уже начались работы по воплощению СУБД, притом она, как и сама статья, неоднократно переписывалась в виду особенностей как меня, автора, так и Rust — прототипы за две недельки не напишешь. Так что советую заглянуть через пару месяцев в исходный код и увидеть если не бета-релиз, то хотя бы альфу, которую можно будет полноценно потыкать. И где-то там будет вторая часть статьи с ревизией положений, изложенных ниже, хе-хе.
Так как исследуемая тема настолько обширна, что даже отдельных статей на каждую часть архитектуры не хватит, то, во-первых, иные подходы к решению поставленных задач затрагиваются только мельком, во-вторых, многие полезные с точки зрения оптимизации вещи не реализованы даже в теории, а в-третьих, раздел используемых источников сконструирован таким образом, чтобы после прочтения данной статьи можно было бы более основательно углубиться в затронутые темы, что я и советую. В том числе, я ожидаю, что знающие люди в комментариях дополнительно расскажут про вышеперечисленные первый и второй пункты, что ещё более повысит ценность данного текста с точки зрения простора для размышлений и развития себя как разностороннего специалиста.
Ну что ж, мы зашли уже слишком далеко, чтобы останавливаться! В путь!
Глава 1. Архитектура. Noctua ex machina
Что-что делаем?
Начнём с определения абстрактного класса BasedTable
… Эй, погоди! А что делаем-то?
Базы данных — это круто, системы управления базами данных — ещё круче, реляционные системы… Ну вы поняли. Нельзя сказать «а давайте сделаем projectName
», а затем начать усердно писать код — с таким оголтелым подходом к разработке мы не то что наткнёмся на неведомые подводные камни, но и вообще не закончим проект. Поэтому немного угомоним свои высокие порывы инженерной души и взглянем на ситуацию со стороны.
Всякий инженерный проект — это решение конкретной задачи. В частности, поэтому так желанная серебряная пуля невозможна — не существует универсального решения. В конце концов, мы учим железку действовать определённым образом в определённом контексте. Осуществляется ли это условными конструкциями if … else
или определённым датасетом, который мы скармливаем нейронке, — не так важно. Важно то, что учителя, в итоге, — это мы. Поэтому от того, насколько мы сможем увидеть решение задачи заранее, и зависит успешность проекта.
Взгляните только на простого столяра, которому требуется смастерить табуретку — явно она не получается случайно в процессе изготовления — табуретка уже существует в голове мастера как законченный проект. Вот, в голове мы её крутим-вертим, разбиваем на составные части, прикидываем необходимые инструменты… И как-то в конце концов переносим нашу воображаемую табуретку в реальный мир. Магия!
Чем же отличается наш случай от столяра с табуреткой? Высокой степенью абстракций. Мы не ощущаем прямого контакта с данным произведением человеческого труда. Из этого и растут дальнейшие проблемы — непонятно даже за что ухватиться. Так что перед тем, как обучать машину решать задачу, давайте научимся решать её сами.
Перепридумываем перепридуманное
В сущности, задача стоит так — предоставить человеку относительно ясный и понятный интерфейс для работы с долгосрочным хранилищем данных. Во как — мысль наша пошла-поехала, начинаем разбивать задачу на подзадачи!
Какой самый удобный для человека, так скажем, визуальный интерфейс? Текст. Есть предположение, что человеку удобнее всего получать информацию из пиктограмм — но, во-первых, я такое даже и представить не могу (отсылаю к предыдущему параграфу), а во-вторых, нам нужно не только информацию извлекать, но и вносить. А как уж эти пиктограммы в компьютер заносить да обучить машину их считывать — это, извините, не ко мне, а к специалистам по машинному обучению. Остались они в постапокалиптическом мире или нет — ещё один хороший вопрос.
Отлично! Значит, взаимодействовать будем с помощью текста — тем самым нужен какой-то определённый язык… И тут в игру вступают ограничения, заданные изначально, то есть вроде наступивший постапокалипсис. Давайте условимся о том, что переизобретать SQL (к его сущности мы перейдём немного позже) мы не будем. Свою NoSQL базу данных сделаем как-нибудь в следующий раз.
Но так просто мы не отделаемся. Потому что под словами «переизобретение» в абзаце выше могут скрываться две вещи: переизобретение спецификации языка и переизобретение самого языка. Что это за странная игра слов? Смотрим ниже:
Допустим, в гипотетическом языке программирования под фирменным названием Language™ существуют две конструкции:
выражение + выражение?
выражение - выражение?
…где выражение
- число или одна из этих синтаксических конструкций, обособленных круглыми скобками ()
. Тут мы вступаем на одно очень интересное поле лингвистики, но не будем торопить события, всё успеем разобрать.
Замечательно — у нас на руках правила, которые задают синтаксис этого языка. Но синтаксис на то и синтаксис, что задаёт лишь правильное расположение лексем. Но как бы мы не ставили, хоть и правильно, слова в языке, он не может являться языком, пока он не будет что-то значить. Это называется семантикой языка.
Можно написать хоть сотни стандартов, описывающих синтаксис языка, но кто-то на этом языке должен говорить — собственно сама СУБД. Точнее сказать, вталдычивать программе о нужных нам записях в базе данных будем мы, но только таким образом, чтобы это понимала программа. Следовательно мы, авторы этой программы, должны каким-то магическим образом научить её понимать некоторое подмножество языка SQL. Именно это, я считаю, тоже можно считать за изобретение языка — хоть уже и по имеющимся стандартам, и всему прочему.
Дружим запрос с процессором
«Но кто-то на этом языке должен говорить…» Интересное заявление — мы начали одушевлять миллионы транзисторов на камне с работающей операционной системой на борту. Пока что он нам не отвечает — то, что мы ему пытаемся сказать, выходит за рамки его понимания. Для него всё бренное есть наличие или отсутствие напряжения на контактах.
Довольно пессимистично для начала, особо учитывая окружающую нас реальность. Но у нас давний друг, выручавший не одно поколение человечества — разум. И у этого разума есть занимательное свойство — способность к абстракции. Не важно, что мы имеем сейчас на руках — мы всегда можем придумать что-то лучше. Что-то, чем легче оперировать. А дальше дело будет за малым — пару миллионов электронных (где-то мы это уже видели) и химических сигналов, и абстрактное видение буквально осуществится с помощью наших ручек.
Но долой эту лирику — давайте строить мост между процессором и человеком с двух концов:
За какой конец взяться?
Конкретно разберём замеченную нами проблему: первое, мы не имеем представления, да и, желательно, не должны, о том, как процессор взаимодействует с данными, второе, мы не знаем, во что стоит переводить строку запроса.
Ответ кроется в разборе проблемы выше — чтобы знать, во что стоит перевести запрос, мы должны знать, как процессор работает с данными. Поэтому первый компонент нашей архитектуры должен быть тем, кто будет заботиться о правильном расположении данных в хранилище, то есть первым делом будем браться за бэкэнд нашей СУБД.
Что из имеющегося под рукой мы можем использовать под хранилище? Компьютер предоставляет нам две опции: храни данные либо в оперативной памяти, но до завершения своего процесса, либо в файловой системе, но надолго. Многие проблемы встанут перед нами только в случае хранения базы в файловой системе, поэтому условимся, что база будет представлять из себя одну большую последовательность записей в одном файле, который можно представить как массив в оперативной памяти, ведь мы хотим сохранить для пользователей такую возможность.
Далее — вот мы храним данные, но в каком формате? Записать белиберду — просто, а как её считать и преобразить обратно?
Сделать простое иногда во много раз сложнее, чем сложное, поэтому не будем изобретать велосипед, пока это нам напрямую не понадобится. «В лоб» задача решается следующим образом:
То есть в данный момент база представляет из себя один текстовый файл с последовательностью записей, предварённых заголовком с метаданными: какие поля есть в записях, какого они типа.
Из-за данного формата данных перед нами встали следующие проблемы:
Перед тем, как начинать операции с данными на диске, мы должны выгрузить их в оперативную память — иначе скорость работы будет неимоверно низкой. Но, допустим, нам нужна запись под номером 100500 — нам что, придётся загрузить все записи с 1-ой по 100500-ую? А если там хранятся гигабайты данных?
А как мы будем доставать 100500-ую запись? Как видно выше, каждая новая запись начинается на новой строке, то есть они разделяются символом переноса строки (в зависимости от платформы — CRLF/LF/…). Нам придётся считывать символ за символом, строку за строкой — звучит так же плохо, как и в предыдущим примере.
Текстовый формат данных — ну слишком жирно! Каждый символ (в том числе и пробелы, и переносы строк, и запятые) занимает 1 байт при кодировке UTF-8 или 2 байта при UTF-16. Звучит не так страшно? Представим 100000 записей всего лишь с одним знаковым целочисленным полем, хранящем максимальное число, которое можно уместить в 32 битах —
2147483647
. В UTF-8 это поле занимает 10 байт, а бинарный формат, то есть запись, как есть — 4 байта,0x7f 0xff 0xff 0xff
при шестнадцатеричном формате записи. 6 байтов разницы, плёвое дело? Ну-ну — в итоге вместо ~390 килобайт данная таблица будем занимать весь мегабайт! В два с половиной раза больше!
Как мы можем решить эти проблемы?
Мысль первая: почему бы не разделить этот файл — но не на отдельные файлы, а на сегменты, чей размер будет известен заранее?
Размер должен быть определён заранее и желательно не должен меняться. Если размер сегментов будет зависеть от количества строк (например, 100 записей в одном сегменте), то нам так же придётся считывать строку за строкой, пока не насчитываем достаточное количество для перевода страницы. Непорядок…Мысль вторая: а если мы отойдём от текстового формата данных и перейдём к бинарному?
Если принять оба этих решения, то мы получим следующий формат файла:
Примечание
Так как теперь у нас нет знаков CRLF/LF, которые бы указывали на начало новой записи, то нам нужно либо располагать записи на странице особым образом, либо перед началом поля данных хранить тип, некоторый байт, чьё значение бы указывало на то, сколько байтов, за ним следующих, считать, и что они значат (строка, номер записи, целое число, число с плавающей запятой...).
Здесь и далее будем рассматривать это всё достаточно концептуально — записи располагаются последовательно на странице. О том, как это на самом деле будет происходить, будем смотреть уже во второй части статьи по реализации всех наших задумок.
В СУБД такие сегменты обычно именуются страницами, но могут встречаться и другие названия. Поэтому будем называть их именно страницами.
Теперь у нас есть файл базы данных, разделённый на страницы одного размера. В нашей архитектуре должен появиться герой, который сможет разобраться в этом, который сможет предоставлять вверх по цепочке структуры СУБД эти страницы.
В английской литературе его зовут просто — pager. Пейджер, то есть страничник. Диспетчер, оператор страниц. Звучит во всех случаях глупо, но ради сохранения консистентности текста будем называть его оператором страниц.
Этот оператор запрашивает данные у файла ровно по странице — и это сделано не просто ради решения тех проблем, которые мы выявили ранее, а ещё ради облегчения дискового процесса ввода-вывода. Диск, в отличие от оперативной памяти (и то оперативная память ведёт себя лучше, когда запрашиваемые данные расположены рядом друг с другом в виду внутреннего строения), считывает данные не по байтам, а по огромным сегментам — и операционная система так же хранит эти данные в страницах. Зачастую размер такой страницы равняется 4096 байтам, то есть 4 килобайтам. Так что такую особенность нужно оседлать и использовать в своих нуждах.
Вообще современные СУБД усиленно следят за количеством операций ввода-вывода, так как это повышает как скорость работы, так и время жизни накопителя, особенно, если он твердотельный. В этом участвуют многоуровневые кэши внутри структуры, строение хранящихся данных… Но современные СУБД сгинули в пучине ядерного огня, так что времени философствовать у нас не так уж и много!
Как вы заметили в примере с упрощённым бинарным представлением файла, в странице под номером 0 расположены названия таблицы вместе с номером той страницы, на которой таблица начинается; а в самой странице, в конце, указывается номер следующей страницы, на которой следуют данные, которые не поместились в странице предыдущей.
Следите за руками — если одной страницы не хватает для расположения всех данных таблицы, то мы берём любую попавшуюся под руку свободную страницу (пусть она даже не будет последующей!), вписываем указатель в последнюю страницу таблицы, продолжаем записывать данные в новую страницу.
Немного отойдём от темы. Мне трудно было уместить в голову понятие указателей в бинарном файле — поэтому объясню в том числе и для читателей: указатель на страницу — это её индекс, смещение от начала файла. В том числе и указатель на байт в бинарном файле — это его смещение от начала файла.
Если вы уже знакомы с понятием указателей в оперативной памяти, то там адрес всегда указывает на одно и то же место в этой памяти. Если представить оперативную память как один большой файл, то указатель в ней практически то же самое, что и указатель в файле. Простое смещение от начала структуры…
«Берём любую попавшуюся под руку» — хе-хе, а руки-то вот они, не уследили! Такие вещи кто-то должен делать — но точно не оператор страниц. У него цель одна — паковать данные в страницы. Что там дальше — это, извините, не его ответственность. Пусть мир перевернётся уже во второй раз — но внутрь страницы не заглянет!
Значит нам нужен ещё один оператор… Если страницы соединить в продиктованном указателями порядке, то мы получим таблицу. Значит это… оператор таблиц! Как всё легко, оказывается!
Оператор таблиц уже может заглядывать внутрь страницы — он может увидеть указатели, может увидеть, наконец, уже данные наших записей. Иными словами, оператор таблиц предоставляет вверх по структуре СУБД атомарные, то есть минимально возможные, инструкции над этими данными. Конечно, нам придётся поработать с тем, как эффективно хранить эти указатели, но, если особо не всматриваться, архитектура бэкэнда завершена. Внимательные читатели могли заметить, что мы упустили несколько важных вещей — но мы к ним вернёмся, не переживайте.
Значит, приступим к созданию нашего фронтэнда — того, что будет понимать повеления пользователя и трансформировать их во внятные для бэкэнда инструкции.
На данном уровне абстракции запрос от пользователя представляет из себя строку. Как одну целую строку переделать в набор инструкций, да так, чтобы ещё и пользователь понял, что он сделал не так?
Если вы чутка знакомы с тем, как работают компиляторы и интерпретаторы языков программирования, то вы уже знаете ответ. В общем, подход такой: создать из строки промежуточные представления, на каждом из этих представлений производить как валидацию данного уровня анализа, так и подготавливать данное представление для следующего уровня.
Какие уровни анализа текста мы знаем из школьного курса русского языка?
Лексический анализ: на данном уровне предложение представляет из себя набор лексем, то есть характеристик слова на основе его происхождения, назначения, употребления в речи… Также могут включаться и отношения данного слова с другими в предложении, но для нас это уже перебор. Строка после проведения этого анализа переводится в массив лексем, которые точно присутствуют в языке SQL. Если пользователь ввёл SELECT …
, то такая лексема существует, и мы передаём её дальше. Если же, по чистой случайности, в строке находится SLECT …
, то ни о каком правильном запросе не может идти и речи, мы всё-таки просматриваем всю строку, и отдаём массив ошибок обратно пользователю. Почему не остановиться на первой ошибке? Да вы только представьте адские муки того, кто допустил множество ошибок в запросе на несколько строк! Переделывать запрос и опять скармливать СУБД… User experience first, как говорится.
Синтаксический анализ: предложение есть взаимосвязанный набор слов, представляющий из себя определённую структуру. Анализ данного уровня должен выявить эту структуру, и найти ошибки, если структура не удовлетворяет языку. Чаще всего эта структура называется абстрактным синтаксическим деревом, о чём мы поговорим в отдельной главе. Мы получаем набор лексем, не заботимся о том, правильные ли это слова (практически, так как всю такую грязную работу совершили на прошлом этапе), и выстраиваем отражающую взаимоотношения слов структуру.
Семантический анализ: слова, структуры слов, это понятно — но предложение ведь должно нести какой-то смысл. Так как SQL это своего рода приказы — «возьми, обнови, вставь, создай», то и осмысливать это нужно как приказы — перед тем, как исполнить, стоит подумать о том, возможно ли вообще их исполнение. Если пользователь пытается получить несуществующее поле из таблицы, то он должен быть об этом предупреждён, а дальнейшая обработка запроса должна быть остановлена.
Дальше логика начинает расходиться, поэтому продолжим мыслить только в понятиях SQL. Этот язык в своей логике опирается на реляционную алгебру. Операции в этой алгебре представляют собой, если говорить упрощённо, навороченные логические операции. Множества, связи, перемножения, различные проекции… Что если семантически верное синтаксическое дерево (простите!) перевести в операции реляционной алгебры, раз уж в конце концов всё к ней и сводится? Таким образом мы облегчим сложность строения бэкэнда нашей СУБД, обеспечим будущую расширяемость функциональности — реляционная алгебра проще по строению, чем ветвистые и глубокие синтаксические деревья.
Произведя перевод всего запроса в реляционную алгебру, мы получили определённый план выполнения. Но кто сказал, что пользователь знает, как правильно составить план? Вдруг он не знает о существовании чего-то, что облегчило бы выполнение этого плана?
Данный этап называется оптимизацией запроса. В СУБД канувшей в лету цивилизации такая информация являлась, в большинстве своём, коммерческой тайной. Хранилась под строжайшим запретом, под семью замками и за пятиметровым забором из колючей проволоки. Возможно где-то тут кроется причина падения той цивилизации, но я, честно говоря, уже и не помню, что тогда было.
Но, в любом случае, делать оптимизацию мы не будем. Мы подготовили для себя фундамент, на котором можно будет легко, хе-хе, эту оптимизацию прикрутить. Но не сегодня — и так много чего весёлого впереди!
Мы оформили фронтэнд и бэкэнд. Компилятор предоставляет в бэкэнд план, записанный в формате реляционной алгебры, а оператор таблиц предоставляет, собственно, таблицы с записями в них, скрывая все страшные подробности хранения данных.
Но что-то не сходится… С одной стороны расположен план, а с другой — атомарные инструкции. Как будто здесь не хватает ещё одного компонента системы (если вы ещё не заметили это из картинки выше), который бы мог бы отработать по сгенерированному плану…
То, что мы упустили из анализа ранее, — и есть ключевой компонент СУБД, её сердце. Движок. Он-то и будет по высокоуровневым инструкциям (плану) производить вычисления над записями, которые предоставляет оператор таблиц.
Но здесь кроется ещё одна проблема — операции реляционной алгебры не так-то просты, они не всегда могут располагаться последовательно по смыслу, а, значит, движку придётся проводить предварительный анализ плана, что тоже увеличивает сложность компонента системы. Что же делать?
Решение этой проблемы кроется в ещё одном (боже мой!) анализе запроса. Теперь точно последнем. План, представляющий собой реляционную алгебру, можно перевести в последовательную цепочку инструкций, которую движок прочитает и выполнит так же последовательно.
Такой компонент уже можно называть не просто движком, а целой виртуальной машиной, со своим набором инструкций, регистрами и другими страшными вещами. Итого в строение компилятора (не просто так же он называется компилятором!) мы добавляем генератор кода для виртуальной машины, и этот код будет тем минимальным уровнем инструкций, которые может произвести ввод пользователя.
Мы смогли сохранить простоту каждого компонента нашей системы, не усложняя при этом общую цепочку взаимодействий — step by step, шаг за шагом запрос преодолевает проверки и преобразования, в конце концов добираясь до тех самых данных, ради которых и была затеяна вся эта история.
Скрываем реализацию — кто, что и кому должен?
Мы проделали долгий путь по общему анализу «подкапотной» нашей СУБД, с целью сохранения как простоты, так и концептуального содержания, чтобы не отбросить учебные (и немножко покрупнее — у нас всё-таки постапокалипсис за бортом) цели в угоду создания собственных велосипедов!
Но это не конец пути, а только начало. Поэтому сделаем привал, и посмотрим на то, что мы в итоге придумали — со всеми подробностями о том, какой компонент кому и что должен передавать по цепочке вызовов.
Каждый из компонентов скрывает свою часть реализации СУБД: компилятору не очень важно то, как данные хранятся на диске, как организованы соединения. У него есть своя часть работы — и он её должен выполнять, чтобы об этом не заботились нижележащие части нашей системы.
После общего анализа, разумеется, мы приступим к рассмотрению каждого компонента отдельно, рассмотрим подводные камни, которые мы не видели сверху, погрузимся в контекст — и, наконец-то, наломаем дров!
Глава 2. Транзакции, или как остаться без копейки в кармане
Кислотный ACID
СУБД должна поддерживать несколько подключений к одной и той же базе данных. Несколько процессов, приложений, или несколько потоков в одном процессе должны иметь возможность взаимодействовать с одним файлом базы данных.
И тут начинает происходить какой-то неведомый ужас. Мы еле-еле сообразили, что делать с одним подключением, а тут уже появляются несколько. Наши мозги начинают потихоньку плавиться — возможно это влияние той радиоактивной кислоты с потолка. Хм, кислота…
Нам пришла ещё одна помощь из прошлого мира — это ACID. Странная аббревиатура, с моей точки зрения, так как переводится как «кислотный». Возможно, что она так называется потому, что СУБД должна работать, даже если случится кислотный дождь — что я имею в виду под этой аналогией? Рассмотрим, собственно, сами принципы, скрывающиеся за этими словами, правда, не очень последовательно:
I, Isolation — несколько транзакций могут происходить одновременно, и должны это делать без вмешательства одна в другую.
Когда-нибудь это закончится, и страшные новые слова перестанут появляться — но, видимо, даже ядерная война этому не поспособствует. Транзакции — это что такое?
Вот одно соединение может выполнить один запрос. И точно понятно, что никто иной в этот запрос не должен вмешиваться. Ну, по крайней мере, желательно. Тогда данный запрос можно рассматривать как отдельную транзакцию — но что, если мы несколько расширим границы невмешательства с одного запроса на несколько? Мы предварительно скажем СУБД: «Вот слушай, сейчас я начну транзакцию, и я хочу, чтобы запросы внутри неё представляли нечто единое и неразделимое». Сделаем все наши грязные дела с данными, а потом завершим транзакцию. И нас вообще не беспокоит, что во время выполнения этой транзакции делают другие приложения — дай мне, наконец, уже мои данные, база!
Вот и транзакция — это несколько запросов, которые должны с точки зрения СУБД представлять нечто единое. Это понятие поможет нам в будущем.
A, Atomicity — либо да, либо нет: транзакция или произошла, или была отменена, и все изменения, которые были произведены этой транзакцией, не произошли. Довольно простое понятие с точки зрения пользователя, так как «ну а как что-то может произойти наполовину?», но разработчики СУБД уже не одно поколение ломают головы о том, как это можно поддерживать. Но кто думает о разработчиках?.. Разберутся!
На то транзакция — это набор единых запросов и команд, которые не могут произойти только на половину. Если СУБД сказала, что транзакция исполнена — значит должна быть исполнена! Ну, или отменена, об этом тоже стоит помнить.
Вот, кстати, почему СУБД называется SicQL — от латинского слова sic! — что сказано, то сказано — и сделано! А то, что оно там связано с совами да сычами, — это вы надумали, ничего такого, хе-хе.
C, Consistency — база данных должна остаться согласованной, или хотя бы целостной (и это два разных понятия, разберём чуть ниже), до и после заверения транзакции. Здесь мы вводим понятия «заверения» транзакции, от английского commit (в контексте СУБД получается именно заверение, хотя перевести можно и иначе). Мы просим базу данных заверить выполнение транзакции — и или принять её, или отклонить. Но это разговор про Atomicity, здесь же вводится требование, что база данных не только должна поддерживать атомарность, но и согласованность данных.
Примечание
По слову commit кстати есть интересная история про наименования commit messages в системе контроля версий Git. Идут споры о том, в каком времени писать сообщения – в прошедшем, допустим, Added smth, или в настоящем, Add smth. Традиционным считается второй подход, хотя более логичным звучит первый. Бытует мнение, что вот, создатель Git, Linux и просто хороший человек Линус Торвальдс плохо владел английским, и поэтому сообщения к коммитам писал с ошибкой. Но это не так. Commit есть глагол «заверь», «подтверди», и сообщение должно правильно расшифровываться как «добавь что-то», а не «добавил...».
D, Durability — устойчивость. СУБД заверила транзакцию, но, вот те на, сервер потух. И устойчивая СУБД должна гарантировать то, что заверенная транзакция не будет потеряна даже в том случае, если случится апокалипсис, или, в локальном случае, если операционная система отдаст концы.
Но всё это чистая теория. Разрыв между практикой и теорией на практике в стократ больше, чем в теории. Посмотрим же на чистую практику.
Первая встреча с монстром конкуренции
Представим следующую ситуацию: процессы A и B имеют открытое соединение с одной и той же базой данных, в которой записан баланс кошелька определённого человека.
Процесс A хочет перевести на другой кошелёк 100 рублей. Он считывает данные из записи и получает 100 рублей — отлично, значит можно перевести — организовываем соединение с другим кошельком…
Как вдруг в эту ситуацию вмешивается процесс B. Он тоже хочет перевести на другой счёт 100 рублей. Считывает текущий баланс, получает 100 рублей — начинает производить бизнес-логику, в конце концов записав обратно баланс в 0 рублей.
Но вот процесс А организовал соединение, произвёл все вычисления и даёт задание СУБД записать баланс в те же 0 рублей. «Ну что, процесс — не дурак, делаем, как сказано», — и что же мы получаем в итоге?
Было 100 рублей. Перевели 200. Получили баланс в 0 рублей. Некрасиво получилось…
Мы столкнулись с аномалией — то есть с таким поведением, при котором вроде бы верные с точки зрения одной транзакции изменения при выполнении вместе с другой транзакцией приводят к нарушению согласованности данных.
Что такое согласованные данные — вопрос хороший, так как в рамках СУБД мы определить это не можем. Целостность данных — можем (например, что данные в ячейке не должны равняться NULL), так как понятие целостности полностью лежит в поле управления СУБД. Согласованность данных же определяется приложением, пользующимся СУБД.
Оставлять это так, как сейчас, нельзя — мы переносим заботу о правильном порядке выполнения транзакций на пользователя — а что это тогда за СУБД, которая не может (или не хочет) исполнять контракт по слежению за данными? Я даю СУБД данные, а она заставляет меня ещё и следить, чтобы они случайно не перемешались?
Нужно искать решение. И оно есть — в самом SQL.
Кладём болт на аномалии в реальном времени
Стандартом SQL (SQL92) определяются некоторые из известных аномалий, которые могут происходить в рамках одной транзакции: потерянное обновление (с которым мы столкнулись выше), грязное чтение (получение данных из ещё не заверенной транзакции), неповторяющееся чтение (получение данных из заверенной транзакции), фантомное чтение (получение новых записей из заверенной транзакции). Ещё есть известные, но не описанные стандартом аномалии, и неизвестные, то есть ещё не обнаруженные, аномалии. Но мы усложнять ситуацию ещё крепче не будем, так что примем за правило, что действия СУБД при других аномалиях — это неописанное поведение (unspecified behaviour).
Далее стандарт определяет степени изоляции транзакций, каждая из которых описывает список тех аномалий, которые могут произойти:
-
Read uncommited:
грязное чтение,
-
Read commited:
неповторяющееся чтение,
-
Repeateble read:
фантомное чтение,
-
Serializable:
полное отсутствие аномалий!
Каждая изоляция как бы «наслаивается» одна над другой, допуская всё больше и больше аномалий.
А вот Serializable — это отдельная история: СУБД должна заверить приложение, что транзакции, выполненные параллельно, выполнятся и повлияют на данные так же, как если бы они выполнялись последовательно. Про это сразу забываем — это сладкие мечты параллельного вычисления. Но…
Может быть сможем?
Или нет? Вдруг самая сладкая мечта — отсутствие семантических багов и обеспечение согласованности — лежит прямо у нас под носом?
Давайте попробуем придумать такую систему параллельного вычисления, которая позволит исполнить требования Serializable.
Представим два соединения к базе данных, X и Y. База данных состоит из трёх страниц — мастер-страницы и двух страниц таблицы T.
X начинает транзакцию чтения, читает страницу 2. Y начинает транзакцию по записи новых данных в страницу 2, СУБД заверяет эту транзакцию. Для удовлетворения ограничений по изоляции транзакций новое чтение страницы 2 транзакцией в X должно выдать те же данные, что и при первом чтении.
Значит нам нужно хранить как неизменённые данные, так и изменённые, при том условии, что заверение изменённых данных не должно перезаписывать неизменённые данные до того момента, пока не будут заверены транзакции по чтению неизменённых данных.
Ох, вот это да! Вот это условие… Хотя подождите — ведь мы уже всё нужное описали! Стоит просто накинуть на неизменённые данные и изменённые данные страшные и непонятные названия, как мы получим вполне рабочее решение, которое нужно просто обличить в код.
Write-ahead log — это упреждающий журнал. Страница ещё не была записана в файл базы данных, но СУБД заверяет приложение, что все соединения, начавшие транзакцию после заверения данной транзакции, получат новые данные, в то время как другие работающие транзакции этих изменений не увидят.
Но мы видим ситуацию с точки зрения транзакции записи. Как же она выглядит с точки зрения другой транзакции?
Представим, что мы совершаем транзакцию чтения. Сначала нужно посмотреть журнал — нет ли там обновлённой страницы, записанной заверенной транзакцией?
Однако здесь кроется две детали:
Деталь первая. А что если в журнал после открытия транзакции занесли ещё одну, ту же самую страницу?
Деталь вторая. Как считывать этот журнал? Он может доходить до нескольких мегабайт, что может заметно ухудшить производительность соединения.
Смотрим в определение такого простого слова как журнал: «книга, тетрадь или электронный ресурс для регулярной, периодической записи наблюдений, фактов и т. п.» Периодической записи — то есть записи наслаиваются друг на друга. Если мы запомним на время открытия транзакции определённую позицию в журнале, ниже которой данные для нас будут являться заверенными, а данные выше мы считывать не будем — то мы обеспечим ту самую Serializable изоляцию транзакций, позволив одновременное существование открытых транзакций записи и чтения. И пусть в журнале существуют несколько версий одной и той же страницы, читать мы будем только ту, которая находится ближе всего к нашей метке позиции.
Какая же красота! Как лаконично и понятно — однако стойте, а что же делать с двумя транзакциями записи?
Снова обращаемся к определению журнала — раз записи наслаиваются друг на друга, то представьте себе обычную человеческую очередь… за колбасой, к примеру. Вкусной! Я вас в этом заверяю, хе-хе.
Вот как два человека могут стоять в одном и том же месте в очереди? Да они же передерутся, глотки вырвут, но первое место в очереди займут! Если одновременно обслуживать двоих людей за колбасой, хранящейся на одном складе, то я точно уверен, что кому-то из этих двух несчастных колбасы не достанется. Что там дальше за мордобой будет — я даже не представляю.
Поэтому журнал будет доступен только одной транзакции записи — следующим придётся подождать того момента, как предыдущая транзакция разблокирует упреждающий журнал.
А чтобы посмотреть количество колбасы на складе — да ради бога, смотрите, сколько хотите — хоть один, хоть два, хоть миллион раз одновременно. Можно даже смотреть количество колбасы по тем моментам, как кто-то её купил. А там наверно ещё и хлебушек есть…
А колбасой-то придётся делиться!
Ну что ж — после таких примеров обязательно нужно подкрепиться. Да, простите, не удержался, больше такого не будет. Я вас завер… Всё-всё, закончили!
С первой деталью всё решили. Осталась вторая — как считывать журнал эффективно?
Есть давняя история о том, как хранить данные о данных — индексы.
Однако правильно создать индекс может только транзакция записи — ведь только она видит журнал в последнем состоянии, отображающем истинное состояние всей базы данных.
Но этот индекс могут считывать и транзакции чтения — неужто мы сбросим весь наш прогресс и начнём сначала?
Спокойно, товарищи, — нужно сделать всего одну простую вещь. Сделать индекс обратно совместимым. Просим индекс узнать — под каким номером в журнале хранится запрашиваемая страница, да такая, чтобы она была меньше хранимого в метке значения? Либо мы найдём значение и отправимся читать страницу из журнала, либо не найдём. Ну-с, бывает — вперёд в файл базы данных!
Так как операция чтения будет совершаться при каждом запросе страницы, то алгоритмическая сложность, то есть «о-большое», должна быть минимальной. Алгоритмическая сложность вставки элементов в этот индекс, конечно, тоже должна быть поменьше, но если будет стоять выбор между меньшей сложностью считывания, но большей сложностью вставки, и большей сложностью считывания, но меньшей — вставки, то выбор должен пасть на первое.
Понятие алгоритмической сложности для тех, кто с ней не знаком, хоть и кроется под ореолом тайного и проклятого знания, но кто запрещает нам не вдаваться в подробности? Алгоритмическая сложность «о-большое» представляет из себя скорость роста времени выполнения алгоритма от увеличения количества элементов, к которому применяется этот самый алгоритм, в худшем случае (для обозначения лучшего случая есть нотация «омега-большое»). — скорость линейна: если количество элементов выросло в тысячу раз, то во столько же раз вырастет время выполнения алгоритма. Если на одном элементе скорость равняется 1 мс, то на двух элементах — 2 мс, на тысяче элементов — 1 с. И та же логика на всех других зависимостях.
Но где и каким образом будет храниться этот индекс?
Нам нужно сообщаться с другими процессами — а как можно обратиться к чужой памяти? Эту вещь контролирует операционная система, и даже если мы попытаемся хоть как-нибудь продвинуться в этом направлении самостоятельно, то наш миленький процесс СУБД повесится, оставив предсмертную записку Segmentation fault (core dumped)
.
Нужен иной подход — и его предоставляет та же операционная система. Это отображение файла в память. Вдаваться в подробности того, как оно работает под капотом, мы не будем, условимся на том, что это доступная для чтения и записи нескольким процессам память.
Далее начинаем задавать требования для структуры данных: возможность предоставлять по двум значениям — номеру страницы и маркеру транзакции — номер страницы в упреждающем журнале. Так как в журнале может существовать несколько версий одной и той же страницы, но в разных транзакциях, то индексировать мы будем номера страниц.
Нам нужен массив — и здесь есть повод для раздумий. На данный момент предполагается наиглупейший способ хранения информации о транзакции — этот массив будет длиной в лимит страниц для упреждающего журнала. Этот массив будет содержать номера страниц так, как они содержатся в журнале — если в журнале страница 5 находится на втором месте, то и в массиве в элементе под индексом 2 будет лежать число 5.
Таким образом, поиск в таком индексе будет организован через простой алгоритм: прыгаем на элемент с индексом, равному маркеру транзакции, считываем влево, пока не найдём либо номер нужной нам страницы, либо начало массива — что значит, что в журнале нужной нам страницы нет.
Также я предполагаю, что в будущем мы сможем это оптимизировать с помощью хэш-таблиц с открытой адресацией — отдадим это на откуп будущему мне и читателю. Пока что алгоритмическая сложность поиска в таком индексе линейна и зависит от лимита страниц в журнале, что сильно лимит этот ограничивает.
Пылесос? Ещё и автоматический??? Дайте два!
Со всеми этими дополнительными файлами, журналами и индексами жить интересней и приятней, да вот возникает один вопрос — а что с ними делать-то в конце концов? Вот они у нас хранятся, собирают данные. Но СУБД данные не только сохраняет — но и удаляет. А вот о том, как от этих файлов избавиться — точнее сказать, как эти файлы очистить — мы не очень подумали.
Ведь если упреждающий журнал с его индексом не чистить, то вскоре нам понадобится уже индекс для индекса, хе-хе.
Сначала узнаем, как удаляются записи: для того, чтобы особо не нагружать ввод-вывод, мы просто будем указывать в начале записи, жива она или нет. Но и здесь встаёт та же проблема — пометили запись как удалённую, а кто за нами чистить будет?
Но и тут на амбразуру неведомой сложности кинулся ещё один герой — пылесос. Да-да, именно так переводится ключевое слово VACUUM
, которое вызывает операцию по очистке файла базы данных от тех записей, которые были помечены к удалению. Операция дорогая — и требует просмотра каждой записи в базе данных. Алгоритм, в общем, простой: открыли таблицу, поехали смотреть запись за записью: запись мёртвая? — удалить, запомнить расположение, искать следующую запись. И если она живая — сдвинуть на то место, которое мы запомнили на предыдущем шаге. Делаем до упора, до конца таблицы — а затем удаляем страницы, которые оказались вовсе пусты.
Всё это дело требует некоторой сноровки и инструментов оператора таблиц, которым мы займёмся в следующей главе. Плюс желательно было бы гарантировать, что мы случайно файл не сломаем своими кривыми ручками, поэтому базу данных скопируем и все операции будем выполнять уже на ней. Операция стала ещё дороже — но мы же не будем пытаться запихнуть в эту СУБД 200 терабайт данных? Правда ведь?..
Это был наш первый пылесос, который будет заниматься очисткой файла базы данных. У второго пылесоса задача другая, но не менее важная — очистка упреждающего журнала. Здесь мы мудрить не будем — мы просто перенесём все страницы из журнала на их положенное место в основном файле. Когда это должно происходить? Зачастую пользователю об этом задумываться не стоит, поэтому поставим автоматическую очистку на отметке в 100 страниц в журнале — вспоминаем, как устроен наш индекс журнала. Если транзакция записи обнаруживает, что отметка достигнута — блокирует файл, дабы никто не смог ничего в этот момент с файлом сотворить, а затем очищает упреждающий журнал. Или это сделает последнее закрывшееся соединение.
Ну что ж, наконец перейдём к самому интересному — тому, как эти страницы организованы между собой, как устроены записи, — к оператору таблиц!
Глава 3. Деревья. Сбалансированные. Страшно
Слово дерево и слово смерть для вас означает одно и то же!
Как мы помним из первой главы, страницы скованны одной цепью связанны однонаправленным списком — каждая предыдущая страница в таблице ссылается на следующую. Но нужно внести некоторый порядок в происходящее.
Во-первых, напоминаю, в одной базе данных может существовать не одна таблица, значит и таких списков должно быть несколько — за ними придётся следить. Под это дело лучше всего подойдёт первая страница в файле, будем называть её мастер-страницей (или мастер таблицей, если информация не помещается только на первой странице) — в ней будут, как в обычной таблице, храниться названия таблиц, их колонок, типы колонок и прочая информация, которая позволит легче ориентироваться в файле (и выполнять некоторые условия по целостности данных) — но, самое главное, будут храниться номера первых страниц таблиц. Или, лучше сказать, входных страниц, корней таблиц.
У вас начинают прокрадываться мысли, к чему я веду. Когда мы разделяли файл базы данных на сегменты, мы делали это из побуждений облегчить поиск записи — но такое последствие вытекает не сразу. Чтобы знать, к какой странице обращаться за определённой записью, мы должны построить индекс таблицы.
В чём заключается сущность индекса? Мы в неструктурированную информацию вносим дополнительный смысл, который позволяет уменьшить время поиска по индексированному ключу.
Как это выглядит на практике: у нас есть 100 записей на двух страницах и дополнительная структура данных, которая сообщает нам, что записи с 1-ую по 50-ую находятся на первой странице, а с 50-ой по 100-ую — на второй странице. Если бы нам была нужна 100-ая запись, то без такой структуры нам бы пришлось прочитать каждую запись, а с ней — только половину (затратив время дополнительно только на чтение данных из такой структуры).
Конечно, пример взят с потолка, но перед нами теперь ясно вырисовывается проблема — какую структуру данных применить для индекса?
Есть такая страшная вещь как деревья. Я даже слышал, что ими пугают первокурсников непослушных детей. Но ничего они не страшные — их просто до кучи много. Но разве это не играет нам на руку?
Хрестоматийный пример — бинарные деревья поиска. Допустим, мы имеем массив чисел [1, 2, 3, 4, 5, 6, 7]
. Если для поиска мы будем перебирать каждый элемент массива, то наш алгоритм поиска номера элемента в массиве будет иметь сложность.Но что если мы организуем индекс с помощью бинарного дерева поиска?
Допустим, нам нужно узнать местоположение числа 5 в массиве. Число 5 больше или меньше, чем число 4? Больше, значит идём по правой ветви. Число больше или меньше 6? Меньше, значит идём по левой ветви. Число равно 5? Равно — и его индекс в массиве равняется 4 (удивительно!).
Поиск, организованный с помощью бинарного дерева оказался быстрее — но постоянна ли его алгоритмическая сложность? Вдруг кто-то поиграется с нашей структурой, и вместо нормального дерева мы получим следующее:
Однонаправленный список! Хотя мы можем проводить те же операции сравнения, но от бинарного дерева здесь ничего не осталось. Дерево выродилось, а итоговая скорость поиска только уменьшилась, ведь мы не получаем бонусов к скорости ни от индекса, ни от самой структуры массива, которая позволяет практически бесплатно обращаться к случайному его элементу.
Вырождение дерева — это плохо. Поэтому умные люди задолго до нас придумали иные подходы к деревьям поиска, которые сводили бы к нулю возможность их вырождения. Сегодня мы ознакомимся с одним из видов сбалансированных деревьев как одним из способов хранения индексов — сидеревьями.
Дубы, ели, доколе?!
Заглянув в Википедию, вы увидите только много-много терминов, которые вроде бы описывают свойства и алгоритмы cбалансированных деревьев. Ох, совсем забыл, постапокалипсис… Так что надеваем противогазы и вперёд на поверхность, бороться со сложностью!
Кардинальное различие междуидеревьями (для упрощения в дальнейшем будем называть их просто сбалансированными) заключается в том, что данные в последних хранятся только в листьях, а также зачастую листья связаны с помощью списка. Забегая вперёд, хочу предупредить, что зачастую для индексов используютсядеревья, но чтобы и далее не забивать мозг информацией, мы остановимся только на созданиидеревьев.
Если представить запись в формате ключ-значение, то ключом будет являться некоторый уникальный номер записи, а значением — все оставшиеся колонки записи.
Сущностьдеревьев в нашем случае заключается в следующих вещах:
Два вида узлов: внутренние узлы и листья. Внутренние узлы хранят только ключи и указатели на нижележащие узлы, т. е. потомков. Каждый из потомков делегирует свой максимальный ключ, за исключением крайнего правого потомка. Листья содержат как ключи, так и значения по этим ключам.
Указатели и интервалы ключей. У внутреннего узла естьключей ипотомков. Крайний левый указатель ссылается на потомка, который является корнем для поддерева, хранящего в себе ключи в интервале. Крайний правый указатель ссылается на поддерево с ключами в интервале. Иной указатель, под номером, для которого верно неравенство, ссылаются на поддерево с ключами в интервале.
Однонаправленный список из листьев. Так как ключи в листьях расположены упорядоченно, то для более лёгкого перебора всех ключей в дереве каждый лист, содержащий ключи, имеет ссылку на лист, содержащий ключи, либо не имеет ссылку, если этот лист конечный.
Сильная ветвистость дерева и постоянная высота. Выше мы говорили про какое-то энное число. Весь смысл cбалансированных деревьев заключается в том, что в него встроены алгоритмы, которые позволяют отложить вырождение до смерти Вселенной, а также вместить большое количество ключей на одном узле. Знаете, в чём оно нам поможет? В том, что мы снизим количество обращений к диску во время обхода дерева при поиске! Но такое числодолжно быть действительно большим — и его верхняя граница будет задана всем свободным местом на странице. Для того, чтобы свободное место на странице было легко отслеживать, то при каждом добавлении записи мы будем высчитывать свободное место на странице и хранить его в заголовке страницы. Нижняя граница будет равняться двум, чтобы из дерева не получился однонаправленный список.
Что ж…
Какой-то ужас. Пиктограммы лучше текста, говорите? Ну тогда смотрим визуализацию свойств:
Понятно, что сбалансированное дерево за просто так ничего делать не будет. Будем его учить жить и не вырождаться.
Переворачивать не придётся. Придётся дробить
И опять какая-то чужеродная сила вмешалась в наш чистый мир деревьев — все наши узлы оказались под завязку забиты, а мы захотели внести новую запись. Как мы поступим?
Для начала заходим в тот лист, в котором должна храниться новая запись. Если свободного места на странице меньше, чем размер новой записи, то мы начинаем творить магию — запрашиваем у оператора страниц новую страницу, делим записи в текущей странице пополам — раскидываем половинки записей на обе страницы. Вписываем индекс новой страницы в старую, а в новую страницу вписываем указатель старой. Теперь в листах есть свободное место, но ключи во внутренних узлах ещё не обновлены.
Здесь есть общий подход и один пограничный случай — начнём с последнего. Если же мы пытались добавить новую запись в последнего потомка узла, то нам нужно только добавить в предка ещё один ключ, взяв максимальный ключ от старого потомка, и ещё один указатель — на новую страницу. В ином случае нам придётся как добавлять ключ с указателем на новую страницу, так и обновлять старый ключ.
Но вот ещё один пограничный случай — а что если делегировать ключи и указатель некому, так как мы находимся в корне дерева, который является в том числе ещё и внутренним узлом?
Тогда придётся запрашивать у оператора страниц ещё одну новую страницу, которая станет новым корнем. Здесь как придётся обновлять указатель на корень уже в мастер-таблице, которая по сути тоже является сбалансированным деревом, так и переносить в новую страницу ключи с указателями — и эта цепная реакция может спровоцировать очень, очень долгую операцию по балансировке дерева. Но такова плата за постоянную алгоритмическую сложность по поиску...
Кстати об алгоритмической сложности — какова она в нашем случае? Так как для того, чтобы выйти на нужную запись, мы должны совершить следующее:
Зайти в корень, сравнить искомый ключ с первым хранящемся: если он меньше или равен ему, то идём по указателю с таким же номером, что и у ключа; если он больше, то смотрим: остались ли ещё ключи в узле? Если да, то проверяем со следующим ключом. Если нет, то идём по последнему указателю.
Зайти во внутренний узел, проделываем ту же самую операцию со сравнениями.
Как зашли в лист, начинаем прогонять искомый ключ со всеми ключами в узле.
Итого, мы имеем алгоритмическую сложность поиска нужной страницы в, где— высота дерева от корня до листьев, а— минимальное количество ветвей в каком-либо из узлов.
Хоть это заняло больше времени, чем простое хранение однонаправленного списка, мы выиграли практически во всём, воспользовавшись сбалансированными деревьями. В том числе с их помощью мы сможем реализовывать ограничения на какие-либо поля, однако данная тема выходит за рамки этой части.
Как в итоге сделать просто
В итоге оператор таблиц предоставляет следующие возможности для СУБД: создать таблицу, удалить таблицу, найти значение по ключу, вставить пару ключ-значение, пометить пару к удалению (настоящее удаление пары и поддержку VACUUM
мы реализуем в следующий раз), выдать все записи таблицы в порядке возрастания.
Вот я бы хотел заострить внимание на последнем — про выдачу всех записей таблицы. Как-то неудобно получится, если мы будем переносить заботу о задаче по работе с данными на вышестоящие компоненты архитектуры. Нужно снова выдумать некую абстракцию, которая облегчит работу, а затем просто её поддерживать иными, не важными для вышестоящих компонентов, способами.
Я надеюсь, что вы знакомы с понятием итератора. Если же нет, то не беда — это инструмент, который не вываливает на нас все страшные подробности хранящихся данных во всей их полноте благодаря тому, что просто предоставляет только одну запись за раз. Мы можем двигаться вперёд или назад, сбрасывать курсор, доставать запись, на которую курсор указывает.
Данный инструмент позволит облегчить следующую часть нашей архитектуры — компилятор.
Глава 4. Профессиональный переводчик на совиный — как довериться пользователю и не сломать базу
Словодробилка
На вход мы получаем строку и только. Значит мы должны пройтись по всей строке, символ за символом, и на выходе отдать массив лексем, или токенов.
У нас есть определённые зарезервированные слова, по типу SELECT
или UPDATE
, или зарезервированные символы (например, оператор неравенства !=
), которые нельзя будет использовать в качестве идентификаторов, то есть названий колонок и таблиц. Поэтому, если мы встретим такие слова, то сможем без раздумий скушать их и превратить в токены ключевых слов.
Также мы можем предугадывать токены — если слово начинается на SELEC
, то это либо SELECT
, либо идентификатор — для такого поведения нам потребуется заглядывать за символ дальше, но не поглощать, т. е. удалять из обрабатываемой строки.
Определив список всех операций, ключевых слов, мы должны определить токены для чисел и для всех остальных слов. Числа мы будем поддерживать целые и с плавающей запятой, а для всех остальных слов определим возможные символы, которые они могут содержать — и если в слове встретится символ {
, то мы выкинем ошибку — но, повторюсь, только после окончания обработки всей строки.
Нас, в конце концов, не заботит расположения слов — лишь бы символы в словах были нормальные, да ключевые слова, операторы и символы заранее определялись, чтобы мы не занимались той же самой работой на этапе обработки токенов парсером, на который мы сейчас и переключимся.
Посылаем слова на три буквы
Какие три буквы, спросите вы? Как в том анекдоте — НБ… Ой, не то — БНФ! Форма Бэкуса — Наура. Мы уже встречались с ней в самом начале статьи, когда обсуждали гипотетический язык Language™:
вычисление
::= выражения '?'
;
выражения
::= выражение
| '(' выражения ')' операция '(' выражения ')'
;
выражение
::= цифра операция цифра
;
цифра
::= '1' | '2' | '3'
| '4' | '5' | '6'
| '7' | '8' | '9'
| '0'
;
операция
::= '+'
| '-'
;
Только что-то схема заметно выросла. Мы записали синтаксис языка с помощью БНФ, и весь смысл этой схемы заключается в том, что существует определённое количество нетерминалов и терминалов. Терминалы — это то, что записано в кавычках — они сводятся к определённым символам. Нетерминалы — это, в общем, всё остальное: комбинации терминалов и нетерминалов (последовательность или выбор с помощью оператора |
).
Такой формат описания синтаксиса языка является очень мощным инструментом — с его помощью автоматические генераторы парсеров даже способны выдать рабочий… парсер?
Но в нашем случае генератором парсера побудем мы. Чтобы было легче понять, что происходит, мы опишем наше, своё собственное подмножество SQL.
SQL очень выразителен — и поэтому описывать его полностью не будем. Вот вы сколько уже статью читайте? Себя пощадите, или меня хотя бы. Но заглядывайте в исходный код SicQL, вдруг там появилось что-нибудь ещё!
Начнём с самого верха: что представляет собой запрос? Это либо транзакция чтения/записи, с несколькими выражениями внутри, либо одно выражение. Так и пишем!
query
::= 'BEGIN WRITE TRANSACTION;'
(statement ';')+
'COMMIT' ';'?
| 'BEGIN READ TRANSACTION;'
(select ';')+
'COMMIT' ';'?
| statement ';'
;
Тут появились некоторые новые символы — и они являются ни терминалами, ни нетерминалами (что?). Элементы можно группировать в группы с помощью скобок, а затем указывать, что элемент может не появиться или появиться только один раз (символ ?
), появиться один раз и больше (символ +
), не появиться или появиться несколько раз (символ *
). Такой формат записи не был задуман изначально в БНФ, но зато появился в расширенной версии БНФ (extended BNF, EBNF) — но она не позволяет выдумать что-то иное — просто запись выглядит почище.
Дальше нам нужно определить из чего может состоять само выражение:
statement
::= select
| create
| drop
| insert
| update
| delete
;
Ну как-то не очень показательно… Давайте возьмём описание выражения по созданию таблицы в базе — нетерминалаcreate
— рассмотрим его поподробней.
create
::= 'CREATE' 'TABLE' table_existence?
'(' column_definition (',' column_definition)* ')'
;
Вот уже что-то поинтереснее. Идут терминалыCREATE
иTABLE
— это понятно. Затем нетерминалtable_existence
, который хранит в себе терминалыIF
NOT
EXISTS
и которого вообще может в запросе не быть. Затем скобки — здесь будут указываться декларации колонок: название колонки и её тип.
column_definition
::= column_name type
;
column_name
::= identifier
;
type
::= 'VARCHAR' '(' integer ')'
| 'FLOAT'
| 'INTEGER'
| 'BOOLEAN'
;
identifier
::= word
;
А что это за нетерминалыinteger
иword
? Целое число и слово — но мы их определять в БНФ не будем; и так понятно.
Понятен теперь и возможный синтаксис нашего подмножества SQL. Но как же мы будем создавать из массива токенов ветвистое абстрактное синтаксическое дерево?
Воспользуемся машинами состояния, конечными автоматами — мы находимся в том или ином состоянии, и чётко определены всевозможные состояния, в которые мы можем перейти из текущего — мы не можем прыгнуть в любое другое состояние, если этот переход не определён заранее. Это очень занимательная тема, так что настоятельно рекомендую освоить её в свободное время.
Вот, допустим, мы парсим column_definition
— и у нас есть только одно возможное состояние, которое включает в себя сочетание нетерминалов column_name
и type
. Значит, если мы не сможем войти в это состояние, то переданное сочетание токенов не верно, и мы выбросим ошибку — но сначала не пользователю, а тому, кто пользуется текущим парсером. И он уже решит — либо все его возможные переходы состояния оказались неверны, а значит и весь массив токенов в принципе, либо надо опробовать следующее состояние. Ведь все нетерминалы, в конечном счёте, стремятся стать терминалами.
По такому же принципу действуют парсеры column_name
и type
. Пробуем всевозможные состояния, пока не получим... короткое замыкание!
Ведь электроны, образно, путешествуют везде, постоянно ищут возможность замкнуть цепь. Гифка выше очень хорошо показывает работу нашего парсера.
И, таким образом, мы создаём для каждого нетерминала, сводящегося к определённому набору токенов, терминалов, парсер, и комбинируем их для вышестоящих нетерминалов. Но только нам необходимо, чтобы из одного и того же набора токенов всегда получалось одно и то же синтаксическое дерево. Ведь мы же не хотим повышать напряжение для той же работы, хе-хе.
Примечание
Парсер языка Language™, кстати, мог использовать в том числе и обратную польскую нотацию, Reverse Polish Notation (RPL), когда выражение (5 + 6) - 2
будет преобразовано не в абстрактное синтаксическое дерево, а в такое состояние: 56+2-
. Довольно красиво, не так ли? Знак операции стоит после всех операндов, что позволяет избавиться от структуры дерева с предками и детьми. Но такой трюк можно провернуть только в тех языках, в которых количество операндов строго ограничено (хоть и не всегда), чего не скажешь о нашем выразительном SQL.
Так как в нашей схеме синтаксиса отдельно указаны места, где могут находиться названия колонок и таблиц, в дереве мы будем хранить не собственно токены, образующие идентификатор, а указатели на них, индексы идентификаторов в отдельном массиве, который мы будем именовать символьной таблицей. Ведь мы, опять же, не хотим, чтобы препроцессор, осуществляющий семантический анализ, проходился снова по одному и тому же дереву в поисках идентификаторов?
А в чём смысл?
Вот мы и добрались до одного из самых простых компонентов нашей системы — препроцессора. Ему на вход, из-за наших предыдущих инженерных решений, необходима только символьная таблица, простой массив простых элементов. Ну и, конечно, доступ к самой базе данных, у которой он сможет спросить метаданные — какие таблицы существуют, и какие колонки в них находятся, с какими типами. Отвечать на этот вопрос, разумеется, будет оператор таблиц — ведь мы же не хотим прыгать через голову и работать со страницами напрямую.
Тут особо и расписывать нечего — поэтому не будем лить воду посмотрели на котика, расслабились, переходим дальше.
Алгебра пригодилась! Правда не та…
И да, это совсем не та алгебра, о которой вы могли подумать. Это реляционная алгебра. Здесь нет привычных операций вычитания, сложения... Здесь они круче, и на порядок.
Данные представлены здесь в виде отношений — множеств упорядоченных кортежей. То, что для нас является таблицей с именами и возрастами, для реляционной алгебры это отношение с кортежами (Имя, Возраст). Упорядоченных кортежей потому, чтобы атрибуты (= колонки) внутри кортежей не перемешались, хе-хе.
Над этими кортежами мы можем совершать множество различных операций — притом результат такой операции, в конечном счёте, вернёт отношение, или множество кортежей, над которым снова можно провести какую-либо операцию. Это один из фундаментов нашей будущей поддержки как вложенных операций, так и оптимизаций.
Разберём несколько основных операций реляционной алгебры:
Выборка отношения по определённому условию: такая операция выберет все кортежи, которые удовлетворяют условию.
Проекция отношения на его атрибуты: название говорит само за себя — мы просто возьмём из кортежей данные только по определённым колонкам, атрибутам.
Декартово произведение отношений: мы получим всевозможные сочетания кортежей в двух отношениях — декартово произведение отношений Буквы( (А), (Б) ) и Цифры( (0), (1) ) выдаст отношение из кортежей (А, 0), (А, 1), (Б, 0), (Б, 1).
Объединение отношений с одинаковыми атрибутами: мы «приплюсуем» к кортежам первого отношения все те кортежи второго отношения, которых нет в первом.
Пересечение отношений с одинаковыми атрибутамимы возьмём в итоговое отношения только те кортежи, которые присутствуют и в первом, и во втором отношении.
Разница отношений с одинаковыми атрибутами: в итоговом отношении окажутся те кортежи, которые присутствуют только в первом отношении.
Ну, это замечательно — но как преобразовать имеющееся абстрактное синтаксическое дерево в операции над отношениями?
Так как возможные операции SQL, слава богу, ограничены, то мы просто можем сопоставить одну операцию SQL с другой операцией реляционной алгебры, на пути сделав некоторые преобразования.
SELECT * FROM bP
— это проекция.
SELECT name FROM basedProgrammers WHERE age > 30
— .
Название отношения, конечно, сокращено — вводить операцию переименования будем с помощью символьной таблицы на уровне парсера, а не планировщика.
Наделав шаблоны для основных синтаксических конструкций SQL, мы создали план, который можно без дополнительных преобразований оптимизировать — но план так и останется планом, пока мы не превратим его в конкретные действия, инструкции для нашей виртуальной машины...
Котогенератор
Эх если бы у меня был такой котогенератор, я и не мучался бы никогда. Но имеем, что имеем — только суровый кодогенератор для нашей виртуальной машины.
Описывать кодогенератор, не представляя, для кого создаётся этот код — достаточно сложно, но возможно благодаря заранее продуманной системе. Нам нужно ветвистую структуру данных сделать плоской, превратить в последовательный набор инструкций.
Началом последовательности инструкции будет инструкция по открытию транзакции — или чтения, или записи. Взаимодействие с выводом пользователю будет осуществляться с помощью только одной инструкции, которая выдаст массив записей по запросу, а также завершит выполнение кода.
Но каким образом мы будем взаимодействовать с оператором таблиц? Да легко — ведь он же нам предоставил курсор, который мы можем двигать туда-сюда по записям. Значит вводим инструкцию по открытию курсора на определённой таблице, и этот курсор будет указывать на одну запись, из которой мы можем доставать данные.
Выразить условия выборки нам помогут циклы. Кто имел опыт с ассемблером, тот знает про инструкции «прыжков» — допустим, в человекочитаемом формате смысл такой: ПЕРЕЙТИ НА ИНСТРУКЦИЮ 5 ЕСЛИ 2 > 1
. И если инструкции с первой по пятую не имеют других прыжков, то мы получим бесконечный цикл.
На каждую операцию сравнения будет своя инструкция: прыгнуть, если равно, прыгнуть, если больше, если меньше, если не равно... А то, что с чем сравниваем, будем указывать как операнды в этих инструкциях.
Операндами могут являться как какие-то конкретные значения, либо значения определённой колонки из открытого курсора.
Простые инструкции, но в итоге с помощью них мы можем выразить, например, декартово произведение:
ТРАНЗАКЦИЯ ЧТЕНИЯ
ОТКРЫТЬ КУРСОР 'БУКВЫ' ; Курсор 0 на таблице с буквами русского алфавита
ОТКРЫТЬ КУРСОР 'ЦИФРЫ' ; Курсор 1 на таблице со всеми цифрами
КОЛОНКА 0 1 ; Берём из курсора 0 колонку с буквой алфавита, записываем
КОЛОНКА 1 1 ; Уже колонка из курсора 1 с цифрой
ЗАПИСЬ В РЕЗУЛЬТАТ ; Складываем получившуюся запись в массив
ВПЕРЁД 1 3 ; Если запись, следующая за курсором 1, существует,
; то прыгаем на инструкцию с индексом 3 (считаем с 0)
; и двигаем курсор на одну запись,
; иначе выполняем инструкции дальше
СБРОС 1 ; Делаем так, чтобы курсор 1 снова указывал на первую запись в таблице
ВПЕРЁД 0 3
ВЫВОД ; Останавливаем выполнение и отдаём массив с записями, которые мы получили
ЗАВЕРЬ ; Заверяем транзакцию
СТОП ; Останавливаем виртуальную машину
Предлагаю вам самим побыть в качестве виртуальной машины и провести данную операцию на простенькой таблице с двумя буквами и двумя цифрами — а затем сравнить с результатом такой операции в параграфе выше.
Для перевода реляционной алгебры в инструкции для виртуальной машины нам понадобится та же самая техника, которой мы сумели воспользоваться на уровне планировщика — для каждой операции мы создадим свой «шаблон» кода с возможными местами, в которые мы можем подставить другой код в зависимости от наличия потомков в узле дерева реляционной алгебры.
Самое главное требования к шаблонам кода — это отсутствие «побочных эффектов». Шаблоны должны делать только те действия, которые необходимы для выполнения операции, иначе мы рискуем сделать только хуже по скорости выполнения запроса — или у нас откроется сотня курсоров над одной и той же таблице, или один шаблон может поломаться от того, что над его курсором случайно поработал другой код.
Глава 5. Заверитель транзакций. Виртуальный нотариус собственной персоной
Вошли и вышли, виртуальная машина за 20 минут
И восстали машины из пепла ядерного огня, и пошла война на уничтожение человечества. И шла она десятилетия, но последнее сражение состоится не в будущем, оно состоится здесь, в наше время, сегодня ночью.
А мне вообще нужно указывать источник?
Вроде слово страшное, но на самом деле ничего страшного в виртуальных машинах нет — поэтому не сопротивляйтесь разберёмся с тем, из чего они состоят:
Во-первых, мы имеем встроенную в виртуальную машину память. Их может быть несколько, каждая для своей задачи или общего назначения, — такие ячейки памяти называются регистрами. Например, нам обязательно понадобится регистр, указывающий на следующую инструкцию, которую следует исполнить.
Во-вторых, есть «оперативная память» — для нас это будет простым массивом, в который мы сможем складывать результаты вычислений по инструкции записи в результат.
В-третьих, так как код последователен, то мы не должны создавать ветвистые процедуры выполнения — для каждой инструкции напишем свою функцию и закинем её в одну большую функцию «процессор», которая выбирает, какую функцию исполнить, исходя из инструкции, на которую указывает регистр, о котором мы говорили выше. Сама функция-процессор — это просто бесконечный цикл, который крутится, пока не напорется на ошибку или не встретит инструкцию остановки.
Вот и вся сложность — и всё благодаря тому, что мы чётко распределили обязанности по всем компонентам системы. Разделили сложность — и как вдруг она исчезла.
Нам нужно добавить в схему выше только пару вещей — и это виды регистров. Регистров специального назначения у нас будет всего два: это регистр-указатель и регистр, в котором будет храниться запись, получаемая от операций над записями в курсорах. По вызову операции записи в результат мы будем переносить этот регистр в «оперативную память», а затем очистим его.
Регистры общего назначения — это наши любимые курсоры. Плохая получается встроенная память, которая может расти, хе-хе, ведь мы будем эмулировать такие регистры с помощью простого динамического массива.
Виртуальная машина готова — она настолько виртуальная, что находится только в нашей голове, по крайней мере пока — но мы забыли одну вещь...
Восстанавливаемся от ошибок — почти как взрослые
На этапе создания компилятора мы всё-таки продолжали обработку ошибок, чтобы пользователю было легче разобраться со своей писаниной. Но если наша виртуальная машина вдруг столкнётся с таким, что курсор открыть невозможно, так как таблицы не существует — ну-с, это капут, товарищи. Мы столько анализов не для этого проводили, чтобы такие ошибки встречать.
Поэтому если ошибки и случаются, что абсолютно возможно, то процесс обработки запроса следует закончить. Но просто повесить процесс с сообщением «страшно, очень страшно, мы не знаем, что это такое» нельзя — у нас вообще-то транзакция открыта. Другие соединения надеются на нас, что мы не будем устраивать суматоху на пустом (или не очень) месте, надеются, что мы не будем перекидывать свою ответственность на них.
Поэтому, словив ошибку, мы начнём не копить их, а откатывать все изменения, которые мы произвели для совершения транзакции — если же это была транзакция записи, то мы запишем в журнал, что или мы дураки, или пользователь слишком хитрый, что данную транзакцию следует рассматривать как не случившуюся и заносить её в файл нельзя. Можно, конечно, начать вычищать журнал, но это будет продлением жизни ошибочного состояния! Индекс журнала обновлять не будем, чтобы на нашу ошибку не напоролись заново, а затем снимем все блокировки записи с файлов. Ещё было бы хорошо завести отдельный журнал ошибок, который будут читать только администраторы базы данных, для того, чтобы они узнали, что же произошло, и решили вопрос в случае необходимости.
Если же мы были в транзакции чтения, то нам будет необходимо только снять блокировки чтения на журнале и на базе данных.
После восстановления от ошибки мы «выключаем» виртуальную машину, выдаём пользователю сообщение о внутренней ошибке. Try again later, хе-хе.
Послесловие
Что дальше?
Так как время оказалось не резиновым, то у нас всё ещё остался огромный простор для развития дальнейшей функциональности. В том числе, пока нам не хватает даже в теории:
Поддержки foreign keys, «синтаксического сахара» над JOIN’ами и ограничителями,
Императивного подмножества SQL со своими функциями,
Большего множества типов хранящихся значений, например, BLOB, TEXT, JSON, DATE…
Индексов на основе B-деревьев, способных индексировать значения по нескольким полям, необходимых для обеспечения требований целостности данных,
Поддержки операции VACUUM на уровне оператора таблиц,
Сложной системы оптимизаций запросов,
Сервера, способного отправлять запросы «по проводу»,
Возможности выбора необходимой пользователю степени изоляции,
Вложенных запросов.
И прочее, и прочее. В одну статью всё вышеперечисленное уж точно не поместится — так что либо задача остаётся на вас, либо… Ждите продолжения — с блэкджеком и живыми совами!
А зачем всё это?
Всё это, конечно, детский сад — реальные СУБД, настоящие production-ready инженерные решения, в которые вложены сотни человеко-лет, устроены сложнее, страшнее и далее по списку.
Но кто запрещает нам экспериментировать, создавать свои любительские проекты? Ведь и Linux, и Postgres, и Rust, как и многие другие важные вещи (не только в сфере IT), изначально создавались как простые, ни на что не рассчитывающие, студенческие поделки…
Как говорится — это редиска, но это наша редиска!
Поэтому творите, друзья, — вдруг именно ваше решение проложит путь в светлое будущее. И без всяких постапокалипсисов. Хотя почему вдруг — я в этом уверен.
Спасибо за составление компании на этом пути — не переключайтесь, впереди ещё вторая часть!
Полезные ссылки
Постскриптум
Хочется выразить отдельные благодарности:
Мише, автору канала МиКаст в Telegram, за его полезные советы на до сих пор продолжающемся пути становления программистом,
коллективу историко-философского журнала «Русская Пустошь» за помощь во вычитке статьи.
А также я буду очень рад, если данная часть статьи заинтересовала хотя бы одного человека испытать себя на поле создания системных инструментов с нуля — в таком случае статья выполнила свою цель: представить сложные вещи интересным приключением, ведь тогда они и вовсе перестанут быть сложными. Хотя бы будет потом с чем сравнить через пару месяцев, хе-хе.
Комментарии (58)
SnakeSolid
06.01.2023 09:00+1Делал в свое время колоночную БД, правда без транзакций. Тоже байт-код использовал, но разбивал работу с таблицами на блоки из трех сегментов: чтение+фильтрация, все вычислительные операции, запись; так чтобы каждый блок мог выполняться в несколько потоков. Оптимизировал только вычислительные операции, а чтение и запись конвертировались в один в готовые узлы сканирования/записи таблиц.
PS: Вы случайно не вдохновлялись SQLite?
sicikh Автор
06.01.2023 09:34+1Случайности не случайны, не зря в тегах статьи стоит SQLite, а в источниках — её архитектура :)
Для меня она стала путеводной звездой, образом простоты и функциональности — и это позволило мне взглянуть на неё с другой стороны. Если бы многие вещи в SQLite были доступны без дополнительных танцев с бубном черезpragma
, то, я думаю, у этой СУБД было бы больше поклонников и меньше тех, кто рассматривает её только как СУБД для тестов (что на самом деле является не очень практичным подходом).
Помимо этого, я вдохновлялся давнишней статьей с СУБД на Python от студентов Иннополиса. Идея об изучении строения СУБД через её создание появилась именно после прочтения той статьи — и я хотел бы воспроизвести такие последствия и для других читателей, но уже для этой статьи.akardapolov
06.01.2023 18:00+2Меня в SQLite впечталило "100-процентное покрытие по MCDC" и последующее практически полное отсутствие багов.
Интервью с создателем SQLite (часть 2): Android 2005, хвала Кнуту, 100% тестовое покрытие, собственная CVS / Хабр (habr.com)зы. ClickHouse на минималках :) real-time-intelligence/fbase: Hybrid time-series column storage database engine written in Java (github.com)
FanatPHP
06.01.2023 10:22+9Я нихчего не понял. Какие-то дикие скачки от архитектуры к языку запросов, потом к схеме хранения данных, потом снова к языку, потом опять схема, потом транзакции, и опять язык…
И все это приправлено густопсовым словоблудием.С одной стороны, объем проделанной работы вызывает уважение. С другой — описание этой работы напоминает острую форму шизофазии.
Сам посыл верный, разобраться в том, как работают базы данных, однозначно полезно. Но при этом разбираться желательно без лирических отступлений и авторских ремарок (с претензией то ли на остроумие, то ли на фамильярность), занимающих чуть ли не половину текста.
И без ненужного функционала. Для того, чтобы описать сложную систему, надо даже часть важного функционала опускать, не говоря уже о добавлении в систему ненужного. Зачем здесь постраничная организация? Причем логика так и осталась неясной: если у нас и так бинарный формат, то зачем страницы-то? Если все делается ради чтения сегментами, то кто мешает читать теми же сегментами из одного файла? Вроде бы, где-то к середине автор пришел к концепции индексов. Но если у нас есть готовый указатель на смещение в файле, то зачем тут еще и страницы?
Попытка галопом по европам рассказать такие понятия как транзакции и блокировки удалась тоже так себе.
В общем, вам надо в первую очередь серьезно поработать над стилем и связностью изложения.
sicikh Автор
06.01.2023 11:40+3Благодарю за ответ, минус не мой.
И я согласен с тем, что это самое настоящее галопом по Европам. Но плохо ли это? Может, кому-то и требуется краткий (или не очень) экскурс в происходящее?
Цель статьи не обеспечить человека всей той теоретической базой, которая заложена в реляционных СУБД. Эту задачу и без меня выполняют вполне уважаемые люди, которые пишут интересно и пишут по делу. Но что бы взяться за увесистые тома ценной информации, нужно, что неудивительно, заинтересоваться. Не каждый программист имеет под боком достойного преподавателя, наставника, который за приличное время сможет рассказать об этом.
Цель статьи — заинтересовать читателя копнуть глубже, попробовать сделать что-то самим. Писал, в конце концов, я для себя — и если бы у меня под рукой пару месяцев назад была данная статья — я был бы нескончаемо рад.
По поводу стилистики текста — кому-то такой стиль очень понравился. Кому-то нет. Разве не так всё устроено в нашем мире? А диагнозы я бы, честно говоря, придержал при себе. Но развиваться есть куда — и я понимаю это как сейчас, так и тогда, в самом начале создания статьи.
По поводу постраничной организации — данный формат организации данных задаёт логику организации БД — какие-то страницы у нас рассказывают о таблицах, другие — о страницах в таблице, третьи же — о самих записях. Это позволяет нам строить индексы на основе деревьев, упрощает логику упреждающего журнала и так далее, и так далее...
Как такое сделать без страниц или любого другого формата упаковки строк, я не очень представляю. Поделитесь же тогда вы своим виденьем :)
P. S. Я бы больше задумался о необходимости «виртуальной машины» и кодогенератора. Так ли он нужен при наличии реляционной алгебры — возможно стоит несколько усложнить хранение операций и отсечь необходимость кодогенератора, позволив движку работать непосредственно с ней.FanatPHP
06.01.2023 13:13+4Это позволяет нам строить индексы на основе деревьев, упрощает логику упреждающего журнала и так далее, и так далее...
Возможно. Но из текста это совершенно неочевидно. О чем, собственно, и речь. Причина разбивки на страницы подана невнятно. Только что у нас был текстовый файл с одной таблицей — и уже один бинарный, разбитый на страницы, с кучей таблиц. У вас хорошо получается чесать языком на отвлеченные темы, но когда речь заходит о деле, начинаются провалы в логике. Хотелось бы обратного соотношения.
Вопрос, хранить ли все в одном файле — полностью вторичен. Это, скорее каприз, чем необходимость. Каприз, который усложняет реализацию и объяснения. И получается, что страницы с информацией таблицах становятся не нужны. Остается постраничная разбивка записей, про которую опять же, ничего не объяснено.
Разумеется, мой теоретический багаж меньше вашего. И ничего подобного, задуманной вами системе я никогда не делал. И, скорее всего, многие ваши решения технически оправданы. Но из текста это не видно.
"Кому-то нравится" — это, конечно, хорошее оправдание для себя, но оно не делает вашу фривольную манеру изложения лучше. Мемасики не могут заменить логические связки.
sicikh Автор
06.01.2023 18:11+1Немного подумав,
как обычно хорошая мысля приходит опосля, я пришёл к тому, структуру статьи можно было выстроить иначе, более понятно. Но что получилось, то получилось :)
Статья вышла как набор заметок «по поводу», и, собственно, она таким образом и создавалась (см. Zettelkasten). Постараюсь не допустить такой оплошности в будущем — комментарии с критикой на Хабре это очень ценный ресурс для развития. Благодарю за неё!
P. S. Ниже в комментариях один из читателей уже вдохновился продолжить какой-то свой собственный проект. Статья свою задачу выполнила — а остальное лишь послужит почвой для будущих статей. Проба пера — она такая :)FanatPHP
07.01.2023 11:30Вот у мне тоже не получается нормально написать с первого раза. Сейчас бы я свой исходный комментарий написал совсем по-другому, без пассивной агрессии. По-хорошему, надо было просто ограничиться короткой рекомендацией, что хотя хорошая шутка и разбавляет текст, облегчая чтение, но когда их становится слишком много, то функция меняется на противоположную — становится сложнее воспринимать основной текст.
Ну и должен отметить что все это относится только к тексту, а не к проекту, который сам по себе вызывает безмерное уважение.
BetsuNo
07.01.2023 12:09+1А мне наоборот очень зашёл ваш стиль изложения. Согласен с предыдущим оратором только в том, что местами получилось несколько сумбурно, но мне это ни сколько не помешало.
developer7
07.01.2023 01:20+1С вами согласен. Пост просто пролистал и сразу перешёл к комментам.
Но по поводу страниц хотел бы сказать зачем мы их применили у себя. Не буду описывать тернисты путь, как мы выбирали размер и прочее, напишу 2 главные причины.
Страницы на диске по 8Кб — основная причина, если мы изменили 1 байт в таблице, не хотелось бы переписывать 16Мб на диске, если страница например 16Мб. Изначально такой размер был выбран для получения преимуществ в скорости чтения с диска, и количества файлов в файловой системе, которое тоже ограничено, и прочего. Но последующая разработка показала оптимальным всё таки размер совпадающий с размером странице диска.
Страница в RAM — тоже равна 8Кб. Основная причина — экономия RAM. Загружать в RAM только те страницы которыми пользуешься.Опять же если страница RAM и DISK одинакового размера — то упрощает их синхронизацию.
Basheyev
06.01.2023 12:05+2Вы очень решительны, если сходу взяли очень широкий фронт работы по всем направлениям одновременно. Мне кажется на реализацию нужно несколько тысяч человеко-часов (1.5-2 года full time). Посмотрел ваши исходники sicql - почти всё ещё предстоит сделать. Несмотря на то, что в своем проекте Boson (NoSql) уже реализовывал отдельно виртуальную машину и компилятор, и B+ Tree индекс, и Storage, и CachedFileIO (аналог pager), но "подружить" эти наработки в целостный продукт довольно сложно. Хочу пожелать вам, как коллеге, терпения и удачи!
sicikh Автор
06.01.2023 12:23+1Да, с кодом, соответствующим текущим виденьем архитектуры, не очень густо :)
Прототип, который не знал ни о блокировках, ни о транзакциях, ни об индексах, меня настолько не удовлетворил, что я сознательно начал всё сначала. Это стало похоже на эксперимент — возможно ли по тексту статьи восстановить то, что имел в виду её автор :)
А о вашем проекте наслышан — то ли сезон своих СУБД начался,то ли ретроградный Меркурий. Благодарю, вам тоже желаю удачи!FanatPHP
08.01.2023 07:35Поскольку я рос вместе с mysql, то мне более логичным представляется скорее последовательный подход, чем "всё и сразу".
Из всего перечисленного самым важным элементом мне представляются индексы. Без которых БД действительно не взлетит. Собственно, MySQL была вполне себе юзабельной без транзакций и строчных блокировок, но имея индексы и табличные блокировки. На энтерпрайз не годилась, но весь веб на себе 3.23 тащила только в путь.
И в этом смысле я задаюсь вопросом — а так ли уж надо делать все сразу: и транзакции, и индексы, и блокировки, и парсер языка, и систему хранения? Может быть, выделить пару приоритетов и сосредоточиться на MVP? Особенно учитывая, что "с кодом пока не очень густо"?
kuza2000
06.01.2023 12:47+4У меня есть библия для тех, кто будет заниматься "главой 3". Издание 78 года, но по прежнему все актуально :)
Сам когда-то развлекался этим...
Akon32
06.01.2023 21:23+1Журналируемую БД наверное проще реализовать на LSM-деревьях, однако в книге Кнута 1973/1978 года их быть ещё не должно.
kuza2000
06.01.2023 22:07Да, упоминания LSM там нет.
Почитал сейчас бегло про LSM. Сложилось впечатление, что они устроены заметно сложнее. И еще впечатление, что их трудно назвать "базовым" алгоритмом. Возможно, ошибаюсь.Akon32
06.01.2023 23:13По-видимому, lsm проще, чем b-деревья.
Читал о реализации наподобие следующего: 1 неотсортированный "журнал" в памяти или на диске, плюс n отсортированных сегментов на диске. При поиске просматривается "журнал", а затем все остальные сегменты. При вставке/обновлении в журнал добавляется запись, при удалении - отметка о удалении. Если журнал велик, он сортируется и сбрасывается на диск как новый сегмент. Сегменты при каких-то условиях объединяются (сортировка слиянием), при этом перезаписанные или удалённые записи теряются. Ещё элементарно делаются снэпшоты для изолированных транзакций - это всего лишь дополнительный журнал на транзакцию, который либо сливается с сегментами потом, либо отбрасывается.
Интересная вещь.
kuza2000
06.01.2023 23:30Ой, не все так просто...
Куча вопросов. Например - в каких структурах хранятся данные на диске? Допустим, таблица в миллион строк - как она лежит на диске? Как в ней осуществляется выборка записи по ключу?
В том, что находится в памяти - те же вопросы. Как осуществляется хранение и поиск? Даже если данные отсортированы, двоичный поиск не эффективен, даже в памяти на современных процессорах с кэшем - при каждом сравнении будут обращения к разным адресам в памяти, их не будет в кэше процессора. А у B-деревьев ключевые данные лежат компактно и будут в кэше с гораздо большей вероятностью.
А про диск все то же самое, только еще хуже. В общем, в описании LSM я не нашел самого главного - как делается поиск. Возможно, плохо смотрел. Но что-то мне кажется, что там используются какие-то более базовые кирпичики. Например, в памяти хэш-таблицы, а на диске - те же B-деревья.
А как строятся дополнительные индексы?
Я как бы не утверждаю, что LSM плохо, нет. Просто я не знаю деталей реализации. А B-деревья я делал, на C++, знаю все детали. Хотя, возможно, по этому они кажутся мне простыми))Akon32
07.01.2023 00:13В общем, в описании LSM я не нашел самого главного - как делается поиск.
Поиск посегментно, начиная с "журнала", а затем с последнего записанного сегмента к первому, пока данные по ключу не найдутся. Внутри сегмента данные упорядочены, так что подойдёт бинарный поиск. Журнал может быть небольшой и неупорядочен, так что линейный поиск. Но можно, конечно, загружать сортированный сегмент или журнал в память для кэширования, и уже в памяти любые структуры строить - хоть b-деревья, хоть хэш-таблицы. Наверно.
Я не очень знаком с этой структурой, прочитал описание несколько дней назад. Мне она показалась относительно простой, но если оптимизировать, конечно можно залезть в дебри.
kuza2000
06.01.2023 23:32Вот тут нашел что-то про LSM на хабре: https://habr.com/ru/company/vk/blog/358210/
kuza2000
07.01.2023 00:00А, ну вот, из той статьи
Например, Tarantool использует для хранения L0 B+*-tree, о которых я рассказывал в своём блоге.
На диске, как понимаю, примерно то же самое. Они скидываются и сливаются.
Получается, как я и предполагал, LSM-деревья работают на B-деревьях. То есть, от B-деревьев уйти никак не получится)))Akon32
07.01.2023 00:02Это частная реализация.
L0 B+*-tree
Похоже на надёжный пароль.kuza2000
07.01.2023 00:11+1На текущий момент я не знаю более эффективных универсальных алгоритмов хранения информации с возможностью быстрого поиска, чем B-дерево. Именно универсальных. В каких-то частных случаях могут быть более эффективные решения, например, хэш-табицы.
А LSM, как я понял, это не алгоритм хранения и поиска, а скорее надстройка над ними. Там нет самого поиска. Он по прежнему использует старые вещи. Скорее всего варианты B-деревьев, просто других альтернатив нет.
В памяти для маленьких деревьев еще могут быть варианты. А вот для диска более эффективных структур я не знаю.
Akon32
07.01.2023 00:30LSM по скорости поиска чуть менее эффективно, чем B дерево (всё-таки, сегменты перебираются линейно, да и в кэш процессора не влезают). У него другие плюсы, типа асинхронной балансировки без прерывания операций поиска и записи, и очень быстрой записи. Если интересно, можете посмотреть на реализацию f2fs - там lsm деревья используются, если верить вики.
sergree
06.01.2023 14:30+2Волшебные приключения в удивительном мире разработки СУБД. Самое то в сочельник. :) За Rust отдельный респект! Спасибо за увлекательную историю.
sicikh Автор
06.01.2023 18:19+1Очень приятно, спасибо! Ваш недавний пост тоже было приятно перечитывать, занял своё почётное место в закладках :)
YuryB
06.01.2023 17:04есть ещё одно важное требование у СУБД, в случаи проблем с диском или фс база не должна превратиться в тыкву, была статья на хабре что та же sqlite в этом плане хорошо отрабатывает и например лучше использовать её, чем пытаться писать в файлы какие-нибудь данные, если там конечно не тривиальный случай
sicikh Автор
06.01.2023 18:25Я мельком думал насчёт того, как поведёт данная система в случае, когда операционная система отдаст концы. В общем, должны сохраняться те транзакции, которые были заверены — а заверяются они тогда, когда происходит
fsync()
в упреждающий журнал. Тогда данные сохранятся в журнале даже при перебое питания (насколько я это понимаю). Если же сбой произошёл при переносе страниц из журнала в основной файл — то данные удаляются из журнала только в самом конце операции. В любом случае эту операцию начнёт сначала первое открытое соединение СУБД, когда оно заметит переполнение журнала, так что заверенные данные останутся в сохранности. По логике...
EchoA
06.01.2023 17:34+1@sicikh, спасибо огромное за качественный и мотивирующий русскоязычный текст по декомпозиции в архитектуре ПО - я рассматриваю вашу статью именно в этом ключе. Это достаточно редкое чтиво.
Сейчас сяду за компьютер и попробую применить на практике предложенный вами подход "начать думать с двух сторон" - а то уже на несколько месяцев забросил разработку своего детища из-за неподдающихся решению проблем с послойной декомпозицией.
Спасибо!
sicikh Автор
06.01.2023 18:35Честно говоря, такого взгляда на статью я ещё не видел :)
Всегда радует, что твои собственные потуги воодушевляют кого-то продолжить бороться уже со своей сложностью. Удачной декомпозиции с вашим детищем!
18741878
06.01.2023 18:00+2А если начать с самых основ, без зауми? Хотя бы вот с этого https://dmkpress.com/catalog/computer/databases/978-5-97060-488-5/
sicikh Автор
06.01.2023 18:32Перерыл я за это время немало литературы, хорошей и не очень, а данный материал не встречал. Звучит интересно, спасибо за рекомендацию!
Простой взгляд на сложные проблемы встретишь не часто...
Alhymik
08.01.2023 17:34Да, интересно, продолжайте. Неплохо бы, например, подробнее про формат страницы. Там же ещё где-то инфа о полях таблицы должна быть, их типах. Что если размер записи превышает размер страницы и занимает, скажем 2.5, а не одну. Или запись не имеет фиксированной длины, поскольку содержит текстовые поля неограниченного размера, причем так, что сам текст может занимать больше одной странцы. Есть ли вообще смысл размещать большой текст постранично? Кроме самих данных и ссылки на следующую странцу, какую метаинформацию страница ещё должна содержать? И т.д., и т.п.
WASD1
08.01.2023 19:36Очень тяжело читать, при том, что материал не выглядит как что-то запредельной сложности. Мне кажется, то описанию не хватает некоторой редактуры.
Поскольку архитектура вашего курса скорее "теоретическая" (это не объём практических знаний - а порядок изложения материала: теоретические учат с начала до конца (или с конца в начало) а практические с середины - сделаем ничего не делающий, но законченный MVP), то выигрышнее смотрятся курсы каждая глава которых построена по такой схеме:
1. общее описание - чего хотим добиться и почему;
2. набросок технической реализации;
3. некоторые частные, но важные технические детали + куда копать \ что улучшать в части 2 (весь бойлерплейт части-2 оставляем читателю воспроизвести самостоятельно, как упражнение к главе);
sancoder
Есть такая СУБД, называется Oracle Database. Славится тем, что не следует (не следовала) стандартам. Например, пустая строка и NULL в понимании этой СУБД - это одно и то же.
Так вот, ее основное достоинство (имхо) - это версионность записей в БД. Рассмотрим пример: транзакция Т1 меняет запись, и дальше что-то делает. Транзакция Т2 пытается читать измененную запись. По стандарту SQL92 вторая транзакция должна встать в ожидание (на всех уровнях изоляции выше read uncommitted). Но с Oracle ситуация другая: на этой СУБД вы получите прошлую версию записи сразу, без блокировки.
Получается, что целостность (consistency) приобретает другой смысл. Оказывается важно, чтобы все чтения транзакции Т2 были сделаны из одного и того же среза (snapshot) данных. И этот срез фиксируется началом транзакции.
Собственно, на заре нулевых, когда NoSQL еще не было, а споры что лучше MSSQL или Oracle были, основным достоинством последней было именно наличие другой модели блокировок и работы с данными.
Вы здесь упоминаете SQL92, но Oracle как мне кажется, добивался большей параллельности (и следовательно большей производительности) не за счет выполнения стандарта, а за счет решения задач более высокого уровня. У MSSQL было лучше с реализацией, но у Oracle было лучше с концепцией.
Если вы реально интересуетесь темой, то я бы порекомендовал прочитать книгу Oracle Core (автор - Jonathan Lewis). Из книги вы узнаете, например, почему у блоков, хранящих данные таблицы, всего 1 слот для блокировок, а у блоков с данными индексов 2.
sicikh Автор
Благодарю за ответ!
Про строки и NULL — очень интересное наблюдение, обязательно надо ознакомиться с обоснованием для такого решения.
А про версионность записей — я с вами согласен, что СУБД, которые реализуют изоляцию транзакций на основе блокировок отдельных записей, добиваются большей параллельности.
На самом деле, первый прототип,
который больше не увидит свет, SicQL пытался в блокировки строк — по крайней мере, в начале пути этого хотелось. Но, столкнувшись с неведомой сложностью сего дела, я быстро идею отбросил. Достаточно наивным было считать, что получится за два месяца теоретически охватить PostgreSQL/..., а затем воплотить в жизнь хоть малейший его клон. После такого провала, хе-хе, взгляд был обращён на SQLite, чья заслуга заключается в том, что изоляция транзакций и многоверсионность относительно других проектов реализованы относительно просто, да так, что для работы с базой данных достаточно одного процесса, который можно запускать непосредственно из приложения.Но, в итоге, всё получилось как в этой цитате: «Всё не так просто, как кажется на первый взгляд, но и не так легко, как кажется на второй».
Рекомендую читателям ознакомиться ещё с тем, как изоляции транзакций поддерживаются в PostgreSQL.
Я надеюсь, что во второй части статьи получится затронуть теоретическую основу блокировок не просто страниц, а записей в ней.
sancoder
SQLite является файловой СУБД. К файлу БД можно обращаться по сети. Если ваша цель реализация СУБД на основе файлов, то не забудьте обеспечить возможность такого обращения, желательно с хоть каким-то уровнем блокировок, то есть чтобы не блокировалась вся БД при обращении кого-то одного.
sicikh Автор
Полная блокировка (предоставление процессу соединения с БД блокировки записи основного файла посредством системного вызова) осуществляется только в момент очистки упреждающего журнала, то есть, когда нам такое поведение и нужно.
В момент чтения из файла БД и чтения/записи упреждающего журнала процессам СУБД предоставляется блокировка чтения/записи только определённой части файла, и зачастую эта часть файла равняется размеру страницы.
Mingun
А я слышал, что в PostgreSQL тоже версионность записей до такой степени, что удалений в процессе обычной работы нет, есть только вставки и обновления (удаления — обновление отметки "удалено", физическое удаление делается вакуумом). Собственно, до прочтения тегов статьи с какого-то момента начало казаться, что автор вместо твердой
научной фантастикитеории начал пересказывать исходный код PostgreSQL (в этот же момент количество смехуечек, как тут уже заметили, превысило комфортный предел). И тоже слышал, что чтения в PostgreSQL всегда идут без блокировок, а это возможно только если читать (условно) устаревшие данные, которые в этот момент могут изменяться другой (пишущей) транзакцией. То есть получается, что подход Oracle не уникален (хотя тоже когда-то давно читал, что такая его стратегия чтения уникальна… но это было тогда, когда PostgreSQL на слуху не было)developer7
Тут надо различать как бы 2 БД. 1 находится в виде файлов на диске. Другая загруженная в RAM. Как раз вся работа с БД идёт в RAM. А на диск уже идёт только синхронизация.
И Вакуум запускается для диска. Потому как на диске используется страничная организация памяти.
А вот для RAM — там можно безнаказанно выделять и удалять память. Иначе можно вставить гигабайт данных, а потом удалить. И что этот гигабайт будет висеть мёртвым грузом в RAM?
Но я не знаю как точно работает postgres. Я написал как это должно работать по логике.
FanatPHP
Я не настоящий сварщик, но меня все-таки смущает эта ваша концепция двух БД. Из практики, она обычно применяется в специализированных решениях, типа сервиса кэширования. А классические БД, такие как постгрес, заведомо ориентируются на объемы, превышающие возможный размер памяти. И требуют, чтобы в память влезали только индексы. В частности, в блокировках и транзакциях может участвовать не вся запись, а только её представитель — первичный индекс. А сколько там гигабайт — дело уже десятое.
developer7
В разработке сейчас собственная реляционная табличная БД. Причина собственной разработки это отдельная тема. И честно сказать — это самый мозговыносящий проект в моей практике.
Прочитав ваш комментарий я прекрасно удивил одну из проблем которую мы решали в процессе проектирования. И у меня возник вопрос.
Собственно как работает доступ к таблицам. Разные потоки делают запросы на read — при этом таблица лочится — но все могут получить доступ на чтение. Если в очереди запросов подходит запрос write — то ожидается пока все read не закончатся, потом лочится на write — и далее с таблицей работает 1 поток.
Все INSERT/UPDATE/DELETE — это write запросы и делаются внутри транзакции. И представим ситуацию — мы сделали UPDATE на гигабайт данных, или DELETE — а далее делаем SELECT в тойже транзакции — и мы должны получить уже изменённые данные. Самое простое — это когда мы залочили таблицу и делаем изменения прямо в ней. И Select тоже в ней. Если придётся откатывать транзакцию — то это тяжёлая операция. В нашем случае — идёт восстановление таблицы из журнала WAL. Также при откате Текущий WAL транзакции не записывается на диск.
Вы же описали ситуацию когда изменения INSERT/UPDATE/DELETE не делаются в существующие таблицы, а делаются кудато в другое место. Ну допустим в массив какой-то. Соответственно в это время с таблицей могут работать другие в режиме read.
Но как тогда делать SELECT? Путь это простая таблица без индексов. Это будет набор массивов в котором просто циклом перебираем строки. Но в вашем случае надо как то знать какие строки изменённые.
Конечно можно придумать геморройный механизм этого. Но это будет намного медленнее и жрать память. Простой перебор одномерного массива намного быстрее, чем перебор с проверкой — а не изменилась ли строка? А ещё надо учитывать DELETЕ — который может вообще пол таблицы в разных местах выкинуть.
Вобщем вопрос — а нужно ли такое поведение? Я вижу как минимум например засаду в следующем сценарии. Сделали платёж — запустился скрипт изменения баланса в БД — но подавис по времени. Если сделать сразу новый платёж — то мы получим неправильный баланс денег.
sancoder
Ответ на ваш вопрос в последнем абзаце (отсылка к книге как устроен Oracle внутри).
У вас сейчас табличные блокировки. Любой изменяющий должен выставить блокировку на таблицу, и от момента блокирования таблицы до момента коммита транзакции таблица недоступна (для других). В стандарте SQL92 вопрос проработан дальше - блокировка ставится на запись, а не на таблицу, но ожидания все равно остаются.
В Oracle ключевым понятием является SCN. Это единый, сквозной номер изменения в БД. Любое изменяющее действие увеличивает этот номер (чтение может не увеличивать). Но каждая транзакция помнит "свой" стартовый SCN, от которого она должна отсчитывать все изменения.
То есть чтобы прочитать данные нужно не только знать что читать, но и то, на какой момент это читать.
Соответственно, изменения в любую запись БД вносятся с учетом этого SCN. Более подробно - вам лучше все же книгу почитать. Поверьте, это пока что поверхностный уровень. Внутри куда все сложнее, особенно с блокированием при изменении индексов.
developer7
SCN — у нас тоже пару раз мелькал в обсуждениях. Ничего сложного нету что бы сделать какой нибудь long и инкриментировать его в транзакциях. Вот только куда и зачем это применить так и не нашлось. И главное это ни как не решает проблему которую я описал. А именно где хранить изменения которые произошли внутри транзакции — напрямую в таблицах RAM или в другом месте. А в таблицы данные переносить при выходе из транзакции. И в этот момент уже точно — хочешь не хочешь а таблицу придётся блокировать. Нельзя вносить одновременно изменения в один массив (таблицу) из разных потоков. Иначе такая каша может получится. Типа в одном потоке мы удалили строку, а в другом вставили. А в реальности существует строка или нет будет решать рандом переключателя задач.
Выигрыш записывать изменения в конце транзакции только в том что это время будет меньше чем время всей транзакции.
В нашей БД такой механизм применить легко даже не сильно меняя код. А именно. При каждом INSERT/UPDATE/DELETE — идёт запись в WAL. Так вот просто комментировать строку изменения таблицы, при этом изменения WAL останется. А в конце транзакции применить WAL к таблицам. Точно также как WAL применяется при старте БД. Также решается мега геморрой отката транзакции — его просто не будет, потому как таблицы RAM не изменились.
Но тогда внутри транзакции SELECT будет работать на устаревших данных.
И ещё непонятно. При старте транзакции указывается список таблиц которые должны быть неизменны на момент старта транзакции, транзакция же атомарна, что бы вся логика внутри транзакции отработала правильно. Но в случае если не блокировать таблицы — и изменения в них могут вносить другие транзакции, может получится каша.
Например делаем внутри транзакции SELECT сколько денег у клиента. Возвращается что 100$. Далее идём делать какую то логику. А в этот момент в другой транзакции снимаем эти 100$. Но при этом мы находимся в первой транзакции и она работает по логике что у клиента есть 100$.
Короче есть на чем подумать. Если внутри транзакции ненужны селект — то можно не блокировать таблицы на время транзакции. В сценариях описанных выше — такой подход неприемлем.
sancoder
При дизайне СУБД с использованием SCN, каждое изменение имеет ссылку на прошлую версию той же записи. Таким образом, если вторая транзакция читает обновленные данные, но знает что ей нужны более старые (см. выше про необходимость знания транзакцией своего стартового SCN), то она по ссылкам в записи проходит дальше в историю, чтобы получить данные на тот момент, когда ей необходимо. Вопрос сколько истории хранить - регулируется размером UNDO TABLESPACE в Oracle. Возможны ошибки, что "транзакция слишком длинная, истории не хватило" - тогда есть 2 выхода: либо увеличить объем UNDO (область админа), либо изменить логику приложения (область разработчика).
СУБД общего назначения не может расчитывать ни на то, что вся таблица поместится в RAM, ни на то, что все изменения одной транзакции поместятся в RAM. Это слишком опрометчиво с точки зрения дизайна, и на практике рано или поздно случится так, что это ограничение будет нарушено.
Помимо механизмов изоляции транзакций, СУБД еще должна заботиться о резервном копировании (и репликации на другие инстансы, если смотреть еще дальше). Здесь тоже встают вопросы как минимизировать объем данных в резервной копии (или объем передаваемых данных на реплику).
Мораль здесь такая: для разработки своей СУБД лучше изучить существующие решения и выбрать работающие решения (отказавшись от ненужных фич), а не пытаться решить проблемы по мере их поступления. Последний путь - дороже в общем случае.
developer7
Понятно про что вы пишете. Вопрос в том имеет ли это смысл, Смотрите — самый простой, и самый быстрый вариант хранения таблицы в памяти — одномерные массивы массивов. Тогда SELECT без индекса — это просто for по массиву. Он делается максимально быстро в кеше процессора.
С другой стороны имеем тот же массив массивов + новые изменённые данные. Где их хранить? Ну допустим имеем новый массив. В котором например структуры (номер строки, новое значение). Тогда вместо простого for, мы должны на каждом цикле делать запрос к новому массиву, перебирать его и искать изменённую строку. Получаем дикий оверхед. Как избавится от этого оверхеда? Так на вскидку ничего нормального в голову не приходит. А если изменений на гигабайт — то вообще получим неработоспособную систему — такой SELECT просто зависнет навечно.
И тут получаем мой вопрос — а ради чего это нужно?
Пока напрашивается простой вариант — сделать 2 вида транзакций:
Fast — это в которых нету внутренних select, либо нас устраивают устаревшие данные. В таких транзакциях блокировать таблицу в конце транзакции.
И второй вариант — с полной блокировкой таблицы.
Что касается собственной разработки БД.
По поводу преимуществ собственной разработки уже были да и будут миллион дискуссий, более того всё существующее ПО это когда то собственная разработка. Но я согласен — без явной причины, на такой подвиг я бы не согласился ни за что. Нас вполне устраивал Postgres. Тут мне в личку писали, я там подробно ответил причину почему всё таки своя БД. Приведу ответ тут:
Mingun
Хм, кажется, вместо написания своей БД на пару лет можно было бы просто добавить патч в PostgreSQL, чтобы он писал метрику времени ожидания записи WAL? Или хотя бы поднять такой вопрос в баг-трекере. Кажется, это точно было бы реализовано быстрее
Alhymik
Вся таблица в виде массива в RAM когда-нибудь порвёт RAM, но да ладно. Чтоб не делать вложенные проходы, можно докинуть изменяемое служебное поле для каждой ридонли-записи в основном массиве. В этом поле хранить индекс изменённой версии записи во втором массиве. При проходе проверка: нет индекса - не было изменений, есть - берём данные по индексу.
developer7
БД про которую я пишу это не in-memory БД. А работает на страницах. Но в любом случае, если таблица без индекса — то её нужно будет загрузить всю в RAM — где в цикле проверять каждую строку. Если памяти не хватает — придётся выгружать первые страницы(которые уже не нужны) из RAM, пред загрузкой последних.
То что вы описали — это то что описал и я. В цикле для каждого столбца каждой строки придётся вызывать функцию которая будут проверять изменялось ли значение. Проверка это будет в общем случае как перебор другого массива в котором будут например структуры (номер строки, номер столбца, новое значение). И избавится от этого дикого оверхеда не получится.
Alhymik
Ещё раз попробую. Будет не перебор второго массива, а прямой доступ к элементу этого массива, сложность O(1). Например, основная таблица Persons (ID, Name, Age) представлена как массив arrPersons[rowIdx][colIdx], но с дополнительным 4-м полем ChangeIdx:
100, "Иванов", 23, null
200, "Петров", 54, 0
300, "Бобров", 37, null
Если сессия читающая, то поле ChangeIdx игнорируется.
Если сесси пишущая, а в вашей СУБД она всего одна для таблицы, то во время прохода смотрим:
У записей с айди 100 и 300 ChangeIdx = null, значит они не модифицировались, считываем данные как есть.
У записи 200 ChangeIdx = 0 значит в массиве изменений arrChangedPersons[ChangeIdx] по индексу 0 для него есть изменённая запись, например, с новым возрастом
arrChangedPersons[0] = 200, "Петров", 55
Петров повзрослел на год. Всё, просто забираем эту новую запись из arrChangedPersons без всяких проходов.
Дальше можно подумать, как не дублировать в arrChangedPersons данные которые не изменились, а писать туда только изменения. Допустим, у записи 200 поменялся и возраст, и фамилия - Петров стал 56-летним Петровским. Может тогда в arrChangedPersons[0] помещать односвязный список записей с полями:
arrPersons.rowIdx // индекс строки основного массива
arrPersons.colIdx // индекс столбца основного массива
newValue // новое значение, допустим, "Петровский"
nextPointer // указатель на следующее изменённое значение записи таблицы, относящеесе уже к возрасту 200-го
...
и т.д.
Alhymik
arrPersons.rowIdx даже не нужен, строка откуда пришли уже и так известна, а если запись удалена, то элемент arrChangedPersons будет содержать пустой список null. Ну и ещё м.б. отдельный массив для заинсерченных в пишушей сессии записей arrInsertedPersons, с проходом по нему после основного массива arrPersons. Если нужен частичный откат транзакции до точки останова, то надо сохранять всю историю изменений, будет чуть сложнее
developer7
Понял вашу идею.
И что получаем. На каждый цикл проверки делать дополнительную проверку 4 поля. И того если имеем миллион строк. Проверок делаем 2 миллиона при переборе (в случае когда условие например придётся на последнюю строку). А если учесть что это будут if с переходом — то скорее всего поломается оптимизация в проце для чистого перебора массива.
Далее мы должны в каждой таблице загруженной в RAM делать дополнительную колонку — например int. И того на 1M строк имеем лишние 4Mб данных.
Меня больше всего волнует потеря производительности для двойной проверки каждой строки.
developer7
Поразмыслив над вашей идеей всплыло куча подводных камней.
Например как учитывать новые добавленные строки? В старой таблице не будет этих строк и собственно не будет 4-го поля для анализа.
Как учитывать удалённые строки? Это придётся ещё тогда служебный столбец добавлять типа deleted. И его тоже анализировать при каждом цикле?
Одновременно транзакция write может работать только 1. Потому как на каждый write придётся дублировать все эти поля.
alan008
В MS SQL версионность включается на уровне базы настройкой READ COMMITTED SNAPSHOT ON/OFF