В этой серии статей я залезу внутрь реляционной базы данных Firebird. Это не полное описание внутреннего устройства, и даже не описание архитектуры, а описание того, что конкретно делает база данных, выполняя свою основную функцию: обработку данных. Я не буду дробить архитектуру базы на подсистемы, не буду описывать подробно каждую подсистему и не буду рисовать архитектуру в виде прямоугольников, соединённых стрелками. Вместо этого я буду показывать куски кода, описывать, что они делают и как вызывают друг друга, чтобы выполнить конкретную работу. Я верю, что понимание того, как работает core-функционал приложения является самым сложным, но самым важным шагом, с которого надо начать. А нюансы, которые могут составлять 50%-70% кода, потом изучаются гораздо легче. Но легче будет потом, а в начале будет много скучного C++ кода.

Я очень плохо знаю C++, исходный код Firebird вижу в первый раз, как SQL-пользователь знаком с Firebird не очень глубоко. Поэтому качество моих исследований такое себе.

Начало

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

Кроме этого, нам понадобятся исходники. Они хранятся на github. Приятно видеть, что разработчики используют теги, качаем тег 5.0.2.

Я смело заявил, что буду концентрироваться на core-функционале базы данных, но даже это очень много, и я выберу для исследования какую-то часть core-функционала. Как известно, базы данных хранят данные где-то на диске, и умеют выполнять запросы к этим данным. Я не буду смотреть, как данные записываются, буду смотреть, как они читаются: хочу описать путь, который при выполнении SELECT-запросов проходят данные от хранения в виде байтов на диске и до отправки по сети.

Главная проблема с исходниками любого большого приложения - это откуда тебя, блин, начинать читать, чтобы процесс чтения привёл не в дебри, а куда надо? Точно не с main(), там будет "Авраам родил Исаака, Исаак родил Иакова...", так я никогда не дочитаю. Хотелось бы удачно ткнуться в такое место в самой середине кода, откуда достаточно близко к месту назначения, и поменьше боковых ответвлений. Вторая проблема состоит в том, что в исходниках описываются какие-то внутренние процессы, о которых я мало чего понимаю. Нужны подсказки о том, что происходит внутри базы в процессе выполнения запроса, ну то есть в динамике. В процессе своих копаний я буду чередовать статический (чтение кода) и динамический (отладка) подходы.

Трассировка

И начнём мы с динамического исследования. Среди исполняемых файлов глаз цепляется за "fbtracemgr.exe", который, судя по названию, поможет нам что-то потрассировать. Документация у Firebird немного странная: что-то есть в online-руководстве, но за многим приходится ходить в README-файлы из дистрибутива в каталоге doc , вот и этот случай такой. Файл README.trace-services.txt в целом полезен: выясняем, что этому инструменту нужен конфигурационный файл, в котором будут указаны события, которые я хотел бы потрассировать, и фильтры на базу данных или соединение. Есть примеры конфигурационных файлов, но очень смутила фраза "List of available events to trace is fixed and determined by the Firebird engine", то есть фиг вам, а не список. Ладно, пробую взять пример конфигурационного файла из этого документа и пример ключей для запуска, запускаю - хрен там, не запускается, что-то не нравится в конфигурационном файле. Тыкс, подумал я, ну тогда может попробуем не пользовательскую трассировку (то, чем я пытался заняться), а системную? Пошёл посмотреть на её конфигурационный файл fbtrace.conf, и увидел решение своих проблем. Во-первых, синтаксис того конфигурационного файла, который я взял из документации, неправильный: разделять имена и значения нужно не пробелом, а знаком равенства. Во-вторых, в системном конфиге трассировки в закомментированных строках перечислены события, которые можно трассировать. Круто, возвращаюсь к своей пользовательской трассировке, исправляю конфиг, запускаю, ошибок нет, ура. Делаю запрос, смотрю вывод трассировщика - пусто. Иду гуглить, нахожу вот такое . С одной стороны, в этом тикете написано, что исправлено, но нифига не исправлено. С другой стороны - там описана причина проблемы (способ подключения трассировщика к базе) и workaround (изменить способ), который у меня сработал. Для своего запроса я получил вот такой результат:

2025-06-04T12:38:55.7690 (13224:000000000B6729C0) START_TRANSACTION
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)
                (TRA_82, READ_COMMITTED | READ_CONSISTENCY | WAIT | READ_WRITE)

2025-06-04T12:38:55.7690 (13224:000000000B6729C0) PREPARE_STATEMENT
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)
                (TRA_82, READ_COMMITTED | READ_CONSISTENCY | WAIT | READ_WRITE)

