Привет, Хабр! Меня зовут Артем Абакумов, я разработчик СУБД РЕД База Данных в РЕД СОФТ.

Представим, что на проде виснет база. Счет идет на секунды, а утилиты мониторинга системы с красивой визуализацией показывают, как сервер медленно умирает из-за нехватки ресурсов. Чтобы выяснить, какое именно подключение (и каким SQL-запросом) все положило, приходится лезть в логи, запускать трассировку и ковыряться в файлах, пытаясь восстановить хранологию соыбтий. Проблема давно насущаная, поэтому мы решили создать инструмент, который будет непрерывно собирать все метрики, влияющие на работу базы данных, и при этом не тормозить систему. Звучит как отличная идея, но на пути к правильной архитектуре нам пришлось все трижды переписывать, воевать с разделяемой памятью и изобретать свои lock-free механизмы. В этой статье я расскажу историю наших архитектурных граблей и итоговое решение.

Почему стандартных инструментов мало

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

Поэтому опытный админ пойдет другим путем: скорее всего, тяжелый запрос использует индексы, сортировку и подобные механизмы. Все они, чтобы не исчерпать всю ОЗУ, используют временные файлы. В РЕД Базе Данных к имени временного файла дописываются ID подключения и запроса. Например: rdb_sort_att271590_stmt528_xJ9R0n. Если подобные файлы занимают много места, проблемная сессия обнаружена. Но чтобы идентифицировать конкретный запрос, опять придется лезть в таблицы мониторинга.

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

Трейс бывает двух форматов: текстовый и бинарный. Первый удобно читать, но он быстро раздувается под нагрузкой. Приходится использовать сторонние утилиты, например, РБД Эксперт, чтобы распарсить его и найти подозрительные события.

Бинарный трейс лучше приспособлен к интеллектуальному анализу, так как позволяет обращаться к событиям через SQL.

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

Это краткое пояснение показывает две проблемы. Во-первых, надо постоянно жонглировать утилитами и выполнять рутинные действия. Во-вторых, существующие средства не позволяют выявить проблему на этапе симптомов, а если и позволяют, то это крайне неудобно.

Кто-то скажет: «Инструментов для мониторинга серверов полно, те же Grafana, Prometheus, Loki. Используйте их и прогнозируйте». Загвоздка в том, что они смотрят на сервер снаружи. Узнать загрузку процессора, потребление памяти или статус службы не составит труда. А вот залезть внутрь СУБД и понять, как живет конкретный запрос, они не могут.

Цена запоздалой реакции при трейсинге постфактум

Анализ причин снижения производительности уже после подения базы данных несет в себе серьезные риски. Издержки такого постфактум-трейсинга всегда измеряются временем простоя, недовольством пользователей и прямыми финансовыми потерями для бизнеса.

В РЕД СОФТ мы захотели сделать РБД Монитор – инструмент, который непрерывно незаметно снимает метрики под высокой нагрузкой и шлет уведомления до того, как ситуация станет критической. Главное условие - это нулевое или минимальное влияние на производительность самой базы. Причем мало знать, что запрос выполняется 5 миллисекунд прямо сейчас. Куда полезнее видеть на графике, что неделю назад он летал, выполнялся за 1 миллисекунду, то есть замедлился в пять раз. Это как раз то средство, которое поможет прогнозировать ухудшение ситуации. Для этого хорошо подходят временные ряды, на работу с которыми специализируется Prometheus. А для визуализации напрашивается Grafana. Довольно известный и популярный стэк. 

Таблицы мониторинга и парсинг текста на первых этапах

Главный вопрос - как именно получать метрики запросов. Сначала попытались использовать таблицы мониторинга. Как ни подбирали тайминги, они все равно сказывались на производительности. Да и самые быстрые запросы, проскакивающие между опросами таблиц, тупо терялись. 

Перешли на парсинг текстового трейса. Слишком медленно. У Prometheus есть особенность: он отправляет запрос агенту и ждет ответа определенное время. На больших объемах мы просто не успевали распарсить трейс, Prometheus отваливался по таймауту и считал, что метрик нет. 

