Pull to refresh
72.56
Wunder Fund
Мы занимаемся высокочастотной торговлей на бирже

Написание минимальной подсистемы хранения данных в памяти для MySQL/MariaDB

Level of difficultyMedium
Reading time24 min
Views5.5K
Original author: Phil Eaton

Я потратил неделю, копаясь во внутренностях MySQL/MariaDB вместе с ещё примерно 80 разработчиками. Хотя MySQL и MariaDB — это, по большей части, одно и то же (я ещё к этому вернусь), я сосредоточился именно на MariaDB.

Раньше я никогда сам не собирал MySQL/MariaDB. В первый день «недели хакерства» я смог наладить локальную сборку MariaDB и твикнул код так, что запрос SELECT 23 возвращал 213. Сделал я и другой твик — такой, что запрос SELECT 80 + 20 возвращал 60. На второй день я смог заставить заработать простую UDF на C, благодаря которой запрос SELECT mysum(20, 30) давал 50.

Остаток недели я потратил, пытаясь разобраться с тем, как сделать минимальный движок для хранения данных в памяти. Именно о нём я и расскажу. Это — 218 строк кода на C++.

Мой движок поддерживает команды CREATE, DROP, INSERT и SELECT для таблиц, поля которых могут хранить только данные типа INTEGER. Он не является потокобезопасным. Я таким его и делал, так как у меня не было времени для того чтобы разобраться с блокировочными примитивами MariaDB.

Здесь я расскажу и о том, как API MariaDB для создания пользовательских хранилищ информации соотносится с подобным API Postgres. Это сравнение я смог провести на основании опыта, полученного при создании проекта на предыдущей хак‑неделе.

Весь код для этого материала можно найти в моём форке MariaDB на GitHub.

MySQL и MariaDB

Сейчас, прежде чем мы продолжим, расскажу о том, почему я постоянно пишу «MySQL/MariaDB».

MySQL распространяется по лицензии GPL (давайте полностью проигнорируем коммерческий вариант MySQL, который предлагает Oracle). Код проекта открыт. Но разработка ведётся, так сказать, за закрытыми дверями. Правда, каждый месяц, или около того, разработчики выкладывают свежий код.

MariaDB — это форк MySQL от создателя MySQL (который, как это случается, больше не участвует в разработке). Этот проект тоже распространяется по лицензии GPL (опять же — не будем принимать во внимание коммерческую разновидность MariaDB, которой занимается MariaDB Corporation). Код MariaDB открыт, процесс разработки тоже.

Когда некто устанавливает в своём дистрибутиве Linux «MySQL», то он, на самом деле, часто устанавливает MariaDB.

Эти две РСУБД, по большей части, совместимы. Исследуя их, я наткнулся на то, что поддержка SELECT.. FROM VALUES.. развивалась в них по‑разному. Некоторые различия задокументированы в базе знаний MariaDB. Но работать с этой базой знаний неудобно. Это привело меня к следующим выводам.

Документация MySQL превосходна. Её легко читать, в ней легко искать то, что нужно, она очень подробная. А вот документация MariaDB находится в процессе её создания. Простите, но я — не приверженец стоицизма: всего за неделю я дошёл до того, что прямо таки возненавидел работу с этой документацией. К счастью, каким‑то странным образом, она не кажется очень подробной. Полностью избежать подробностей в этой документации нельзя, так как нет гарантии того, что MySQL и MariaDB функционируют одинаково.

В итоге я потратил неделю, работая с MariaDB, так как у меня есть тяга к полностью опенсорсным проектам. Но я при этом заглядывал в документацию MySQL, надеясь на то, что она подойдёт и для MariaDB.

Теперь, когда вы ознакомились с положением дел, предлагаю перейти к самому интересному!

Подсистемы хранения данных

Зрелые СУБД часто поддерживают замену подсистем, отвечающих за хранение данных. Зачем это может понадобиться? Возможно — нужна подсистема, обеспечивающая хранение данных в памяти, использование которой позволит ускорить проведение интеграционных тестов. Возможно — нужно переключаться между B‑деревьями (структурами данных, оптимизированными для чтения) и LSM‑деревьями или неупорядоченными кучами (они оптимизированы для записи данных). Или, может, просто хочется попробовать библиотеку для хранения данных, созданную сторонним разработчиком (например — RocksDB, Sled или TiKV).

Сильная сторона возможности переключения между движками хранения данных, с точки зрения пользователя СУБД, заключается в том, что семантика и возможности СУБД при этом, в основном, не меняются. Но при этом база данных может чудесным образом ускориться при решении задач, которые раньше работали медленнее.

Замена слоя хранения данных СУБД оставляет в нашем распоряжении мощные средства управления пользователями, поддержку расширений, поддержку SQL, хорошо известный протокол потока данных. Модификации подвергается лишь метод, отвечающий за хранение реальных данных.