Statement 310:
-------------------------------------------------------------------------------
SELECT * FROM orders
      0 ms

2025-06-04T12:38:55.7690 (13224:000000000B6729C0) EXECUTE_STATEMENT_START
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)
                (TRA_82, READ_COMMITTED | READ_CONSISTENCY | WAIT | READ_WRITE)

Statement 310:
-------------------------------------------------------------------------------
SELECT * FROM orders

2025-06-04T12:38:55.7700 (13224:000000000B6729C0) EXECUTE_STATEMENT_FINISH
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)
                (TRA_82, READ_COMMITTED | READ_CONSISTENCY | WAIT | READ_WRITE)

Statement 310:
-------------------------------------------------------------------------------
SELECT * FROM orders
1 records fetched
      0 ms, 5 fetch(es)

2025-06-04T12:38:55.7700 (13224:000000000B6729C0) CLOSE_CURSOR
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)

Statement 310:
-------------------------------------------------------------------------------
SELECT * FROM orders

2025-06-04T12:38:55.7700 (13224:000000000B6729C0) COMMIT_TRANSACTION
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)
                (TRA_82, READ_COMMITTED | READ_CONSISTENCY | WAIT | READ_WRITE)
      0 ms, 1 fetch(es), 1 mark(s)

2025-06-04T12:38:55.7850 (13224:000000000B6729C0) FREE_STATEMENT
        C:\DATABASES\TESTDB.FDB (ATT_36, SYSDBA:NONE, UTF8, TCPv4:192.168.100.3/59403)

Не то чтобы много информации, но что-то: видим, что внутри Firebird использует понятия STATEMENT, у которого есть стадии PREPARE и EXECUTE, а также понятие CURSOR, это пригодится. Было бы шикарно иметь трассировку поглубже в кишки, но и на этом спасибо.

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

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

Дисковые структуры и Data Page Manager

Попробуем зайти с другой стороны: что известно о том, как данные хранятся на диске. Есть вот такой документ, из которого понятно, что структура хранения данных именуется термином On-Disk Structure (кратко ODS), и вкратце состоит в следующем: файл базы данных разбит на страницы фиксированного размера, страницы бывают или служебные или относящиеся к какой-то таблице, не бывает страниц, относящихся к нескольким таблицам. Доступ к строкам таблицы разбит на два уровня: страницы указателей составлены в односвязный список и указывают на страницы данных, а страницы данных уже хранят строки-записи таблицы. Есть ещё хранение индексов, хранение фрагментированных строк, но пока пропустим это. Документ обрывается на самом интересном месте: внутренняя структура строки не расписана, непонятно, как лежат поля внутри, может справа налево? Запишем в копилочку вопросов, на которые будем искать ответы в коде.

Ну что, попробуем поискать в исходниках что-то, относящееся к ODS. В src/jrd/ сразу находим ods.h и ods.cpp . Находим структуру rhd, описывающую заголовок записи, пробуем поискать, где она используется, и... как-то тухло: только в каком-то коде валидации, а мне-то нужен код обращения к данным. Честно скажу, что на этом месте я забуксовал, начал искать, где применяется структура, описывающая страницу, потратил много времени, но в конце концов мозаика сложилась, и для краткости сразу скажу ответ: нужно было искать использования структуры rdhf, которая описывает заголовок фрагментированной записи, но первые 6 полей этой структуры совпадают с полями структуры rdh.

// Record header
struct rhd
{
	ULONG rhd_transaction;		// transaction id (lowest 32 bits)
	ULONG rhd_b_page;			// back pointer
	USHORT rhd_b_line;			// back line
	USHORT rhd_flags;			// flags, etc
	UCHAR rhd_format;			// format version
	UCHAR rhd_data[1];			// record data
};

// Record header for fragmented record
struct rhdf
{
	ULONG rhdf_transaction;		// transaction id (lowest 32 bits)
	ULONG rhdf_b_page;			// back pointer
	USHORT rhdf_b_line;			// back line
	USHORT rhdf_flags;			// flags, etc
	UCHAR rhdf_format;			// format version    // until here, same as rhd
	USHORT rhdf_tra_high;		// higher bits of transaction id
	ULONG rhdf_f_page;			// next fragment page
	USHORT rhdf_f_line;			// next fragment line
	UCHAR rhdf_data[1];			// record data
};

Структура rdhf очень интенсивно используется в файле dpm.epp. Хмм, странное расширение ".epp", очень похоже на ".cpp", и содержимое тоже похоже на C++, давайте поинтересуемся. В файлах, относящихся к сборке через cmake, можно найти (в builds/cmake/BuildFunctions.cmake) функцию epp_process, которая для этих файлов производит вызов каких-то вспомогательных инструментов. Не будем сейчас в это углубляться.