В итоге решили написать собственный плагин трейса на C++ для Firebird и РЕД Базы Данных, который без промежуточных звеньев сразу бы формировал метрики. Благо, архитектура СУБД такое позволяет.

Shared memory и коварный JSON в первой версии плагина

Первую версию плагина назвали aggtrace. Его цель - отправлять текст запроса, план, и аккумулировать статистку. Все это складывается в бинарный файл. Понятно, что текст нужно отправить только один раз. Сразу возник вопрос – как читать метрики из файла. Чтобы переиспользовать код, решили добавить вывод через средство самой СУБД – Service API, который отдавал JSON.

Тут мы споткнулись об архитектуру Classic, где на каждое подключение СУБД создает отдельный процесс. Общей памяти в привычном виде между ними нет. Решили использовать файл с разделяемой памятью (shared memory) и дополнительный лок-файл для синхронизации. В самой СУБД используется примерно такой же механизм.

В теори все работало хорошо, но на практике вылезли косяки. Во-первых, парсить JSON на хайлоаде — долго. Во-вторых, лок-файл превратился в жесткое бутылочное горлышко: пока один процесс пишет или обновляет статистику, остальные процессы стоят и ждут. В-третьих, тексты SQL-запросов бывают огромными, из-за чего лимит разделяемой памяти в 4 ГБ забивался моментально.

Улучшение плагина aggtrace 2.0 и ограничения Python

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

Эти меры помогли, но агент был написан на Python, а плагин на C++. Синхронизировать разделяемую память между ними оказалось больно. Python читал медленно, файл все равно распухал.

Максимальная скорость и отказ от блокировок в новой версии

В версии 3.0 мы пошли на радикальные меры. 

Статистику вообще перестали хранить в файлах. Теперь мы передаем ее агенту напрямую через Unix Domain Sockets (библиотеку ZeroMQ). Сеть в этом процессе не задействована, поэтому все работает быстро. Агрегацию перенесли на сторону агента, написав для него библиотеку на C++, которая читала и сама формировала метрики в формате  OpenMetrics. Так как плагин перестал агрегировать данные, его переименовали в MessageTrace. 

В разделяемой памяти остались только тексты запросов. Сам файл теперь создается в TempFS (Через shm_open). Это файловая система, которая живет прямо в оперативке, так что не надо думать про возможный проблемы с кэшем файловой системы (правда, работает такой финт только под Linux).

Самым главным нововведением стал полный уход от блокировок к lock-free подходу (т.е. теперь без всяких лок-файлов). У нас множество обработчиков (писателей) и один читатель (агент) - т.е. подход MPSC. Физически, разделяемая память выполняет роль массива, куда пишется длина текста (2 байта) и сам текст.

Изначально весь файл загуляется (это единственный минус данного лок-фри подхода)

Затем выполняется запись:    

struct Text
{
	uint32_t stringLength = 0;
	uint64_t hash;

	Text(const uint64_t hash, const std::string_view dbname) :
		hash(hash)
	{
		memcpy(getTextPtr(), dbname.data(), dbname.length());
		stringLength = dbname.length() // Изменяем размер !после! текста
	}

char* getTextPtr()
{
	return reinterpret_cast<char*>(this) + sizeof(Text);
}
};

bool makeTextPackage(const uint64_t hash, const std::string_view text)
{
       if (text.empty())
            return true;


       if (sharedMemory->textWritePos + text.length() >= getAvailableSpace())
            return false;


        auto size = alignOffset(sizeof(Text) + text.length());


        const TraceSizeType writePos =
               sharedMemory->textWritePos.fetch_add(size);
        if (writePos + size >= getAvailableSpace())
            return false;

        char* data = getMemory(writePos);
        Text* package = new(data) Text(hash, text);

        return true;
};

Синхронизация записи и чтения в буферный файл