Существующие подсистемы хранения данных

MySQL/MariaDB особенно хорошо известны поддержкой пользовательских подсистем хранения данных. В документации к MySQL есть отличный раздел, посвящённый этой теме.

Там прямо говорится о том, что пользователям, вероятнее всего, стоит применять стандартный движок хранения данных. Но это предупреждение не выглядит как категорический запрет подобного, так как ничто другое, похоже, не указывает на особенности работы с другими движками.

В частности, в прошлом мне всегда были интересны CSV‑движки. Если посмотреть реальный код такого движка, там можно найти довольно серьёзные предупреждения относительно различных аспектов его работы.

Разница между кажущейся уверенностью составителей документации и кажущейся уверенностью контрибьютора посеяла во мне зерно сомнения.

В моём случае, плюс существования различных движков заключается в том, что они дают примеры реализации API подсистем хранения данных. Мне особенно пригодились эти примеры реализации движков из репозитория MariaDB: csv, blackhole, example и heap.

Движок heap — это полноценная подсистема хранения данных в памяти. Правда, «полноценность» означает «сложность». У меня возникло такое ощущение, что в экосистеме MariaDB найдётся место для урезанной версии подсистемы хранения данных в памяти.

Именно этому и посвящён данный материал! Начну с рассказа об ограничениях пользовательских подсистем хранения данных.

Ограничения

Возможность адаптировать движок хранения данных к рабочей нагрузке — это полезно и перспективно. Но тут имеются и ограничения, связанные с тем, как в СУБД спроектированы API хранения информации.

И Postgres, и MySQL/MariaDB в настоящее время обладают API для пользовательских подсистем хранения данных, построенные вокруг отдельных строк.

Колоночно-ориентированная обработка данных

Я уже писал о том, что пользовательские движки хранения информации позволяют переключаться между колоночно‑ и строко‑ориентированными хранилищами данных. Существуют две серьёзные причины применения хранилищ, ориентированных на колонки. Во‑первых — это даёт возможность сжатия данных. Во‑вторых — это ускоряет проведение операций над отдельными колонками.

Возможность сжатия данных на диске никуда не денется и в том случае, если нужно работать с отдельными строками в слое API хранения данных, так как сжатие может производиться при записи данных на диск. Но если нужно переходить от столбцов к строкам для работы с ними в API, теряются преимущества, связанные с обработкой сжатых столбцов в памяти.

Кроме того, если переход от столбцов к строкам должен производиться на уровне API хранения данных, перед передачей данных более высоким уровням системы, это приводит к потере возможности быстрого проведения операций над отдельными колонками. При таком подходе обработка данных будет проводиться с ориентацией на строки, а не на столбцы.

Всё это означает, что, хотя колоночно‑ориентированное хранение данных и можно организовать, сильные стороны такого подхода не особенно очевидно себя проявляют с учётом текущего устройства API и в MySQL/MariaDB, и в Postgres.

Векторизация

API построенное вокруг работы с отдельными строками, кроме того, устанавливает ограничения на объёмы операций векторизации. Пользовательская система хранения данных при этом может, так сказать, «под капотом», применять, в некотором объёме, векторизацию. Например — когда API хранения данных запрашивает одну строку — система может всегда заполнять некий буфер N строками и возвращать из буфера запрошенную строку. Но при этом, когда речь идёт об API, который оперирует отдельными строками, придётся, в какой‑то мере, пожертвовать производительностью.

Правда, стоит помнить о том, что если выполняются пакетные операции чтения и записи строк в пользовательском слое хранения данных, не обязательно применение некоей формы векторизации на уровне выполнения операций с данными. Если судить по предыдущему проведённому мной исследованию, то получается, что ни в MySQL/MariaDB, ни в Postgres не применяется векторизованное выполнение запросов. И это — не критика API хранения данных — это лишь факты, о которых стоит помнить.

Хранение и обработка информации

Главное здесь то, что независимо от того, как именно спроектированы API обработки и хранения данных, можно попытаться применить к слою хранения данных такие «оптимизации», которые могут оказаться неэффективными или даже вредными. Происходит такое из-за того, что слой обработки данных просто не пользуется или не может воспользоваться их полезными возможностями.

Ничто не вечно

Текущие ограничения API хранения данных не являются неотъемлемой частью того, как спроектированы MySQL/MariaDB или Postgres. Раньше оба эти проекта не поддерживали подключаемые хранилища. Можно представить себе, что в будущем к любому из этих проектов будет выпущен патч, который добавит в него поддержку пакетных операций чтения и записи строк. Всё это вместе может сделать целесообразнее и использование хранилищ, основанных на столбцах, и применение векторизованной обработки данных.