В dpm.epp работа с заголовками записей происходит в функции get_header(). Вот чуть подсокращённый код:

static bool get_header(WIN* window, USHORT line, record_param* rpb) {
	const data_page* page = (data_page*) window->win_buffer;
	if (line >= page->dpg_count)
		return false;

	const data_page::dpg_repeat* index = &page->dpg_rpt[line];
	if (index->dpg_offset == 0)
		return false;

	rhdf* header = (rhdf*) ((SCHAR *) page + index->dpg_offset);
	rpb->rpb_page = window->win_page.getPageNum();
	rpb->rpb_line = line;
	rpb->rpb_flags = header->rhdf_flags;

	if (!(rpb->rpb_flags & rpb_fragment)) {
		rpb->rpb_b_page = header->rhdf_b_page;
		rpb->rpb_b_line = header->rhdf_b_line;
		rpb->rpb_transaction_nr = Ods::getTraNum(header);
		rpb->rpb_format_number = header->rhdf_format;
	}

	ULONG header_size = (rpb->rpb_flags & rpb_long_tranum) ? RHDE_SIZE : RHD_SIZE;

	if (rpb->rpb_flags & rpb_incomplete) {
		rpb->rpb_f_page = header->rhdf_f_page;
		rpb->rpb_f_line = header->rhdf_f_line;
		header_size = RHDF_SIZE;
	}

	rpb->rpb_address = (UCHAR*) header + header_size;
	rpb->rpb_length = index->dpg_length - header_size;

	return true;
}

Как несложно понять, страница данных уже загружена в память, на неё указывает нечто window->win_buffer, и этот window приходит в функцию как аргумент. Номер строки таблицы также приходит как аргумент. Функция использует ods-структуры data_page и dpg_rpt, чтобы получить строку по номеру, это не поиск, а доступ по индексу. Параметр rpb (он же "record parameter block"), переданный в функцию, похоже играет роль выходного параметра, ибо главная работа функции состоит в заполнении его полей, таких как номер страницы, номер строки. После этого ods-структура rhdf используется для доступа к техническим полям строки (back_page, back_line, transaction, format), чтобы опять же скопировать их в rpb. Наконец, в поля rpb_address и rpb_length записываются адрес, с которого начинаются данные, хранящиеся в строке, и длина этих данных, это самый важный результат.

Файл dpm.epp является критически важной частью системы хранения данных, это "data page manager". Функция get_header() является внутренней (static) функцией этого файла, и вызывается из нескольких функций, таких как DPM_fetch(), DPM_fetch_back(), DPM_fetch_fragment(), DPM_get(), DPM_next(). Посмотрим на DPM_fetch()

bool DPM_fetch(thread_db* tdbb, record_param* rpb, USHORT lock)
{
	SET_TDBB(tdbb);

#ifdef VIO_DEBUG
	jrd_rel* relation = rpb->rpb_relation;
	VIO_trace(DEBUG_READS,
		"DPM_fetch (rel_id %u, record_param %" QUADFORMAT"d, lock %d)\n",
		relation->rel_id, rpb->rpb_number.getValue(), lock);

	VIO_trace(DEBUG_READS_INFO,
		"    record %" ULONGFORMAT":%d\n", rpb->rpb_page, rpb->rpb_line);
#endif

	const RecordNumber number = rpb->rpb_number;
	RelationPages* relPages = rpb->rpb_relation->getPages(tdbb);
	rpb->getWindow(tdbb).win_page = PageNumber(relPages->rel_pg_space_id, rpb->rpb_page);
	CCH_FETCH(tdbb, &rpb->getWindow(tdbb), lock, pag_data);

	if (!get_header(&rpb->getWindow(tdbb), rpb->rpb_line, rpb)) {
		CCH_RELEASE(tdbb, &rpb->getWindow(tdbb));
		return false;
	}

#ifdef VIO_DEBUG
	VIO_trace(DEBUG_READS_INFO,
		"    record  %" ULONGFORMAT":%d transaction %" ULONGFORMAT" back %"
		ULONGFORMAT":%d fragment %" ULONGFORMAT":%d flags %d\n",
		rpb->rpb_page, rpb->rpb_line, rpb->rpb_transaction_nr,
		rpb->rpb_b_page, rpb->rpb_b_line, rpb->rpb_f_page,
		rpb->rpb_f_line, rpb->rpb_flags);
#endif
	rpb->rpb_number = number;

	return true;
}