Обработчик проверяет атомарную переменную textWritePos, чтобы узнать, хватит ли места для записи запроса. Если места достаточно, он увеличивает счетчик. Однако два обработчика могут проверить переменную одновременно, и обоим «хватит места», хотя по факту оно достанется только одному. Поэтому мы ввели двойную проверку: после попытки записи проверяем, не сдвинул ли textWritePos другой обработчик.

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

Сам агент читает данные непрерывно. Самое интересное — как реализована очистка файла: как только он заполняется наполовину, агент на лету очищает первую половину, а затем дочитывает данные, которые успели дописаться во вторую.

Код приведен ниже:

void read()
{
	const auto maxShmemSize = getAvailableSpace();
	localReadPos = shmemData.readPos;

	const auto writePos = shmemData.textWritePos.load();
	sendTexts(localReadPos, std::min(writePos, maxShmemSize));

	shmemData.readPos = localReadPos;
	if (localReadPos < maxShmemSize / 2)
		return;

	// Очищаем первую половину
	char* data = getMemory(0);
	memset(data, 0, localReadPos); // Зануляем прочитанную часть

	// Перемещаем позицию записи в начало
	const auto posBeforeReset = shmemData.textWritePos.exchange(0);
	if (posBeforeReset != writePos || localReadPos != writePos)
	{
		// Обработка случая, если писатель успел что-то записать
		// во время чтенияили возникла
// Либо возникал ошибка во время чтения (localReadPos!=writePos)

//Вычитываем
const uint32_t startScan = localReadPos;

		sendTextSafe(startScan, std::min(posBeforeReset, maxShmemSize));
		const uint32_t endScan = localReadPos;

		// Очищаем
		memset(data + startScan, 0, endScan - startScan); 
	}

	shmem.readPos = localReadPos = 0;
} 

В теории здесь есть одно опасное место: если агент будет слишком долго дочитывать вторую половину, обработчики могут успеть полностью заполнить первую и догнать readPos — то есть затереть данные, которые в этот момент читаются. Но на практике вероятность такого сценария почти нулевая. Чтобы записать что-то в первую половину при непрерывном чтении, нужно сделать это быстрее, чем выполняются memset и exchange у атомарной переменной. Даже в стресс-тестах, когда агент вычитывал полностью заполненный файл, подобной ситуации не происходило.

Двойной фильтр Блума против долгих проверок

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

Реализовать в ней хранение хэш-сета не так просто, а про lock-free можно вообще забыть. Но в данном архитектурном решении допустимы ошибки – не страшно, если текст отправится лишний раз. Поэтому было решено использовать вероятностную структуру данных – фильтр Блума.

На физическом уровне он представляет собой просто массив байтов фиксированного размера, в котором выставляются нужные биты. Благодаря этому он отлично ложится на концепцию разделяемой памяти. У него есть только одна проблема: чем больше данных, тем чаще он ошибается. При большом количестве уникальных SQL-запросов фильтр быстро выродится.

Наше решение проблемы довольно тривиально - мы завели сразу два фильтра Блума и меняли их раз в день. Запрос проверяется во вчерашнем, а добавляется в сегодняшний. Одноразовые запросы со временем просто вымываются.

Обход ограничений конфигурации Firebird

Отдельная история — настройка конфигурации.

В Firebird/РЕД Базе Данных конфигурация трейса происходит в трех файлах:

  1. firebird.conf – опции TracePlugin для указания самих плагинов и AuditTraceConfigFiles (в firebird - AuditTraceConfigFile) для указания конфига трейсов. Каждый конфиг – это своя сессия

    TracePlugin = messagetrace, fbtrace

    AuditTraceConfigFiles = fbtrace.conf

  2. fbtrace.conf. Конфиг трейса, который указывается в AuditTraceConfigFiles.

    database

    {

        enabled = false

        format = text

    }

  3. plugins.conf. В нем можно прописать путь, где хранится плагин, а также конфиг

    Plugin = messagetrace {

       Module = /opt/plugin/messagetrace

    }