Даже в наши дни предпринимались агрессивные попытки обеспечить в Postgres полную поддержку хранилища и обработки данных, основанных на столбцах. Появлялись и проекты, призванные наделить Postgres возможностями по векторизованной обработке данных.

Я не так хорошо знаком с тем, что сейчас происходит в экосистеме MySQL, поэтому не могу говорить о том, что там сейчас делается в этих направлениях.

Локальный запуск отладочной сборки MariaDB

Теперь, когда мы немного сориентировались в ситуации, проведём отладочную сборку MariaDB!

$ git clone https://github.com/MariaDB/server mariadb
$ cd mariadb
$ mkdir build
$ cd build
$ cmake -DCMAKE_BUILD_TYPE=Debug ..
$ make -j8

Сборка занимает некоторое время. Когда я возился с Postgres (это — проект, написанный на C), сборка проекта на моём мощном Linux‑сервере занимала всего минуту. А сборка MySQL/MariaDB с нуля заняла 20–30 минут. Вот вам C++!

Но, к моему счастью, инкрементальные сборки MySQL/MariaDB для реализации твиков, выполняемые после первоначальной сборки, занимают примерно столько же времени, сколько инкрементальные сборки Postgres после небольших изменений.

После того, как сборка завершится, создадим базу данных.

$ ./build/scripts/mariadb-install-db --srcdir=$(pwd) --datadir=$(pwd)/db

Кроме того — создадим конфигурацию для этой базы данных.

$ echo "[client]
socket=$(pwd)/mariadb.sock

[mariadb]
socket=$(pwd)/mariadb.sock

basedir=$(pwd)
datadir=$(pwd)/db
pid-file=$(pwd)/db.pid" > my.cnf

Запустим сервер:

$ ./build/sql/mariadbd --defaults-extra-file=$(pwd)/my.cnf --debug:d:o,$(pwd)/db.debug
./build/sql/mariadbd: Can't create file '/var/log/mariadb/mariadb.log' (errno: 13 "Permission denied")
2024-01-03 17:10:15 0 [Note] Starting MariaDB 11.4.0-MariaDB-debug source revision 3fad2b115569864d8c1b7ea90ce92aa895cfef08 as process 185550
2024-01-03 17:10:15 0 [Note] InnoDB: !!!!!!!! UNIV_DEBUG switched on !!!!!!!!!
2024-01-03 17:10:15 0 [Note] InnoDB: Compressed tables use zlib 1.2.13
2024-01-03 17:10:15 0 [Note] InnoDB: Number of transaction pools: 1
2024-01-03 17:10:15 0 [Note] InnoDB: Using crc32 + pclmulqdq instructions
2024-01-03 17:10:15 0 [Note] InnoDB: Initializing buffer pool, total size = 128.000MiB, chunk size = 2.000MiB
2024-01-03 17:10:15 0 [Note] InnoDB: Completed initialization of buffer pool
2024-01-03 17:10:15 0 [Note] InnoDB: Buffered log writes (block size=512 bytes)
2024-01-03 17:10:15 0 [Note] InnoDB: End of log at LSN=57155
2024-01-03 17:10:15 0 [Note] InnoDB: Opened 3 undo tablespaces
2024-01-03 17:10:15 0 [Note] InnoDB: 128 rollback segments in 3 undo tablespaces are active.
2024-01-03 17:10:15 0 [Note] InnoDB: Setting file './ibtmp1' size to 12.000MiB. Physically writing the file full; Please wait ...
2024-01-03 17:10:15 0 [Note] InnoDB: File './ibtmp1' size is now 12.000MiB.
2024-01-03 17:10:15 0 [Note] InnoDB: log sequence number 57155; transaction id 16
2024-01-03 17:10:15 0 [Note] InnoDB: Loading buffer pool(s) from ./db/ib_buffer_pool
2024-01-03 17:10:15 0 [Note] Plugin 'FEEDBACK' is disabled.
2024-01-03 17:10:15 0 [Note] Plugin 'wsrep-provider' is disabled.
2024-01-03 17:10:15 0 [Note] InnoDB: Buffer pool(s) load completed at 240103 17:10:15
2024-01-03 17:10:15 0 [Note] Server socket created on IP: '0.0.0.0'.
2024-01-03 17:10:15 0 [Note] Server socket created on IP: '::'.
2024-01-03 17:10:15 0 [Note] mariadbd: Event Scheduler: Loaded 0 events
2024-01-03 17:10:15 0 [Note] ./build/sql/mariadbd: ready for connections.
Version: '11.4.0-MariaDB-debug'  socket: './mariadb.sock'  port: 3306  Source distribution