Ого, есть какой-то VIO_trace(), вот это бы мне очень пригодилось для понимания того, как всё работает в динамике! Но эта штука требует перекомпиляции Firebird, поэтому пока без неё, жалко-жалко. Занятно посмотреть на параметры функции DPM_fetch(), тут есть какой-то thread_db, и уже знакомый нам rpb. Из этого rpb функция извлекает и номер строки/записи rpb_line, и номер страницы rpb_page. Предположу, что CCH_FETCH пытается получить страницу из кеша или с диска в window->win_buffer. И для чтения заголовка записи вызывается уже известный нам get_header(). То есть rpb в каких-то из полей содержит входные параметры, а в другие его поля сохраняются результаты работы (и буфер памяти не отдаём, конечно). Одно непонятно, нафига в предпоследней строке присваивается rpb_number, он же и так хранил то значение, которое в него пытаются присвоить, и не очень понятно, зачем этот RecordNumber был прочитан вообще, он же нигде не используется. Такое ощущение, что автор кода думал, что этот rpb_number перезатрётся где-то внутри get_header().

Функция DPM_get() работает очень похоже, но делает чуть больше работы. Её код я для наглядности посокращал посильнее:

bool DPM_get(thread_db* tdbb, record_param* rpb, SSHORT lock_type) {
    Database* dbb = tdbb->getDatabase();
	WIN* window = &rpb->getWindow(tdbb);

	// Find starting point
	ULONG pp_sequence;
	USHORT slot, line;
	rpb->rpb_number.decompose(dbb->dbb_max_records, dbb->dbb_dp_per_pp, line, slot, pp_sequence);

	// Check if the record number is OK
	if (rpb->rpb_number.getValue() < 0)
		return false;

	RelationPages* relPages = rpb->rpb_relation->getPages(tdbb);

	const ULONG dpSequence = rpb->rpb_number.getValue() / dbb->dbb_max_records;
	ULONG page_number = relPages->getDPNumber(dpSequence);

	if (page_number) {
		window->win_page = page_number;
		data_page* dpage = (data_page*) CCH_FETCH(tdbb, window, lock_type, pag_undefined);

		const bool pageOk =
			dpage->dpg_header.pag_type == pag_data &&
			!(dpage->dpg_header.pag_flags & (dpg_secondary | dpg_orphan)) &&
			dpage->dpg_relation == rpb->rpb_relation->rel_id &&
			dpage->dpg_sequence == dpSequence &&
			(dpage->dpg_count > 0);

		if (pageOk && get_header(window, line, rpb) &&
			!(rpb->rpb_flags & (rpb_blob | rpb_chained | rpb_fragment)))
		{
			return true;
		}

		CCH_RELEASE(tdbb, window);

		if (pageOk)
			return false;
	}

	// Find the pointer page, data page, and record
	pointer_page* page = get_pointer_page(tdbb, rpb->rpb_relation,
		relPages, window, pp_sequence, LCK_read);

	if (!page)
		return false;

	page_number = page->ppg_page[slot];
	relPages->setDPNumber(dpSequence, page_number);

	if (page_number)
	{
		CCH_HANDOFF(tdbb, window, page_number, lock_type, pag_data);
		if (get_header(window, line, rpb) &&
			!(rpb->rpb_flags & (rpb_blob | rpb_chained | rpb_fragment)))
		{
			return true;
		}
	}

	CCH_RELEASE(tdbb, window);

	return false;
}

Первое важное отличие состоит в том, что RecordNumber разбивается на компоненты: слот страницы и номер строки, причём то, как эти компоненты запакованы в номере, определяется параметрами базы данных. Второе отличие в том, что на основе номера строки мы можем не сразу попасть на страницу данных, а сначала на страницу указателей. В остальном всё сильно похоже на DPM_fetch(). Похоже, что DPM_fetch() оптимизирована под навигацию по цепочкам страниц, а DPM_get() - под чтение самих данных.

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

VIO

Из всех мест, где используются DPM_fetch() и DPM_get(), выделяется файл vio.cpp, и открыв его мы поймём, что это самое главное место во всей системе хранения данных, именно тут реализовано многоверсионное хранение строк таблиц.

Функций тут много, начнём с чего-нибудь попроще, например с VIO_get():

bool VIO_get(thread_db* tdbb, record_param* rpb, jrd_tra* transaction, MemoryPool* pool) {
	SET_TDBB(tdbb);

	const USHORT lock_type = (rpb->rpb_stream_flags & RPB_s_update) ? LCK_write : LCK_read;

	if (!DPM_get(tdbb, rpb, lock_type) ||
		!VIO_chase_record_version(tdbb, rpb, transaction, pool, false, false))
	{
		return false;
	}

	if (pool && !(rpb->rpb_runtime_flags & RPB_undo_data)) 	{
		if (rpb->rpb_stream_flags & RPB_s_no_data) 		{
			CCH_RELEASE(tdbb, &rpb->getWindow(tdbb));
			rpb->rpb_address = NULL;
			rpb->rpb_length = 0;
		}
		else
			VIO_data(tdbb, rpb, pool);
	}

	tdbb->bumpRelStats(RuntimeStatistics::RECORD_IDX_READS, rpb->rpb_relation->rel_id);
	return true;
}

