2.6.26

Структуры хранения #4 – Memory-optimized columnstore


Автор: Hugo Kornelis, Storage structures 4 – Memory-optimized columnstore;

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

Оптимизированные для памяти колоночные индексы были представлены в SQL Server 2016. За это время я видел несколько эффектных маркетинговых презентаций Microsoft, в которых много говорилось о «аналитике в реальном времени» (real-time operational analytics). Новая тенденция, согласно которой аналитическая обработка больше не должна выполняться на устаревшей копии данных в отдельном хранилище, а непосредственно в OLTP-базе данных. Отчёты всегда были бы полностью актуальными, необходимость в ETL-конвейере отпала бы, а благодаря сочетанию оптимизированных для памяти структур для OLTP-нагрузок и колоночных индексов для аналитической обработки всё всегда было бы быстро. В теории.

Я больше не слышал термин «аналитика в реальном времени» после первоначального выпуска SQL Server 2016. А начиная с внедрения SQL Server 2017, я не припомню, чтобы слышал от кого-либо из сотрудников Microsoft использование терминов «оптимизированный для памяти» и «колоночный» в одном докладе, не говоря уже об одном предложении.

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

Структура хранения

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

Поскольку колоночный индекс является кластерным, все столбцы таблицы автоматически включаются в него. Это также означает, что ограничения на поддерживаемые типы данных не легко обойти. Если таблица содержит столбец, который не поддерживается для оптимизированного для памяти колоночного индекса, вы не можете указать SQL Server просто пропустить этот столбец. Вам придётся фактически удалить его из таблицы. Это может не понравиться пользователям системы, которым этот столбец нужен для их OLTP-работы.

Описанные выше ограничения не очень хорошо документированы. Я не смог их найти нигде, кроме как в этом техническом документе (whitepaper), которому уже почти десять лет. Я провёл некоторые тесты на SQL Server 2025, чтобы увидеть, были ли с тех пор улучшения, но похоже, что это не так. Поэтому я предполагаю, что оптимизированные для памяти колоночные индексы по-прежнему не поддерживают ни LOB, ни off-row столбцы.

Обычно, когда таблица имеет кластерный индекс, этот индекс заменяет кучу (heap storage), которая используется для таблиц без кластерного индекса. Поэтому можно ожидать, что в этом случае структура varheap оптимизированной для памяти таблицы будет заменена новой колоночно-ориентированной структурой хранения. Однако это не так.

Копия данных

Когда у вас есть существующая оптимизированная для памяти таблица и вы добавляете к ней кластерный колоночный индекс, SQL Server создаёт полную копию данных! Представляется, что эта копия хранится в виде групп строк (rowgroups) и сегментов (segments) и сильно сжимается, как и в обычных дисковых колоночных индексах. Поэтому ваши потребности в хранилище не удвоятся внезапно. Но они вырастут. Если таблица имеет 50 ГБ данных, а сжатие колоночного индекса достигает 90%, вам всё равно потребуется дополнительно 5 ГБ дискового пространства.

И поскольку это не обычный колоночный индекс, а оптимизированный для памяти колоночный индекс, эта сжатая копия данных будет полностью храниться в памяти. Как и в случае с другими оптимизированными для памяти индексами, данные также будут записываться на диск, чтобы SQL Server мог перезагрузить их в память при запуске или восстановлении. Но все обычные операции (чтение и запись), которые затрагивают оптимизированный для памяти колоночный индекс, используют эту копию данных, находящуюся в памяти.

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

Поддержка изменений

Сжатие здесь также приводит к тому, что изменения становятся слишком дорогими. Поэтому, как и для обычных колоночных индексов, сжатые группы строк фактически хранятся в структуре «только для чтения». Это означает, что мы также видим те же обходные механизмы.

  • Битовая карта удалённых строк (deleted bitmap, или таблица удалённых строк) хранит идентификаторы строк (Row IDs) всех строк, которые были удалены, но не могут быть фактически удалены, поскольку находятся в сжатой группе строк. Эта структура, очевидно, также хранится в памяти. Поскольку её основное назначение — быстро определять, содержится ли конкретный идентификатор строки в ней или нет, можно предположить, что эта таблица удалённых строк хранится как обычная оптимизированная для памяти таблица с одним столбцом (идентификатором строки) и, вероятно, некластерным Bw-tree индексом на этом столбце. Это также может быть некластерный хэш-индекс, но это, вероятно, было бы немного менее эффективно.
  • Для вставленных строк, опять же, как и в случае с обычными колоночными индексами, используется хранилище дельт (delta store). Я расскажу об этой структуре немного подробнее. Как и в случае с обычными колоночными индексами, фоновый процесс периодически проверяет, достаточно ли новых строк в хранилище дельт, чтобы преобразовать их в новую группу строк. Пороговая величина — один миллион строк, но это основано на оценке количества новых строк, а не на точном подсчёте. Кроме того, если ваши данные часто подвергаются множеству обновлений в течение короткого периода после вставки, вы можете гарантировать, что новые строки останутся в хранилище дельт (где обновления дешевле) в течение определённого периода времени, изменив настройку COMPRESSION_DELAY. Она указывает количество минут, которое должно пройти после последнего изменения строки в хранилище дельт, прежде чем она сможет быть включена в сжатую группу строк.