Обратите внимание на то, что при использовании флага --debug отладочные логи будут храниться в $(pwd)/db.debug. Неясно, почему отладочные журналы рассматриваются отдельно от того, что, как показано выше, выводится в консоль. Меня бы больше устроило, если бы всё попадало бы в одно и то же место.

В другом терминале запустим клиент и выполним запрос:

$ ./build/client/mariadb --defaults-extra-file=$(pwd)/my.cnf --database=test
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 3
Server version: 11.4.0-MariaDB-debug Source distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [test]> SELECT 1;
+---+
| 1 |
+---+
| 1 |
+---+
1 row in set (0.001 sec)

Ура! А теперь напишем пользовательский движок хранения данных!

Где писать код?

Обычно я, когда пишу расширение для какого‑нибудь проекта, ожидаю, что в репозитории этого проекта уже имеется некое расширение. Это ожидание оправдалось в тот раз, когда я писал подсистему хранения данных в памяти для Postgres. Но обычно расширения для Postgres размещаются в их собственных репозиториях.

Мне удалось создать и собрать UDF‑плагин за пределами дерева исходного кода MariaDB. Но когда дело дошло до сборки и загрузки этого расширения, я впустую потратил почти целый день (это — очень много в масштабах хак‑недели).

Расширения для MySQL/MariaDB легче всего создавать, прибегнув к инфраструктуре CMake, существующей внутри репозитория проекта. Конечно, есть способ воспроизвести эту инфраструктуру и за пределами репозитория. Но мне, за день, не удалось разобраться с тем, как это сделать, а тратить больше времени на это мне не хотелось.

По всей видимости, при разработке для MySQL/MariaDB написание расширений в форке проекта — это нормальное явление.

Когда я перешёл на этот подход — я смог очень быстро выйти на сборку и загрузку движка хранения данных. Поэтому именно так поступим и мы с вами.

Шаблон

Создадим в дереве исходного кода MariaDB, в поддиректории storage, новую папку.

$ mkdir storage/memem
В storage/memem/CMakeLists.txt добавим следующий текст.
# Copyright (c) 2006, 2010, Oracle and/or its affiliates. All rights reserved.
# 
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 2 of the License.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1335 USA

SET(MEMEM_SOURCES  ha_memem.cc ha_memem.h)
MYSQL_ADD_PLUGIN(memem ${MEMEM_SOURCES} STORAGE_ENGINE)

Всё это подключается к инфраструктуре сборки MySQL/MariaDB. А это значит, что, когда в следующий раз мы вызовем в ранее созданной директории build команду make — то она, кроме прочего, соберёт и наш код.

Класс подсистемы хранения данных

Хорошо было бы найти способ расширить возможности MySQL с помощью C (хотя бы из‑за того, что благодаря этому потом будет легче портировать код на другие языки). Но все встроенные методы хранения данных используют классы. Поэтому и мы создадим класс.

Класс, который мы должны реализовать, является экземпляром handler. В одном потоке имеется один экземпляр handler, соответствующий одному выполняющемуся запросу. (Postgres даёт каждому запросу собственный процесс, а MySQL даёт каждому запросу собственный поток.) При этом экземпляры handler многократно используются при выполнении разных запросов.

В handler имеется некоторое количество виртуальных методов, которые мы должны реализовать в своём подклассе. В большинстве из них мы не делаем ничего, кроме немедленного возврата из них. Эти простые методы будут реализованы в ha_memem.h. Методы с более сложной логикой будут реализованы в ha_memem.cc.

Подключим в ha_memem.h то, что нам нужно.

/* Copyright (c) 2005, 2010, Oracle and/or its affiliates. All rights reserved.

  This program is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation; version 2 of the License.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335  USA */

#ifdef USE_PRAGMA_INTERFACE
#pragma interface /* gcc class implementation */
#endif

#include "thr_lock.h"
#include "handler.h"
#include "table.h"
#include "sql_const.h"

#include <vector>
#include <memory>

Далее — определим структуры для хранилища данных.

typedef std::vector<uchar> MememRow;

struct MememTable
{
  std::vector<std::shared_ptr<MememRow>> rows;
  std::shared_ptr<std::string> name;
};

struct MememDatabase
{
  std::vector<std::shared_ptr<MememTable>> tables;
};

В ha_memem.cc реализуем глобальную сущность static MememDatabase* (не являющуюся потокобезопасной), к которой будут обращаться все экземпляры handler при поступлении запросов к ним. Нам, правда, нужно, чтобы это определение находилось бы в заголовочном файле, так как мы храним таблицу, к которой в некий момент времени выполняется запрос, в подклассе handler.

Это нужно для того чтобы каждый вызов write_row для записи одной строки, или вызов rnd_next для чтения строки, не требовал бы выполнения поиска в таблице, расположенной в памяти, N раз за один запрос.

И наконец — мы определяем подкласс handler и реализуем простейшие методы.

