Будучи веб разработчиком, я регулярно использую реляционные базы данных в своей работе, но я не понимаю как они работают. Возникают следующие вопросы:
В каком формате данные будут сохранены(в памяти или на диске)
Когда они должны сохраняться на диск?
Почему первичный ключ(primary key) является единственным на одну таблицу?
Как происходит отмена транзакции?
Как формируются индексы?
Когда и как происходит полное сканирование таблицы?
Что такое подготовленное выражение(prepared statement)?
Другими словами, как работают базы данных?
Чтобы разобраться в этом, я создам базу данных с нуля. В качестве образца я использую sqlite, потому что он создан быть небольшим с меньшим количеством возможностей, чем mysql или postgresql,так что я надеюсь что я пойму как это работает. Вся база данных хранится в одном файле!
Sqlite
Более подробное внутренней работы sqlite находиться на ихсайте.
Архитектура sqlite
Запрос проходит по порядку через компоненты для получения или модификации данных. Фронтенд состоит из:
Токенизатора
Парсера
Генератора кода
На входе у нас sql запрос, на выходе байт код виртуальной машине sqlite (по сути скомпилированная программа которая может работать над базой данных).
Бэкенд состоит из:
Виртуальной машины(virtual machine)
Би-дерево (B-tree)
Pager
Интерфейса системы (os interface)
Виртуальная машина воспринимает байт код сгенерированный фронтендом как инструкции. Она затем может выполнят операции на одной или более таблиц или индексов, каждые из которых хранятся в структуре данных названной b-tree. По сути это машина является большим switch`ом, где каждый case отвечает за определенную инструкцию.
Каждое Би-дерево состоит из множества нод. Каждая нода длинной в один page. Би-дерево может получить страницы(page) из файла или сохранить это назад в файл благодаря выдаче команд pager`у.
Pager получает команды на чтение или запись данных. Он отвечает за чтение/запись по соответствующим смещениям(offsets) в файле базы данных. Он также сохраняет в кэше недавно открытые страницы(page) в памяти, и определяет когда эти страницы должны записаны обратно в файл.
Интерфейса системы это слой который отличается в зависимости для какой операционной системы sqlite был скомпилирован. В этом туториале, я не собираюсь добавлять поддержку для множества устройств.
Путь в тысячу вёрст начинается с первого шага, так что давайте начнём чего то менее простого: REPL
Создаем простой REPL
Sqlite запускает собственный repl когда мы запускаем его из командной строки:
~ sqlite3
SQLite version 3.16.0 2016-11-04 19:09:39
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> create table users (id int, username varchar(255), email varchar(255));
sqlite> .tables
users
sqlite> .exit
~
Чтобы сделать также, наша главная функция будет иметь бесконечный который печатает коммандную строку, получает строку от пользователя, затем обрабатывает эту строку:
int main(int argc, char* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
}
}
Мы определим InputBuffer как маленькую обертку вокруг состояния, которое нам необходимо хранить для взаимодействия с getline(). (подробнее об этом через минуту)
typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = (InputBuffer*)malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}
Далее, print_prompt() выводит строку пользователю. Мы делаем это перед чтением каждой строки ввода.
void print_prompt() { printf("db > "); }
Чтобы прочитать строку ввода, используем getline()
ssize_t getline(char **lineptr, size_t *n, FILE *stream);
lineptr: указатель на переменную которую мы используем как буфер содержащий прочитанную строку. Если он NULL, то память на него выделяет getline и таким образом память должна быть освобождена пользователем, даже если команда не выполниться.
n: Указатель на переменную, где сохраняется размер буфера.
stream: Что эта функция будет читать . Мы будем читать стандартный поток.
возвращаемое значение: Число прочитанных байт, которое может быть меньше, чем размер буфера.
Мы говорим getline сохранять прочитанную строку в input_buffer->buffer и размер выделенного буфера в input_buffer->buffer_length. Мы храним возвращаемое значение в input_buffer->input_length
buffer начинается как null, так что getline выделяет достаточно памяти чтобы держать строку ввода и делает buffer указателем на нее.
void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}
// Игнорировать символ новой строки
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}
Сейчас по хорошему создать функцию которая освобождает выделенную память для экземпляра InputBuffer и buffer элемента это же структуры (getline выделяет память для input_buffer->buffer в read_input).
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}
В конце, мы парсим и выполняем команду. Пока что распознается только одна команда: .exit, которая закрывает программу. В противном случае мы выводим сообщение ошибки и продолжаем цикл.
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
Давайте попробуем что мы написали!
~ ./db
db > .tables
Unrecognized command '.tables'.
db > .exit
~
Хорошо, мы имеем работающий REPL. В следующей части, мы начнем разработку нашего языка команд. Тем временем, вот вся программа этой части:
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}
void print_prompt() { printf("db > "); }
void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}
// Ignore trailing newline
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}
int main(int argc, char* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
}
}
sshikov
Во-первых, то что вы собираетесь создать, называется СУБД. СУБД, а не база данных. И это две разные вещи. Если бы вы собирались создать базу данных, вы бы взяли готовую СУБД, и написали бы описание своей базы и ее таблиц на языке SQL (если быть чуть точнее, DDL). И выполнили бы.
Во-вторых, чтобы научиться понимать, как это работает, полезно научиться абстрагироваться от мелких неважных деталей. Потому что начиная описывать с чтения команд с консоли, вы дойдете до конца к 50 статье. Если вообще дойдете.
Кроме всего прочего, чтение SQL откуда-то - это обычно так называемый клиент, или клиентская часть, а типичная СУБД этим вообще не занимается. Поэтому для понимания принципов это не особо-то и нужно. Было бы намного логичнее начать с чего-то типа слоя хранения, т.е. Б-деревьев, ну или парсера SQL (причем напрашивается просто взять готовый), ну или с той части, которая строит план запроса.
idd451289
Во всем согласен кроме готового парсера
Просто как по мне интерес в написании таких проектов это именно все самому, чтобы понять как работает. А то по такому принципу можно взять готовый парсер, готовое управление данными, и написать 2 строки для коннекта. Или ваще взять постгрес и не писать ничего)
sshikov
Я совершенно не против того, чтобы написать парсер SQL самому. Просто как по мне, это многовато для одного проекта, вместе с остальным.
Хотя конечно каждый сам оценивает свои способности и возможности.
piton_nsk
Если писать свой парсер, то можно понять как работают парсеры. Это безусловно полезное знание, но к СУБД отношения не имеет. Вот тут bnf грамматики для старых версий sql, можете оценить объем работы.
assad77
А по мне так норм, начинать с консоли. Это сразу и тесты на минималках, при этом с возможностью проверять любые тесткейзы. И мотивирует сильнее, тк сразу видно, как система функциональностью обрастает. Например акронис труимадж если не начался (я этого не застал), то развивался через такую консольную тулзу и это очень удобно. Сам всегда начинаю именно с тулзы. Юнит тесты уже потом. А интеграционные уже можно и на ней писать.
sshikov
Ну как бы вы вольны начинать с чего угодно. Я говорил о том, что у нормальной взрослой СУБД консоли как таковой обычно вообще нет, у нее интерфейс наружу - это как правило сокет, куда подключается тот или иной клиент, который уже может быть и читает с консоли. И начиная разработку с консоли, вы вот эту вот часть архитектуры просто опускаете, если не теряете. И вместо клиент-серверной архитектуры СУБД получается некая однопользовательская штука, умеющая выполнять запросы. А в данном конкретном случае вообще получился REPL, который не умеет пока ничего. Если уж задумывать REPL, то было бы желательно сделать автокомплит для SQL, например. А в текущей реализации вообще нет никаких признаков автокомплита. Более того, написанный уже код будет мешать его реализовать.
mynameco
В статье упоминается путь в 1000 верст. Я посчитал, это 1 385 454 шага, видимо столько будет статей еще.