Будучи веб разработчиком, я регулярно использую реляционные базы данных в своей работе, но я не понимаю как они работают. Возникают следующие вопросы:
- В каком формате данные будут сохранены(в памяти или на диске) 
- Когда они должны сохраняться на диск? 
- Почему первичный ключ(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 шага, видимо столько будет статей еще.