class ha_memem final : public handler
{
  uint current_position= 0;
  std::shared_ptr<MememTable> memem_table= 0;

public:
  ha_memem(handlerton *hton, TABLE_SHARE *table_arg) : handler(hton, table_arg)
  {
  }
  ~ha_memem()= default;
  const char *index_type(uint key_number) { return ""; }
  ulonglong table_flags() const { return 0; }
  ulong index_flags(uint inx, uint part, bool all_parts) const { return 0; }
  /* Следующие значения, заданные в директивах define, могут быть, при необходимости, увеличены */
#define MEMEM_MAX_KEY MAX_KEY     /* Максимальное разрешённое количество ключей */
#define MEMEM_MAX_KEY_SEG 16      /* Максимальное количество сегментов на ключ */
#define MEMEM_MAX_KEY_LENGTH 3500 /* Как в InnoDB */
  uint max_supported_keys() const { return MEMEM_MAX_KEY; }
  uint max_supported_key_length() const { return MEMEM_MAX_KEY_LENGTH; }
  uint max_supported_key_part_length() const { return MEMEM_MAX_KEY_LENGTH; }
  int open(const char *name, int mode, uint test_if_locked) { return 0; }
  int close(void) { return 0; }
  int truncate() { return 0; }
  int rnd_init(bool scan);
  int rnd_next(uchar *buf);
  int rnd_pos(uchar *buf, uchar *pos) { return 0; }
  int index_read_map(uchar *buf, const uchar *key, key_part_map keypart_map,
                     enum ha_rkey_function find_flag)
  {
    return HA_ERR_END_OF_FILE;
  }
  int index_read_idx_map(uchar *buf, uint idx, const uchar *key,
                         key_part_map keypart_map,
                         enum ha_rkey_function find_flag)
  {
    return HA_ERR_END_OF_FILE;
  }
  int index_read_last_map(uchar *buf, const uchar *key,
                          key_part_map keypart_map)
  {
    return HA_ERR_END_OF_FILE;
  }
  int index_next(uchar *buf) { return HA_ERR_END_OF_FILE; }
  int index_prev(uchar *buf) { return HA_ERR_END_OF_FILE; }
  int index_first(uchar *buf) { return HA_ERR_END_OF_FILE; }
  int index_last(uchar *buf) { return HA_ERR_END_OF_FILE; }
  void position(const uchar *record) { return; }
  int info(uint flag) { return 0; }
  int external_lock(THD *thd, int lock_type) { return 0; }
  int create(const char *name, TABLE *table_arg, HA_CREATE_INFO *create_info);
  THR_LOCK_DATA **store_lock(THD *thd, THR_LOCK_DATA **to,
                             enum thr_lock_type lock_type)
  {
    return to;
  }
  int delete_table(const char *name) { return 0; }

private:
  void reset_memem_table();
  virtual int write_row(const uchar *buf);
  int update_row(const uchar *old_data, const uchar *new_data)
  {
    return HA_ERR_WRONG_COMMAND;
  };
  int delete_row(const uchar *buf) { return HA_ERR_WRONG_COMMAND; }
};

Автор полноценной подсистемы хранения данных может серьёзно подойти к реализации всех этих методов. Мы же уделим внимание лишь 7 из них.

Для того чтобы завершить работу над шаблоном — перейдём в ha_memem.cc и поработаем над директивами include.

/* Copyright (c) 2005, 2012, Oracle and/or its affiliates. All rights reserved.

  This program is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation; version 2 of the License.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335  USA */

#ifdef USE_PRAGMA_IMPLEMENTATION
#pragma implementation // gcc: Class implementation
#endif

#define MYSQL_SERVER 1
#include <my_global.h>
#include "sql_priv.h"
#include "unireg.h"
#include "sql_class.h"

#include "ha_memem.h"

А теперь займёмся реализацией хранилища.

Реализация

Глобальная база данных

Прежде всего — надо объявить глобальный экземпляр MememDatabase*. Мы, кроме того, реализуем вспомогательную функцию для поиска в базе данных индекса таблицы по имени.

// ПРЕДУПРЕЖДЕНИЕ! Все операции доступа к `database` в этом коде не являются
// потокобезопасными. Так как это было написано в течение хак-недели, 
// у меня не было времени,чтобы достаточно хорошо разобраться 
// с рантаймом MySQL/MariaDB и написать потокобезопасную версию этого кода.
static MememDatabase *database;

static int memem_table_index(const char *name)
{
  int i;
  assert(database->tables.size() < INT_MAX);
  for (i= 0; i < (int) database->tables.size(); i++)
  {
    if (strcmp(database->tables[i]->name->c_str(), name) == 0)
    {
      return i;
    }
  }

  return -1;
}