Выглядит просто: оптимистично пытаемся взять ту версию строки, которая самая новая, если она не подходит для текущей транзакции, ищем в предыдущих версиях. Потом вызовем VIO_data(). Ну давайте посмотрим на эту VIO_data(), из которой я для краткости убрал отладочную трассировку и комментарии, в которых какой-то разработчик поучает некую Вирджинию.

void VIO_data(thread_db* tdbb, record_param* rpb, MemoryPool* pool) {
	SET_TDBB(tdbb);

	jrd_rel* const relation = rpb->rpb_relation;

	// If we're not already set up for this format version number, find
	// the format block and set up the record block.  This is a performance
	// optimization.

	Record* const record = VIO_record(tdbb, rpb, NULL, pool);
	const Format* const format = record->getFormat();

	record->setTransactionNumber(rpb->rpb_transaction_nr);

	// If the record is a delta version, start with data from prior record.
	UCHAR* tail;
	const UCHAR* tail_end;

	Difference difference;

	// Primary record version not uses prior version
	Record* prior = (rpb->rpb_flags & rpb_chained) ? rpb->rpb_prior : nullptr;

	if (prior) {
		tail = difference.getData();
		tail_end = tail + difference.getCapacity();

		if (prior != record)
			record->copyDataFrom(prior);
	} else {
		tail = record->getData();
		tail_end = tail + record->getLength();
	}

	// Set up prior record point for next version
	rpb->rpb_prior = (rpb->rpb_b_page && (rpb->rpb_flags & rpb_delta)) ? record : NULL;

	// Snarf data from record
	tail = unpack(rpb, tail_end - tail, tail);

	RuntimeStatistics::Accumulator fragments(tdbb, relation, RuntimeStatistics::RECORD_FRAGMENT_READS);

	if (rpb->rpb_flags & rpb_incomplete) {
		const ULONG back_page  = rpb->rpb_b_page;
		const USHORT back_line = rpb->rpb_b_line;
		const USHORT save_flags = rpb->rpb_flags;
		const ULONG save_f_page = rpb->rpb_f_page;
		const USHORT save_f_line = rpb->rpb_f_line;

		while (rpb->rpb_flags & rpb_incomplete) {
			DPM_fetch_fragment(tdbb, rpb, LCK_read);
			tail = unpack(rpb, tail_end - tail, tail);
			++fragments;
		}

		rpb->rpb_b_page = back_page;
		rpb->rpb_b_line = back_line;
		rpb->rpb_flags = save_flags;
		rpb->rpb_f_page = save_f_page;
		rpb->rpb_f_line = save_f_line;
	}

	CCH_RELEASE(tdbb, &rpb->getWindow(tdbb));

	// If this is a delta version, apply changes
	ULONG length;
	if (prior) {
		const auto diffLength = tail - difference.getData();
		length = difference.apply(diffLength, record->getLength(), record->getData());
	} else {
		length = tail - record->getData();
	}

	if (format->fmt_length != length) {
		BUGCHECK(183);			// msg 183 wrong record length
	}

	rpb->rpb_address = record->getData();
	rpb->rpb_length = format->fmt_length;
}

Пока проскочим начальную часть про Record и Format, и доберёмся до вызова unpack() в строчке 39. Вспомним, что DPM-функция get_header() уже локализовала строку в памяти, заполнив поля rpb_address и rpb_length. Сама unpack() проверяет флаг rpb_not_packed, если он выставлен, то распаковку производить не надо, копируются байты, возможно дополняются нулями в конце. Этот функционал появился в версии 5.0 (статья на Хабре). Если же флага нет, вызывается RLE-распаковка.

inline UCHAR* unpack(record_param* rpb, ULONG outLength, UCHAR* output) {
	if (rpb->rpb_flags & rpb_not_packed) {
		const auto length = MIN(rpb->rpb_length, outLength);
		memcpy(output, rpb->rpb_address, length);
		output += length;

		if (rpb->rpb_length > length) {
			// Short records may be zero-padded up to the fragmented header size.
			// Take it into account while checking for a possible buffer overrun.

			auto tail = rpb->rpb_address + length;
			const auto end = rpb->rpb_address + rpb->rpb_length;

			while (tail < end) {
				if (*tail++)
					BUGCHECK(179);	// msg 179 decompression overran buffer
			}
		}

		return output;
	}
	return Compressor::unpack(rpb->rpb_length, rpb->rpb_address, outLength, output);
}