Обновление строки в хранилище дельт выполняется легко. Эти строки не сжаты, поэтому SQL Server может просто изменить данные. Конечно, это всё ещё оптимизированные для памяти данные, поэтому это изменение будет отражено как удаление старой строки (путём установки временной метки окончания) и добавление новой строки с новыми значениями.

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

Очень важно понимать, что изменения в оптимизированных для памяти колоночных индексах становятся дорогими. Помните, сжатые колоночные группы строк — это всё копии данных, которые также хранятся в varheap. Таким образом, когда вы удаляете строку, SQL Server должен удалить её из varheap (что, как описано в предыдущей статье, является виртуальным удалением путём установки временной метки окончания). А затем он также должен удалить её из копии в колоночном индексе (что, опять же, является виртуальным удалением — на этот раз путём поиска её идентификатора строки и добавления этого значения в таблицу удалённых строк).

Вторая куча переменной длины (Second varheap)

Как упоминалось выше, вставленные (и обновлённые) строки изначально хранятся в хранилище дельт. Это хранилище дельт реализовано как вторая куча переменной длины (varheap). Однако, в отличие от сжатых данных, эта вторая куча переменной длины не является дублированием данных.

Вы можете думать об этом так, как если бы обычная куча переменной длины, которая уже существовала для поддержки оптимизированной для памяти таблицы и её некластерных индексов, разделена на две части. Большая часть содержит те же данные, которые также скопированы в колоночный индекс. Вторая часть содержит только «недавние» данные. Эти данные ещё не были сжаты в колоночный индекс. Другими словами, когда фоновый процесс сжимает строки из хранилища дельт в новую сжатую группу строк, он копирует данные в эту сжатую группу строк, а затем перемещает данные из varheap хранилища дельт в обычную varheap!

Чтение из оптимизированного для памяти колоночного индекса

Чтение данных из оптимизированного для памяти колоночного индекса действительно очень ограничено. Как и для обычного колоночного индекса, поиск по ключу (seeks) не поддерживается. И по той же причине: данные не хранятся таким образом, который позволяет эффективно искать по значению.

Однако там, где обычный кластерный колоночный индекс действительно позволяет выполнить (очень дорогой) поиск по ключу (Key Lookup) для поиска конкретных строк, которые были идентифицированы через другой (некластерный) индекс, для его оптимизированного для памяти аналога это не так. Не потому, что это было бы слишком сложно реализовать, а потому, что в этом просто нет необходимости.

Каждая оптимизированная для памяти таблица требует как минимум один хэш-индекс или Bw-tree индекс. Они всегда являются некластерными индексами. Поэтому на первый взгляд кажется, что могут быть случаи, когда оптимизатор выберет один из этих некластерных индексов для идентификации требуемых строк. Но помните, даже если эти индексы являются некластерными, они реализованы как цепочки указателей на varheap и, следовательно, обеспечивают прямой доступ ко всем данным. Предоставляется, они поступают из varheap, а не из кластерного колоночного индекса. Но это идентичные копии друг друга. Вы никогда не увидите поиск по ключу (Key Lookup) ни на одной оптимизированной для памяти таблице. Добавление кластерного колоночного индекса не изменит этого.

Таким образом, в целом для чтения данных из оптимизированного для памяти колоночного индекса используется только один оператор: Columnstore Index Scan.

Columnstore Index Scan

Когда оператор Columnstore Index Scan нацелен на оптимизированный для памяти колоночный индекс, он делает практически то же самое, что и Columnstore Index Scan для обычного колоночного индекса. Исключение заключается в том, что данные теперь хранятся в памяти и что хранилище дельт имеет иную структуру.

Итак, снова первый шаг — использовать исключение столбцов (column elimination) и (если свойство Predicate позволяет) исключение групп строк (rowgroup elimination), чтобы читались только те сегменты, которые могут содержать релевантные данные. Затем эти сегменты читаются. Если таблица удалённых строк хранится как Bw-tree индекс, то этот индекс сканируется одновременно, чтобы быстро определить, какие строки логически удалены и, следовательно, должны быть пропущены. Если она хранится как хэш-индекс, то для каждой строки, которую хочет вернуть Clustered Index Scan, потребуется выполнить поиск в этом индексе.

Конечно, строки в хранилище дельт также должны быть возвращены. Они считаются ещё одной файловой группой, но здесь процесс иной. Я предполагаю, что SQL Server использует ту же логику, что и для сканирования таблицы (Table Scan) в обычной оптимизированной для памяти таблице: он просто сканирует varheap (но в этом случае только одну из двух varheap — ту, которая хранит новые и недавно изменённые строки) и возвращает каждую найденную строку, если свойство Predicate её не отбраковывает.

Заключение

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

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

Для чтения данных из оптимизированного для памяти колоночного индекса можно использовать только один оператор плана выполнения: Columnstore Index Scan. Этот оператор читает сжатые группы строк вместе с таблицей удалённых строк и varheap хранилища дельт, точно так же, как Columnstore Index Scan для дискового колоночного индекса.

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




Комментариев нет:

Отправить комментарий