Обратите внимание на то, что я, когда писал материал, заметил, что этот код рассчитан на существование лишь одной базы данных. А MySQL работает иначе. Каждый раз при вызове в MySQL USE... осуществляется переключение между базами данных. Можно выполнять запросы к таблицам, находящимся в разных базах данных. Реальная подсистема для хранения данных в памяти должна знать о существовании разных баз данных, а не только о существовании разных таблиц. Но для того чтобы не раздувать код, мы, в рамках данного материала, реализовывать это не будем.

Далее — создадим механизмы инициализации и завершения работы плагина.

Жизненный цикл плагина

Прежде чем регистрировать плагин в MariaDB, нам нужно настроить методы для его инициализации и для корректного завершения его работы.

Метод инициализации будет отвечать за инициализацию глобального объекта MememDatabase* database. Он задаст обработчик для создания новых экземпляров нашего класса handler. Ещё он задаст обработчик для удаления таблиц.

static handler *memem_create_handler(handlerton *hton, TABLE_SHARE *table,
                                     MEM_ROOT *mem_root)
{
  return new (mem_root) ha_memem(hton, table);
}

static int memem_init(void *p)
{
  handlerton *memem_hton;

  memem_hton= (handlerton *) p;
  memem_hton->db_type= DB_TYPE_AUTOASSIGN;
  memem_hton->create= memem_create_handler;
  memem_hton->drop_table= [](handlerton *, const char *name) {
    int index= memem_table_index(name);
    if (index == -1)
    {
      return HA_ERR_NO_SUCH_TABLE;
    }

    database->tables.erase(database->tables.begin() + index);
    DBUG_PRINT("info", ("[MEMEM] Deleted table '%s'.", name));

    return 0;
  };
  memem_hton->flags= HTON_CAN_RECREATE;

  // Инициализация глобальной базы данных, хранящейся в памяти.
  database= new MememDatabase;

  return 0;
}

Обратите внимание на то, что макрос DBUG_PRINT — это вспомогательный механизм для отладки кода, предоставляемый MySQL/MariaDB. Как было сказано выше, выходные отладочные сведения попадают в файл, заданный флагом --debug. Я, к сожалению, не смог разобраться с тем, как принудительно сбросить на диск поток, в который пишет этот макрос. У меня возникло такое ощущение, что время от времени, когда я ожидал найти лог, содержащий сведения об ошибке сегментации, найти его не удавалось. Файл часто содержал нечто, напоминающее логи, записанные лишь частично. Но, в случае отсутствия ошибок сегментации, в отладочный файл, всё равно, попадают логи, выводимые DBUG_PRINT.

Единственная обязанность, возлагаемая на функцию, обеспечивающую корректное завершение работы плагина — это удаление глобальной базы данных.

static int memem_fini(void *p)
{
  delete database;
  return 0;
}

Теперь мы можем зарегистрировать плагин!

Регистрация плагина

Регистрация метаданных (имени, версии и прочего) плагина, а так же его коллбэков, осуществляется с помощью maria_declare_plugin и maria_declare_plugin_end.

struct st_mysql_storage_engine memem_storage_engine= {
    MYSQL_HANDLERTON_INTERFACE_VERSION};

maria_declare_plugin(memem){
    MYSQL_STORAGE_ENGINE_PLUGIN,
    &memem_storage_engine,
    "MEMEM",
    "MySQL AB",
    "In-memory database.",
    PLUGIN_LICENSE_GPL,
    memem_init, /* Инициализация плагина */
    memem_fini, /* Деинициализация плагина */
    0x0100 /* 1.0 */,
    NULL,                          /* переменные состояния     */
    NULL,                          /* системные переменные     */
    "1.0",                         /* строка с номером версии  */
    MariaDB_PLUGIN_MATURITY_STABLE /* уровень зрелости плагина */
} maria_declare_plugin_end

Вот и всё! Теперь надо реализовать методы для записи и чтения строк, а так же — для создания новой таблицы.

Создание таблицы

Для создания таблицы мы сначала проверяем, чтобы у нас ещё не было таблицы с таким именем, и чтобы в ней были бы только поля типа INTEGER. Далее — мы выделяем под таблицу память и присоединяем её к глобальной базе данных.

int ha_memem::create(const char *name, TABLE *table_arg,
                     HA_CREATE_INFO *create_info)
{
  assert(memem_table_index(name) == -1);

  // Пока мы поддерживаем только поля типа INTEGER.
  uint i = 0;
  while (table_arg->field[i]) {
    if (table_arg->field[i]->type() != MYSQL_TYPE_LONG)
      {
    DBUG_PRINT("info", ("Unsupported field type."));
    return 1;
      }

    i++;
  }

  auto t= std::make_shared<MememTable>();
  t->name= std::make_shared<std::string>(name);
  database->tables.push_back(t);
  DBUG_PRINT("info", ("[MEMEM] Created table '%s'.", name));

  return 0;
}