UCHAR* Compressor::unpack(ULONG inLength, const UCHAR* input,
						  ULONG outLength, UCHAR* output)
{
	const auto end = input + inLength;
	const auto output_end = output + outLength;

	while (input < end) {
		const int length = (signed char) *input++;

		if (length < 0) {
			auto zipLength = (unsigned) -length;

			if (length == -1) {
				zipLength = get_short(input);
				input += sizeof(USHORT);
			} else if (length == -2) {
				zipLength = get_long(input);
				input += sizeof(ULONG);
			}

			if (input >= end || output + zipLength > output_end)
				BUGCHECK(179);	// msg 179 decompression overran buffer

			const auto c = *input++;
			memset(output, c, zipLength);
			output += zipLength;
		} else 	{
			if (input + length > end || output + length > output_end)
				BUGCHECK(179);	// msg 179 decompression overran buffer

			memcpy(output, input, length);
			output += length;
			input += length;
		}
	}

	if (output > output_end)
		BUGCHECK(179);	// msg 179 decompression overran buffer

	return output;
}

Тут всё не очень сложно: если контрольный байт меньше 0 (выставлен старший бит), то нужно развернуть одно значение в последовательность одинаковых значений. По сравнению с документом, описывающим хранение данных на диске, тут добавлена поддержка длинных и очень длинных последовательностей, это также появилось в Firebird 5.0 .

Есть большое подозрение, пока умозрительное, что этот код "горячий", то есть работает часто, поэтому его производительность важна. Первый момент: строка распаковывается вся до конца, даже если она содержит 500 полей, а прочитать из неё нужно будет только первые два поля. Для незапакованных строк также будет сделан memcpy() всей строки. Это архитектурная особенность, про которую попозже ещё поговорим. Второй момент, в котором я не так уверен: немного смущает, что для коротких последовательностей, которые, как мне кажется, встречаются чаще, будут выполены два if-условия, хотя можно было бы одно. Но хрен знает, может предсказатель ветвлений процессора хорошо обработает эту ситуацию.

Возвращаемся к VIO_data(), в котором после вызова unpack() находится код, обрабатывающий фрагментированные записи. После этого стоит CCH_RELEASE, который, похоже, отдаёт страницу в кеш. Логично, поскольку содержимое записи мы оттуда уже скопировали. Я не уверен на 100%, но возможно, что страница была заблокирована, пока мы к ней обращались, поэтому лучше, чтобы CCH_RELEASE был как можно раньше. Логично, что код, обрабатывающий delta-версии записей (версии, которые содержат только то, что поменялось), работает уже после CCH_RELEASE. Интересно, обработку фрагментированных записей не следует ли тоже производить уже после CCH_RELEASE? Подумаю об этом попозже.

В самом конце VIO_data() мы переставляем поля rpb_address и rpb_length на распакованное содержимое строки. Но, минуточку! А куда мы его распаковали-то? Мы проскочили немного кода в начале функции, который работает с Record и Format, настало время вернуться туда.

Функция VIO_Record() совсем небольшая:

Record* VIO_record(thread_db* tdbb, record_param* rpb, const Format* format, MemoryPool* pool) {
	SET_TDBB(tdbb);
	// If format wasn't given, look one up
	if (!format)
		format = MET_format(tdbb, rpb->rpb_relation, rpb->rpb_format_number);

	Record* record = rpb->rpb_record;

	if (!record) {
		if (!pool)
			pool = rpb->rpb_relation->rel_pool;

		record = rpb->rpb_record = FB_NEW_POOL(*pool) Record(*pool, format);
	}
	record->reset(format);
	return record;
}

Сначала проверяется, нельзя ли переиспользовать rpb_record, если там ничего нет, создаётся новый из пула, и сохраняется в rpb. Ох уж этот rpb, это прямо какой-то гигантский контекст, который передаётся из функции в функцию, он же служит для переиспользования. Из VIO_data() функция VIO_record() вызывается с параметром format, равным null, поэтому всегда будет вызван MET_format().

Metadata

Давайте посмотрим на эту функцию, она находится в файле met.epp. Судя по комментариям в начале файла, это функционал, относящийся к метаданным. Итак, функция MET_format():