Плагин можно поместить в папку plugins и ничего не прописывать в конфигурационном файле plugins.conf, но делать так не рекомендуется: при обновлении или переустановке СУБД эта папка удалится, и плагин придется копировать заново, что лишь усложняет процесс.

Firebird устроен так, что если в firebird.conf указать два плагина трейса, для каждого подключения запустятся две сессии. А если указаны два конфига – то вообще четыре. То есть нельзя запустить только плагин messagetrace, если прописано TracePlugin = messagetrace, fbtrace.

С этим связан еще один подводный камень: конфигурационный файл fbtrace.conf передается в оба плагина. И если он вдруг не будет соответствовать формату, возникнет ошибка. То есть не получится добавлять свои собственные опции. А требовать от пользователя указывать в системе только наш плагин — не вариант.

Решение следующее – полностью игнорировать конфиг fbtrace.conf, а вместо этого подгружать настройки из другого места. Этот альтернативный путь можно указать через файл plugins.conf:

Plugin = messagetrace {
   Module = /opt/plugin/messagetrace
   Config = messagetrace_config
}
Config = messagetrace_config {
   ConfigFile = /opt/exporter_conf_.json
}

Запуск и конфигурация плагина трассировки

Здесь ConfigFile – нестандартная опция, которую можно прочитать прямо в нашем плагине через API. Если вы пишете собственный плагин и у вас буквально одна-две опции, их можно прописать напрямую в секцию Config файла plugins.conf.

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

#MESSAGETRACE
database
{
	enabled = false
}

Наш плагин читает первую строчку, видит комментарий с тэгом и понимает, что конфиг адресован ему. В свою очередь, fbtrace видит, что логирование выключено, и не запускается.

Остался последний момент. Как именно запускать трейс. В РЕД Базе Данных / Firebird есть два способа: системная сессия и пользовательская.

Системная сессия как раз и указывается в firebird.conf через параметр AuditTraceConfigFiles. Она работает непрерывно, поэтому гарантированно не упустит ни одного запроса, однако не всем администраторам нравится постоянно включенная фоновая сессия. Но главные проблемы связаны с администрированием: редактировать три разных конфигурационных файла слишком трудоемко. На практике 90% жалоб на «нерабочий» трейс сводились именно к некорректной настройке конфигов.

Поэтому в конечном счете было решено использовать пользовательскую сессию, которую запускает сам агент сбора метрик.

Спасаем память Prometheus с помощью нормализации запросов

Последним узким местом остался объем метрик. В нашем подходе, в Prometheus один временной ряд весит 52 КБ. Миллион уникальных запросов (например, если в них вшиты константы) заставят Prometheus сожрать неадекватное количество памяти. 

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

Результаты на боевой базе в 36 терабайт

Итоги обкатки на реальной базе в 36 Терабайт со 100 интенсивными подключениями: количество запросов благодаря нормализации ужалось с 13 миллионов до 9 тысяч. Потребление памяти Prometheus упало с 64 ГБ до 9 ГБ (при хранении истории за две недели). 

Разделяемой памяти размером 256 МБ хватает с головой, он не переполняется даже под высокой нагрузкой. Overhead плагина сократился до менее чем миллисекунды (счет на наносекунды для события execute dsql). При этом отслеживается изменение планов выполнения запросов прямо на дашборде. А сам агент умеет делать тестовые запросы к СУБД, проверяя жива ли база, даже если пользователи в нее ничего не пишут. И все это без страха положить рабочий сервер неудачным запуском трейса. 

Путь от медленного парсинга JSON до lock-free буферов в оперативной памяти занял у нас немало времени. Зато теперь инструмент спокойно переваривает тысячи запросов в секунду на живом продакшене. Архитектура получилась устойчивой, а плагин и агент живут абсолютно независимо: если один компонент отвалится, второй ничего не заблокирует.

А как вы отлавливаете тяжелые SQL-запросы на своих проектах? Приходилось ли писать кастомные обвязки для баз данных или хватает штатных утилит? Делитесь опытом в комментариях, с удовольствием обсужу технические детали и отвечу на вопросы.

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