Не так уж и сложно. Теперь займёмся командой, позволяющей добавлять в таблицу новые строки.

Вставка в таблицу новых строк

Тут нет метода, вызываемого в начале работы запроса INSERT. В нашем распоряжении имеется поле table в родительском классе handler. Воздействие на объект, представленный этим полем, осуществляется при выполнении запросов SELECT или INSERT. Текущую таблицу мы можем получить, обратившись к этому полю.

Так как у нас имеется переменная для std::shared_ptr<MememTable> memem_table в классе ha_memem — мы можем проверить её на NULL при вставке в таблицу новой строки. Если там и правда будет NULL — мы можем найти текущую таблицу и записать в this->memem_table ссылку на соответствующую таблицу MememTable.

Но тут имеется нечто большее, чем просто имя таблицы. Имя const char* name, передаваемое вышеописанному методу create(), очень напоминает полностью квалифицированное имя таблицы. Наблюдения показали, что, при создании таблицы y в базе данных test, значение const char* name равняется ./test/y. Префикс в виде точки — . — вероятно означает то, что база данных является локальной, но я в этом не уверен.

Итак, мы написали вспомогательный метод, который воссоздаёт полностью квалифицированное имя таблицы перед поиском такого имени в глобальной базе данных.

void ha_memem::reset_memem_table()
{
  // Сброс курсора таблицы.
  current_position= 0;

  std::string full_name= "./" + std::string(table->s->db.str) + "/" +
                         std::string(table->s->table_name.str);
  DBUG_PRINT("info", ("[MEMEM] Resetting to '%s'.", full_name.c_str()));
  assert(database->tables.size() > 0);
  int index= memem_table_index(full_name.c_str());
  assert(index >= 0);
  assert(index < (int) database->tables.size());

  memem_table= database->tables[index];
}

Затем мы можем этим воспользоваться в методе write_row для того чтобы выяснить текущую MememTable, на которую направлен запрос.

Но сначала отвлечёмся на то, как MySQL хранит строки.

API MySQL для работы со строками

Когда создают API пользовательского хранилища информации в Postgres — ожидается, что читать и писать данные будут, обращаясь к массиву Datum.

Выглядит совершенно разумно.

В MySQL операции чтения и записи направлены на массив байтов. Это, как по мне, не вполне адекватно. Конечно, можно, поверх этого всего, создать собственную систему сериализации/десериализации. Но мне кажется странным то, что всем заинтересованным лицам необходимо ориентироваться в таком вот совершенно непрозрачном API. Хотя этот API, конечно, документирован.

В нашей реализации будет опущена поддержка значений NULL. Мы будем поддерживать лишь поля INTEGER. Но это не отменяет необходимости знания о том, что первый байт будет занят. Мы, кроме того, предполагаем, что не столкнёмся более чем с одним байтом NULL.

Это и есть тот таинственный байтовый массив, на который направлены операции чтения и записи данных в read_row(uchar* buf) и write_row(const uchar* buf).

Вставка в таблицу новых строк (дубль два)

Для того чтобы не усложнять себе жизнь, мы собираемся сохранять строки в MememTable в том же виде, в котором ими оперирует MySQL.

int ha_memem::write_row(const uchar *buf)
{
  if (memem_table == NULL)
  {
    reset_memem_table();
  }

  // Исходим из предположения о том, что тут нет значений NULL.
  buf++;

  uint field_count = 0;
  while (table->field[field_count]) field_count++;

  // Сохраним строку в том же формате, в котором нам её передала MariaDB.
  auto row= std::make_shared<std::vector<uchar>>(
      buf, buf + sizeof(int) * field_count);
  memem_table->rows.push_back(row);

  return 0;
}

Такой подход, кроме того, сильно упрощает и чтение строк!

Чтение строк

Единственное небольшое различие между чтением и записью строки заключается в том, что MySQL/MariaDB сообщают о том, когда SELECT начинает сканирование таблицы.

Мы воспользуемся этой возможностью для того чтобы сбросить курсор current_row и поле memem_table. Дело в том, что, как уже было сказано, для одного запроса применяется один класс handler, но эти классы повторно используются при выполнении других запросов.

int ha_memem::rnd_init(bool scan)
{
  reset_memem_table();
  return 0;
}

int ha_memem::rnd_next(uchar *buf)
{
  if (current_position == memem_table->rows.size())
  {
    // Сбросим таблицу, хранящуюся в памяти, чтобы сделать заметнее логические ошибки.
    memem_table= NULL;
    return HA_ERR_END_OF_FILE;
  }
  assert(current_position < memem_table->rows.size());

  uchar *ptr= buf;
  *ptr= 0;
  ptr++;

  // Внутри системы строки хранятся в том формате, который нужен MariaDB
  // Поэтому их можно просто скопировать.
  std::shared_ptr<std::vector<uchar>> row= memem_table->rows[current_position];
  std::copy(row->begin(), row->end(), ptr);

  current_position++;
  return 0;
}