Format* MET_format(thread_db* tdbb, jrd_rel* relation, USHORT number) {
	SET_TDBB(tdbb);
	Attachment* attachment = tdbb->getAttachment();
	Database* dbb = tdbb->getDatabase();

	Format* format;
	vec<Format*>* formats = relation->rel_formats;
	if (formats && (number < formats->count()) && (format = (*formats)[number])) {
		return format;
	}

	format = NULL;
	AutoCacheRequest request(tdbb, irq_r_format, IRQ_REQUESTS);

	FOR(REQUEST_HANDLE request)
		X IN RDB$FORMATS WITH X.RDB$RELATION_ID EQ relation->rel_id AND
			X.RDB$FORMAT EQ number
	{
		blb* blob = blb::open(tdbb, attachment->getSysTransaction(), &X.RDB$DESCRIPTOR);

		// Use generic representation of formats with 32-bit offsets

		HalfStaticArray<UCHAR, BUFFER_MEDIUM> buffer;
		blob->BLB_get_data(tdbb, buffer.getBuffer(blob->blb_length), blob->blb_length);
		unsigned bufferPos = 2;
		USHORT count = buffer[0] | (buffer[1] << 8);

		format = Format::newFormat(*relation->rel_pool, count);

		Array<Ods::Descriptor> odsDescs;
		Ods::Descriptor* odsDesc = odsDescs.getBuffer(count);
		memcpy(odsDesc, buffer.begin() + bufferPos, count * sizeof(Ods::Descriptor));

		for (Format::fmt_desc_iterator desc = format->fmt_desc.begin();
			 desc < format->fmt_desc.end(); ++desc, ++odsDesc)
		{
			*desc = *odsDesc;
			if (odsDesc->dsc_offset)
				format->fmt_length = odsDesc->dsc_offset + desc->dsc_length;
		}

		const UCHAR* p = buffer.begin() + bufferPos + count * sizeof(Ods::Descriptor);
		count = p[0] | (p[1] << 8);
		p += 2;

		Array<UCHAR> tmpArray;	// must be aligned for the maximum datatype align requirement
		while (count-- > 0) {
			USHORT offset = p[0] | (p[1] << 8);
			p += 2;

			Ods::Descriptor odsDflDesc;
			memcpy(&odsDflDesc, p, sizeof(odsDflDesc));
			p += sizeof(Ods::Descriptor);

			dsc desc = odsDflDesc;

			desc.dsc_address = tmpArray.getBuffer(desc.dsc_length, false);
			memcpy(desc.dsc_address, p, desc.dsc_length);
			EVL_make_value(tdbb, &desc, &format->fmt_defaults[offset], relation->rel_pool);

			p += desc.dsc_length;
		}
	}
	END_FOR

	if (!format)
		format = Format::newFormat(*relation->rel_pool);

	format->fmt_version = number;

	// Link the format block into the world

	formats = relation->rel_formats =
		vec<Format*>::newVector(*relation->rel_pool, relation->rel_formats, number + 1);
	(*formats)[number] = format;

	return format;
}

В начале проверяется, вдруг форматы уже закешированы в relation->rel_formats, если да, то забираются оттуда. А вот если нет, то начинается интересное! Видимо, epp-файлы могут содержать специальный синтаксис итерации по строкам таблицы, похожий на SQL-синтаксис. Из системной таблицы RDB$FORMATS зачитывается формат с указанным номером из BLOB-поля (BLOB - это SQL-тип для больших неструктурированных байтов) и распаковывается. Распаковка несложная: первые два байта - это количество полей, и эти поля парсятся при помощи Ods::Descriptor. Ого, мы начали с ODS, и вернулись опять к нему же, давайте посмотрим на него.

struct Descriptor
{
	UCHAR	dsc_dtype;
	SCHAR	dsc_scale;
	USHORT	dsc_length;
	SSHORT	dsc_sub_type;
	USHORT	dsc_flags;
	ULONG	dsc_offset;
};

Собственно, вот и он, формат хранения метаданных для таблиц. Самое интересное тут dsc_offset, он вроде как должен позволять обращаться к полям строки по индексу, не сканируя строку слева направо. Пока не очень понятно, как это работает для полей переменной длины типа VARCHAR, но до этого мы ещё доберёмся. Однако это смещение активно используется в MET_format(), в цикле for для расчёта размера длины строки: итерируемся по полям, текущее поле каждый раз определяет размер. Ну ладно, хотя можно было бы сразу последнее поле проверить. Второй цикл используется для распаковки значений по-умолчанию для полей. В конце функции созданный Format сохраняется в relation.rel_formats для переиспользования, поскольку он неизменяемый.

Теперь мы сможем ответить на вопрос, куда же мы всё таки распаковывали строку. В функции VIO_Record() есть вызов record->reset(format) , внутри которого происходит вот что:

