Ring Buffer — самодостаточная структу��а. Ничего не растёт, ничего не ломается — просто следующий круг
Ring Buffer — самодостаточная структура. Ничего не растёт, ничего не ломается — просто следующий круг

Работая с MS SQL, я привык воспринимать название Ring Buffer как небольшую структуру в памяти, организованную по принципу FIFO overwrite. И чаще всего в контексте Extended Events. Но как-то я встретил упоминание того же Ring Buffer в заголовке статьи про секционирование таблиц! Купился на название, прочёл статью и сохранил себе идею.

В статье описывалось, как Ring Buffer решает задачу ротации данных во времени, которую принято решать с помощью Sliding Window. И я постараюсь передать эту идею так, чтобы после прочтения у вас появился еще один способ решить обычную задачу необычным способом. Не для галочки в резюме, а для рассказов на встречах с коллегами. В моей работе этот подход позволил сделать интересной скучную задачу организации хранения статистики производительности сервера, но может быть использован и для других данных с ограниченным сроком хранения или, иначе говоря, с фиксированным количеством секций. Например, данных аудита.

Сравнение Sliding Window с Ring Buffer

Оба подхода имеют много общего, но ключевое отличие заключается в работе с секциями, а именно в том, как они переиспользуются.

В подходе Sliding Window секции вовсе не переиспользуются: вместо этого выполняется удаление старой секции (MERGE RANGE) и создание новой секции (SPLIT RANGE). Важно, что обе операции должны точно выполняться, потому что:

  • без MERGE «тащится хвост» устаревших данных,

  • без SPLIT новые данные либо продолжают записываться в последнюю доступную секцию, либо вовсе теряются.

При этом порядок выполнения не важен. 

В подходе Ring Buffer все необходимые секции создаются в самом начале.  Далее необходимо очищать секцию с устаревшими данными перед началом записи в неё новых. Это помогает избежать потери данных, но не спасёт от разрастания секций при ошибке в процессе очистки. Это значит, что совсем избавиться от джобов обработки не удастся, но можно обойтись только транкейтом секций (для MS SQL server 2016 и более новых), при условии, что данные безнадёжно устаревают к концу периода хранения. Например, как это бывает с логами. Шаб��он таблицы приведу в примерах ниже.

И, как я отмечал в начале статьи, в нашей компании мы применяем такой подход именно к таблицам со статистикой производительности — для статистики ожиданий, активных запросов и наличия блокировок. Детальная информация для анализа устаревает через 7–30 дней, в зависимости от её типа.

Сразу перейду к деталям реализации и начну с вводных.

Пример использования

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

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

Уверен, что у читателя уже нарисовалась в голове схема и функция секционирования и пишется привычная процедура для поддержки по sliding window. А ниже я опишу решение этой задачи с применением Ring Buffer, который и создан для работы с таблицами с фиксированным количеством секций.

В примере буду использовать таблицу со следующей структурой:

CREATE TABLE dbo.Logs
(
    ... – много полезных данных

    collection_time datetime2(7) NOT NULL,

    offset AS 
      (CONVERT(tinyint, DATEDIFF(DAY, (0), collection_time) % (14))) PERSISTED NOT NULL –where 14 is equal to buffer size
) ON PSRingBuffer(offset)

Особого внимания требует поле offset. В случае с нашим решением тут используется дата, точнее, день и номер этого дня делится на 14. Именно это число определяет максимальное количество секций, которые используются таблицей. И тут выбрано 14, а не 7 — специально для обеспечения запаса расширения глубины хранения. Предполагается, что более двух недель истории запросов для анализа нам не понадобится, даже чтобы перекрыть новогодние выходные.

Для разных таблиц может использоваться одна и та же функция и схема секционирования.Количество секций, доступных в функции и схеме, может быть больше, чем необходимо для конкретной таблицы. Например, в функции может быть описано 30 секций, а таблица будет использовать только 14 из них. 

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

Описание самих секций:

CREATE PARTITION FUNCTION [PFRingBuffer] tinyint
AS RANGE RIGHT FOR VALUES (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13)

CREATE PARTITION SCHEME [PSRingBuffer] AS PARTITION [PFRingBuffer] ALL TO ([PRIMARY])

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

Вот сигнатура процедуры, используемой у нас:

EXEC dbo.RingbufferRotate
    @Retention = 7,  -- сколько секций останется после запуска
    @BufferSize = 14,  -- сколько всего их используется целевой таблицей
    @CurrentPartition = $PARTITION.PFRingBuffer(DATEDIFF(DAY, (0), GETUTCDATE()) % 14),
    @SourceTblName = N'Logs',
    @TargetTblName = N'LogsSwitch'  -- таблица, в которую будут сбрасываться секции для архивирования/очистки

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

В идеальном мире определение секции с самыми старыми данными, которые нужно сохранить, выглядело бы так: 

SET @OldestPartition = (@CurrentPartition - @Retention + @BufferSize) % @BufferSize

Но MS SQL сервер добавляет секцию для значений меньше первой границы и Null, чем сдвигает их порядковые номера.

Также требуется учесть, что значения в поле offset начинаются с 0, тогда как номера секций с 1. 

Таким образом, финальная формула выглядит так:

SET @OldestPartition = (@CurrentPartition - @Retention + 1 - 2 + @BufferSize) % @BufferSize + 2

Где: 

  • @Retention + 1 помогает правильно вычесть заданную глубину хранения;

  • -2 смешает номера секций из-за наличия Null-секции;

  • последнее +2 возвращает соответствие номеров в sys.partitions.

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

WHILE 1=1
BEGIN

    SET @CurrentPartition = (@CurrentPartition + 1 - 2) % @BufferSize + 2

    IF @CurrentPartition = @OldestPartition
    BEGIN
        BREAK
    END

    SET @Sql =
        'TRUNCATE TABLE @SourceTblName WITH (PARTITIONS(@CurrentPartition))'

    EXEC sp_executesql @Sql

END

Вот несколько примеров работ�� процедуры очистки.

Предположим, что сегодня день номер 11 (offset), 14 секций доступно в функции и 7 должны остаться  после очистки.

Сначала определится номер секции для значения 11 — это 13.

Начиная со следующей секции будет происходить движение «вперёд» и очистка каждой следующей секции. То есть 14, 15, 16…

Но 16 не вяжется с выбранным количеством секций и будет преобразовано делением на размер буфера % @BufferSize. Счёт продолжится до тех пор, пока номер секции в итерации не совпадёт с номером секции с самыми старыми данными, которые хотим оставить. 

Визуально это будет выглядеть так:

Для дня 12 (offset) при тех же параметрах выглядит так:

И на десерт, offset = 2 с аналогичными вводными создает впечатление, что данные закольцованы:

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

'ALTER TABLE <@SourceTblName> SWITCH PARTITION <@CurrentPartition> TO <@TargetTblName> PARTITION <@CurrentPartition>'

Исходная и целевая секции должны находится в одной и той же файловой группе. Но нас интересует только очистка и поэтому в процедуре выше выполняется:

'TRUNCATE TABLE @SourceTblName WITH (PARTITIONS(@CurrentPartition))'

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

Оригинальная статья Thomas Kejser раньше была доступна по ссылке. Но на момент написания она перестала работать, а найти работающую мне не удалось.

Заключение

Подход отлично подошёл нам для организации хранения логов производительности сервера БД, хотя и представлял больше академического интереса на старте.

Среди наших требований были:

  • довольно ограниченная глубина хранения;

  • предсказуемый размер данных;

  • ротация, которая не будет мешать вставке новых данных;

  • минимум усилий для поддержки.

Хочу отметить, что статья не преследует цели сравнить возможности MS SQL Server и каких либо других СУБД ни с точки зрения работы с секциями, ни с точки зрения способов организации хранения данных.