Всё готово!

Сборка и тестирование

Вернёмся в директорию build, которую мы создали в корневом разделе дерева исходного кода, и ещё раз запустим make -j8.

Остановим сервер (понадобится сделать что‑то вроде killall mariadbd, так как сервер не реагирует на Ctrl‑C). Перезапустим сервер.

По какой‑то причине этот плагин не нуждается в загрузке. Можно, в CLI MariaDB, выполнить команду SHOW PLUGINS;, и мы его увидим.

$ ./build/client/mariadb --defaults-extra-file=/home/phil/vendor/mariadb/my.cnf --database=test
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 5
Server version: 11.4.0-MariaDB-debug Source distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [test]> SHOW PLUGINS;
+-------------------------------+----------+--------------------+-----------------+---------+
| Name                          | Status   | Type               | Library         | License |
+-------------------------------+----------+--------------------+-----------------+---------+
| binlog                        | ACTIVE   | STORAGE ENGINE     | NULL            | GPL     |
...
| MEMEM                         | ACTIVE   | STORAGE ENGINE     | NULL            | GPL     |
...
| BLACKHOLE                     | ACTIVE   | STORAGE ENGINE     | ha_blackhole.so | GPL     |
+-------------------------------+----------+--------------------+-----------------+---------+
73 rows in set (0.012 sec)

Вот так! Чтобы создать таблицу с помощью плагина — надо установить ENGINE = MEMEM. Например — это может выглядеть как CREATE TABLE x (i INT) ENGINE = MEMEM.

Создадим скрипт, который позволяет опробовать движок memem в деле. Сохраним его в storage/memem/test.sql.

drop table if exists y;
drop table if exists z;

create table y(i int, j int) engine = MEMEM;
insert into y values (2, 1029);
insert into y values (92, 8);
select * from y where i + 8 = 10;

create table z(a int) engine = MEMEM;
insert into z values (322);
insert into z values (8);
select * from z where a > 20;

Запустим скрипт.

$ ./build/client/mariadb --defaults-extra-file=$(pwd)/my.cnf --database=test --table --verbose < storage/memem/test.sql
--------------
drop table if exists y
--------------

--------------
drop table if exists z
--------------

--------------
create table y(i int, j int) engine = MEMEM
--------------

--------------
insert into y values (2, 1029)
--------------

--------------
insert into y values (92, 8)
--------------

--------------
select * from y where i + 8 = 10
--------------

+------+------+
| i    | j    |
+------+------+
|    2 | 1029 |
+------+------+
--------------
create table z(a int) engine = MEMEM
--------------

--------------
insert into z values (322)
--------------

--------------
insert into z values (8)
--------------

--------------
select * from z where a > 20
--------------

+------+
| a    |
+------+
|  322 |
+------+

Здесь вы можете видеть мощь подсистем хранения информации! Наш движок поддерживает весь язык SQL. И это — даже несмотря на то, что мы сделали хранилище совсем не таким, как то, которое используется по умолчанию.

Мне наскучили занятия системами хранения данных в памяти

Конечно, мне скучно снова и снова делать одинаковые проекты в разных СУБД. Но представленный здесь проект отличается крайним минимализмом, что сильно упрощает портирование реализованной здесь системы хранения данных в ещё какую-нибудь среду.

Я, занимаясь этим проектом, стремился к тому, чтобы он был бы минималистичным, но осмысленным. И я, по крайней мере для себя, этой цели достиг.

О ChatGPT

Я уже писал о том, что исследования систем, подобные этому, не удалось бы провести в заданных мной временных рамках, если бы не ChatGPT. В частности — платная версия GPT4.

Ни документация MySQL, ни документация MariaDB не оказали мне должной помощи, например, в том, чтобы мгновенно выяснить то, как получить имя текущей таблицы при сканировании (член table класса handler).

А вот при работе с ChatGPT можно задавать вопросы, наподобие «In a MySQL C++ plugin, how do I get the name of the table from a handler class as a C string?». Иногда на такие вопросы даются правильные ответы, иногда — нет. Но, в любом случае, можно попробовать код. Если он собирается, то уместно будет говорить о том, что ChatGPT даёт, по меньшей мере, ответы, содержащие некую долю правды.

О, а приходите к нам работать? 🤗 💰

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

Tags:
Hubs:
Total votes 21: ↑21 and ↓0+21
Comments5

Articles

Information

Website
wunderfund.io
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
xopxe