void reset(const Format* format = NULL) {
	if (format && format != m_format) {
		m_data.resize(format->fmt_length);
		m_format = format;
	}
	m_fake_nulls = false;
}

Поле m_data - это Firebird::Array , контейнер-массив, который может менять свой размер.

Насчёт того, что у каждого поля таблицы есть offset, и как это сочетается с наличием полей переменной длины, я предположу вот что: нет в Firebird никаких полей переменной длины, VARCHAR хранится так же, как CHAR, но хвост строки состоит из нулевых байтов, которые сжимаются RLE-методом.

Должен признать, что наше внимательное изучение того, как устроен Format, не особо пригодилось: в том коде, который мы рассматривали в этой статье, только функция VIO_data() использует fmt_length для проверки, что прочитанные данные не превысили ожидаемый размер. Но очень важно запомнить, что формат сцепляется с байтами данных строки. В следующих частях мы увидим, как это используется.

Заключение

На этом месте стоит сделать паузу и вкратце обобщить то, что мы смогли понять. Самой высокоуровневой функцией, до которой мы смогли подняться, была VIO_get(), предназначенная для чтения строки из таблицы. Она принимает контекст rpb, в котором указаны таблица (relation) и некий номер строки, в котором запакованы номер страницы и номер записи внутри страницы. Вызывается DPM_get(), чтобы получить страницу (возможно, заблокировать её) и найти запись на странице, расположение записи сохраняется в контекст. После чего вызывается VIO_data(), который попробует распаковать запись в Firebird::Array и связать её с метаданными. Мы ответили на вопрос, как хранятся поля внутри строк: в соответствии с форматом, который хранится в метаданных таблицы и возвращается вместе со строкой.

В высокоуровневой документации по Firebird упоминается, что при обновлении строк создаётся новая версия, в которой хранятся только изменившиеся столбцы. Как мы видели, в том способе, которым хранится строка, нет никакой структуры, которая бы это описывала. Из того, что мы узнали, можно предположить, что в этом случае создаётся новый Format, сохраняется в системную таблицу форматов, и номер формата указывается при сохранении строки. Если так, то я думаю, что функция, которая читает такие строки должна понимать, что Format не соответствует полной строке, найти полную строку и как-то их объединить. Такая функция, скорее всего, находится где-то в vio.cpp, и она, я думаю, вызывает VIO_data().

Похоже, что механизм неструктурированного чтения/блокирования страниц спрятан в функциях CCH_FETCH()/CCH_RELEASE() , эту низкоуровневую подсистему мы совершенно пропустили, будем надеяться, что вернёмся к ней в будущем.

В следующей части попробуем подняться к более высоким слоям логики работы с данными.

Самостоятельная работа

Для тех, кому понравилось и кто хочет улучшить познания, думая самостоятельно, извольте!

  1. Алгоритм RLE-декодирования работает так: если контрольный байт меньше нуля, то берём его по модулю, и это будет количество повторов, которые нужно сделать для последующего байта. Два специальных значения: -1 и -2 используются для обозначения длинных и супер-длинных повторов. Почему именно эти значения?

  2. Мы видели, что у таблицы (relation) может быть несколько форматов. Как Firebird определяет, какой именно брать и сцеплять со строкой?

  3. Представим гипотетическую ситуацию: мы хотим извлечь только некоторое количество начальных полей из строки. Для этого мы подготавливаем нужный нам Format, поля которого совпадают с начальными полями таблицы. Выглядит так, что unpack() корректно обработает случай rpb_record_not_packed, а вот Compressor::unpack() - не отработает. Как его починить? Можно ли это сделать способом, который не ухудшит производительность?

  4. В заключении было высказано предположение, что многоверсионный механизм хранения строк использует механизм Format для того, чтобы при обновлении сохранять только изменившиеся столбцы. Как, используя SQL, это проверить?

Что не вошло в статью

Стало мне интересно, как компилятор скомпилирует RLE-декодировку Compressor::unpack(), важная же функция! Вдруг там всё компилятором супер-оптимизировано?
Запускаю IDA Freeware, прошу декомпилировать firebird.exe, указываю путь к firebird.pdb, он декомпилирует. Ищу функции, и их нет. Вообще ни одной функции, которые бы начинались с "VIO_". Что за ерунда? Может, файл не тот? Глянул, firebird.exe занимает около мегабайта, это очень странно, слишком мало. Заглянул в plugins/ , там лежит engine13.dll, он занимает 8 мегабайт, и pdb-файл для него имеется. Открываю его в IDA, ффух, это оно. Посмотрел функцию, откомпилировано без сюрпризов, все if-ы на своих местах (плохих).

Комментарии (0)