Дедупликация в OpenZFS теперь хороша, но использовать её не стоит

в 11:46, , рубрики: dedup, deduplication, filesystem, OpenZFS, reflink, storage, zfs
Дедупликация в OpenZFS теперь хороша, но использовать её не стоит - 1

Вот-вот выйдет релиз OpenZFS 2.3.0 с новой функцией Fast Dedup. Это огромный шаг вперёд по сравнению со старой дедупликацией и отличный фундамент для будущих доработок.

Контрибьютор OpenZFS @gmelikov и команда VK Cloud совместно перевели статью об этом релизе, в которой новая функция сравнивается со старой дедупликацией и описывается максимально подробно с практическими примерами. В 2023–2024 коллеги из Klara много работали над этой функцией, и мы согласны с ними, что она весьма хороша! 

После релиза Fast dedup на многих ресурсах в обсуждениях продолжили писать, что «новый дедуп всё так же плох, он требует столько же ОЗУ и также убивает производительность». Но эта информация лишь отчасти близка к правде и повторяет всё тот же мотив, который когда-то кто-то озвучивал на форумах.

Винить в этом никого не хочется. И не стоит, так как дедупликация в OpenZFS и правда была очень требовательной к правильному применению. Найти качественные гайды тоже не просто, ответ по умолчанию — «не используйте её» — был и (в целом) остаётся правильным. Но, по прошествии почти 20 лет жизни дедупа в OpenZFS, настало время вернуться к этому вопросу.

Посмотрим на свежую информацию об имплементации дедупа в OpenZFS, как он работал до улучшений, в чём была его проблема, что поменяли в fast dedup, и почему же это всё ещё не дефолт.

Оглавление:

Что вообще такое дедупликация

Формально дедуп можно определить так:

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

Но как понять, есть ли уже данные на диске, а если есть, то где именно? Эту информацию нужно хранить и извлекать, а для этого требуются дополнительные операции IO, часто очень значительные.

Эта сохранённая информация и есть «таблица дедупликации» (она же DeDuplication Table, DDT). Это хеш-таблица, в которой контрольная сумма данных это «ключ», а расположение на диске и счётчик ссылок — «значение». Таблица дедупликации хранится как часть метаданных пула, то есть считается не пользовательскими, а «структурными» данными пула.

Как работает дедупликация

При включённой дедупликации модифицируются шаги IO операций записи. Обычно для записи на диск блок данных готовится на уровне DMU и передаётся в SPA (о базовом устройстве ZFS можно почитать тут). Шифрование и сжатие выполняются как обычно, потом вычисляется контрольная сумма.

Без дедупликации вызывается аллокатор metaslab, чтобы запросить свободное пространство в пуле для хранения блока. При этом местоположения (DVA) выводятся и копируются в block pointer (указатель на блок). При включенной дедупликации OpenZFS меняет логику и ищет контрольную сумму в DDT. Если её не удаётся найти, он как обычно вызывает механизм аллокации metaslab, получает актуальные DVA, заполняет блок и позволяет записать данные на диски как обычно. Потом он создаёт новую запись в DDT, указывая контрольную сумму, DVA и счётчик ссылок со значением 1. Если же ему удаётся найти контрольную сумму, он копирует DVA из значения в block pointer, констатирует, что операция записи «выполнена» (completed), а затем увеличивает счётчик ссылок (refcount).

При включённой дедупликации в блоке-указателе размещаемые блоки отмечены специальным флагом D. В будущем это поможет высвободить блок. Аналогичным образом меняется шаги IO «освобождения» (free) с отметкой D. Если отметка существует, выполняется такой же поиск по DDT, и счётчик ссылок уменьшается. Если счётчик ссылок не равен нулю, констатируется, что IO операция «выполнена» (completed). Но если значение достигло нуля, то последняя «копия» блока высвобождается, так что запись в DDT удаляется. При этом вызывается механизм аллокации metaslab, чтобы освободить фактически занятое блоком пространство.

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

Очевидно, каждая стоящая система дедупликации должна экономить больше «настоящего» пространства и ресурсов ввода-вывода, чем она тратит на работу с таблицей. И это фундаментальная проблема с традиционной дедупликацией: эти дополнительные затраты ресурсов столь велики, что компенсировать их можно только в случае редких специализированных рабочих нагрузок.

Чем так плоха традиционная дедупликация

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

  • Построение и хранение самой таблицы дедупликации.

  • Дополнительные затраты на сбор и подготовку изменений в таблице дедупликации.

  • Проблема «уникальных» записей в таблице.

Таблица дедупликации 

В традиционном подходе таблица дедупликации реализована наипростейшим образом, который может работать: выполнялся поиск стандартного объекта OpenZFS хеш-таблицы на диске, вот и всё. Это т.н. тип объекта «ZAP», который используется в OpenZFS для директорий файлов, списков свойств и внутренних нужд. Это вполне разумный выбор. Но не слишком подходящий для целей дедупликации.

ZAP — довольно сложная структура, не будем сейчас углубляться в эту тему. Нам в рамках этой статьи достаточно знать, что каждый блок данных в объекте ZAP это массив фрагментов (chunk) фиксированного размера. При этом одна пара «ключ/значение» потребляет столько чанков, сколько нужно для хранения ключа, данных и заголовка, который описывает, как используются эти чанки.

У одной записи дедупликации 40-байтовый ключ. Значение может занимать до 256 байт. Но перед хранением его сжимают, так что обычно на самом деле хранится 64 байта. Каждый чанк внутри ZAP имеет размер 24 байта и может содержать либо заголовок, либо до 21 байта данных ключа или значения. В совокупности мы смотрим на ceil(40/21) + ceil(64/21) + 1 == 7фрагментов на каждую запись. Типичный блок дедупликации ZAP равен 32K. В нём хватает места для 1320 фрагментов (у блоков ZAP есть собственный заголовок, описывающий чанки). Так что в одном блоке дедупликации хватит места для 1320/7 = 188 «типичных» записей.

Конечно, мы могли бы создать формат, получше приспособленный для хранения записей дедупликации, но главная проблема здесь не в формате. Настоящая проблема — это амплификация, которая встречается в OpenZFS везде, где блок данных содержит массив несвязанных элементов. OpenZFS никогда не выполняет запись части блока и никогда не перезаписывает сформированный блок. Чтобы обновить одну запись дедупликации, нужно загрузить с диска целый блок, изменить определённую часть и снова выполнить запись нового блока целиком. А потом нужно выполнить запись нового block pointer в indirect block (блок косвенной адресации), а его новый block pointer — в другой indirect block, или dnode, и так далее вплоть до верхушки дерева, как с любой другой записью в OpenZFS. Чем выше по дереву мы поднимаемся, тем сильнее амортизируются эти дополнительные затраты ресурсов применительно к операциям записи во всём пуле. Но в ZAP с дедупликацией для каждого записываемого блока выполняется цикл «чтение-изменение-запись» (read-modify-write, rmw), потому что нам как минимум нужно увеличить счётчик ссылок.

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

Именно с этим связаны давние рекомендации, по которым для дедупликации требуется огромный объём оперативной памяти. Чтение таблицы дедупликации напоминает чтение любых других данных в OpenZFS: она кэшируется в ARC. Если у вас столько оперативной памяти, что ARC никогда не приходится удалять ту или иную часть таблицы дедупликации, можно в целом отказаться от надобности читать записи из таблицы дедупликации на диске перед её обновлением.

Здесь может помочь редко используемый класс vdev dedup. Если добавить в пул достаточно большой и быстрый vdev dedup, можно немного снизить требования к ресурсам памяти. В конечном счёте это превращается в весьма требовательную сборку: на больших масштабах, при которых дедупликация имеет смысл, этот vdev нужно создать из достаточно больших и быстрых носителей. Объёма этого vdev должно хватать для всей таблицы дедупликации, а производительность должна всё-таки быть настолько достаточной, чтобы не замечать того факта что это не ОЗУ . Многотерабайтные NVMe отлично подходят, если вы можете себе их позволить, но это решение не для слабонервных людей или ограниченных бюджетов.

The live entry list (список динамических записей)

Примечание переводчика: а как бы вы перевели live entry в данном контексте? Пишите в комментариях!

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

Каждая операция записи в OpenZFS назначается определённой открытой «транзакции», которая идентифицируется числовым «id транзакции». По мере готовности данные сбрасываются на диск, после этого транзакция «закрывается», но это ещё не конец! При этом записываются все метаданные для транзакции, включая обновления той самой таблицы дедупликации, все вышеупомянутые обновления block pointer'ов и прочие сведения для внутренних нужд. По умолчанию это происходит не позднее чем через 5 секунд с последнего раза, а на практике — когда возникает перерыв в активности userspace.

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

Поскольку речь идёт об операциях записи данных, они начинаются «немедленно». Но давайте вспомним пайплайн записи IO, рассмотренный ранее. Сначала надо просмотреть DDT и решить, действительно ли нужно выполнить запись данных или просто увеличить счётчик ссылок. Функцию поиска по таблице дедупликации нужно будет вызывать пять раз. В конечном счёте она попытается прочитать соответствующую часть ZAP блока дедупликации (хотя ARC сократит всё до одной операции чтения). В результате выяснится, что запись не существует, и операция записи будет выполнена. Наконец, будет создана новая запись со счётчиком ссылок 1. Итого, мы получим явно не дедуплицированные данные и таблицу дедупликации, не отражающую реальность.

Так что вместо этого OpenZFS хранит в памяти список «динамических» записей. Они в полном объёме хранятся в памяти, и они отслеживают записи, созданные или изменённые во время этой транзакции. Для начала функция поиска по DDT ищет нужную запись в этом списке. Если ей удаётся найти запись, счётчик ссылок динамической записи увеличивается и он выводится в результатах поиска. Если записи нет, новая динамическая запись создаётся и отмечается флагом «in progress». Потом выполняется переход на уровень ZAP, чтобы получить собственно запись дедупликации. Когда эта запись возвращается, функция поиска распаковывает её в динамическую запись, отмечает её флагом «ready» и выводит её в результаты поиска. В это время приходят и другие потоки для записи данных. Они выполняют поиск динамической записи, видят флаг «in progress», ставят «будильник», чтобы проснуться, когда она будет готова, и засыпают. Когда они просыпаются, они видят изменившийся флаг «ready», увеличивают счётчик ссылок и выводят её в результатах поиска.

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

Собственно, это рациональная модель, которая не особенно отличается от грубой модели поведения OpenZFS в целом: вся работа с данными выполняется во время транзакции, а по завершении транзакции все изменения с соответствующими метаданными обрабатываются и записываются. Если вам доводилось слышать, как разработчики OpenZFS говорят об «открытом» (open) и «синхронизирующемся» (syncing) контекстах, они обсуждали именно это.

Так в чём же проблема? В огромных динамические записях: размер каждой — 424 байта. Необработанная запись, которую мы загружаем и сохраняем в ZAP, имеет размер 296 байтов (больше чем 40+~64 байта, хранящихся в ZAP, потому что это версия без сжатия), что уже само по себе проблема. К этому добавляется ещё 128 байтов для внутренних нужд (например, флаги in progress и ready и связанные с ними блокировки). Этот список разрастается довольно быстро, и хотя в конце каждой транзакции он очищается, пиковые значения могут быть очень высокими.

А ведь это kernel slab memory, а не память ARC. Её нельзя вернуть, когда система перегружена. Это нельзя исправить никакими настройками или параметрами.

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

Уникальные записи

Самый большой минус таблицы дедупликации — это пространство, которое нужно для отслеживания уникальных записей. Чтобы дедупликация работала, нужно отслеживать всё сохранённое на диске. При этом от дедупликации будет какая-то польза, только если счётчик ссылок больше 1. Любой блок, у которого есть только одна копия, просто занимает место в таблице дедупликации в ожидании дня, когда будут записаны точно такие же данные. Если это никогда не произойдёт, мы так и не окупим эти затраты.

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

Как fast dedup всё это исправит

Так называемая «быстрая дедупликация» (т.н. fast dedup) — это набор изменений, которые в совокупности должны справиться с описанными проблемами. Проще говоря, наша цель — сократить объёмы, которые мы храним в таблице, проявить находчивость по поводу сбора и промежуточного хранения этих изменений, а также предоставить инструменты, позволяющие оператору ограничить содержимое таблицы и гибко управлять ею.

Уменьшаем список динамических записей 

Мы начали с сокращения ресурсов памяти, которые требуются для списка «динамических» записей. Таблица дедупликации — это обычный хранящийся объект, доступ к которому предоставляется через ARC, так что у нас довольно много вариантов оптимизации. Однако, список динамических записей — это как раз простой список, в котором фиксируется каждая запись, связанная с этой транзакцией и закреплённая в памяти. Мы не можем избавиться от него в используемой архитектуре, так что мы просто были вынуждены как-то его уменьшить.

Тип динамической записи ddt_entry_t был не очень хорошо проработан. 

  • В нём использовалось несколько больших числовых типов для хранения простых флагов. Их заменили на битовое поле (bitfield). 

  • Упростили некоторые поля для синхронизации записей (вспомните пример с пятью экземплярами на запись выше). 

  • Ещё 40 байтов приходилось в нём на информацию, требовавшуюся только когда блок с дедуплицированными данными записывается впервые, или когда ему требуется операция repair write (функция самовосстановления в OpenZFS). Эта информация никогда не используется после создания записи, так что мы возвели её до уровня отдельного объекта «IO state», который мы создаём, когда он нам нужен, и отбрасываем, когда закончили с ним. 

Но всё это небольшие изменения. Основное — в хранящейся части записи. Ключ равен 40 байтам, 32 из которых это контрольная сумма. В сущности это фрагмент block pointer'а, так что его нельзя изменить без последствий для его собственной структуры. Такие изменения не входят в объем этой задачи. Что ещё более важно, мы хотели оставить ключ как есть, чтобы обеспечить совместимость с уже существующими таблицами дедупликации. Не то чтобы это было совершенно необходимо, но нет таких значительных преимуществ, которые оправдывали бы сложность конвертации в старый формат в случае надобности.

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

typedef struct ddt_phys {
    dva_t       ddp_dva[SPA_DVAS_PER_BP];
    uint64_t    ddp_refcnt;
    uint64_t    ddp_phys_birth;
} ddt_phys_t;

Это три 128-битовых DVA, счётчик ссылок для этой записи и время появления (birth time) записи (id транзакции). Всё это довольно разумно. Понятно, как это сочетается с ключом, образуя почти законченный блок-указатель. В любом случае его будет достаточно, чтобы очистить блок.

Но погодите-ка, четыре таких записи? Почему четыре? Что ж.

У датасетов OpenZFS есть параметр copies, который указывает, сколько копий данных блока следует записать. Если помните, мы уже говорили, что во время операции записи вызывается механизм аллокации metaslab, который выделяет пространство для хранения данных блока — он может возвращать несколько DVA. Вот как работает параметр copies: механизм распределения выделяет на диске один, два или три региона подходящего размера, во все из них записываются данные, и именно это количество DVA попадает в block pointer.

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

Давайте рассмотрим, что это значит для дедупликации. Допустим, у вас есть датасет сcopies=1 (по умолчанию), и вы записываете блок. Block pointer получает один DVA (два других будут состоять из нулей). Вы копируете его несколько раз, происходит повторное использование DVA и увеличивается счётчик ссылок дедупликации. Потом вы меняете значение параметра на copies=2 и снова копируете блок. Выполняется поиск записи в таблице дедупликации, но у неё один DVA, а в соответствии с политикой выполнения записи должно быть два. Что мы сделаем?

Для традиционной дедупликации это совершенно новая запись. Она проходит через механизм распределения, выделяются и записываются два DVA. Потом выполняется обновление записи дедупликации, но счётчик ссылок для «физической» записи с 1 копией (в dde_phys[1]) не увеличивается. Вместо этого происходит копирование DVA в запись с 2 копиями (в dde_phys[2]), устанавливается счётчик ссылок в значение 1. И с этого момента эти два варианта блока рассматриваются как отдельные записи дедупликации с общим ключом.

Вообще это довольно редкий случай, чтобы оператор менял copies= на существующем датасете. Кроме того, не рекомендуется это делать, если уже применяется дедупликация, так как для новых записей фактически нивелируется наличие имеющейся таблицы дедупликации: они снова начнут с номера 1, а, значит, будет использоваться больше места! Так что большую часть времени другие «физические» записи будут заполняться нолями и оставаться неиспользованными. Это не такая уж проблема для записей в ZAP блоках дедупликации, поскольку они сохраняются сжатыми, а длинные ряды нулей хорошо поддаются компрессии. Но когда они хранятся в памяти в динамическом списке, они просто сидят там и всё —192 байта нулей, которые никогда никому не понадобятся.

Внимательный читатель может заметить: если block pointer может содержать только до трёх DVA и для параметра copies= можно задать значение 1, 2 или 3 — для чего тогда четвёртая запись? А ни для чего. Раньше она использовалась с функцией dedupditto для хранения дополнительных DVA «на всякий случай». Но от неё было мало пользы, много багов, и несколько лет назад её удалили. Она всё ещё поддерживается для очень старых пулов, но современные версии OpenZFS могут выполнять на них только чтение, а не запись.

После ряда экспериментов мы поняли кое-что. Предположим, мы получаем операцию записи для блока, который уже есть в таблице дедупликации. Однако у него слишком мало DVA. В таком случае нам остаётся только выделить и записать достаточно дополнительных копий данных, чтобы выполнить запрос (до 3), и добавить их в запись дедупликации, когда мы увеличиваем счётчик ссылок. Это значит, что теперь у нас на диске есть блоки со старым количеством DVA, но это нормально, поскольку гарантии остались прежними. Конечно, это усложняет «поиск записи» в IO-пайплайне, и привносит некоторые нюансы, когда высвобождается блок, если счётчик ссылок достиг нуля. Но ничего страшного, ради благого дела можно и потерпеть.

А это безусловно благое дело! У новых DDT с fast dedup поле «значение» теперь всего 72 байта, а не 256, как было в традиционной версии. Дополнительные 8 байт отведены под дополнительное 64-битное число для поддержки функции удаления (pruning) лишнего (об этом ниже).

Если сложить всё вместе, получается, что теперь отдельная запись в динамическом списке равна 216 байтов — почти вдвое меньше первоначальных 424 байтов. Высвободить на будущее «половину ресурсов памяти» это очень кстати, особенно, когда речь идёт о памяти slab, которую так просто не освободишь.

Хранящаяся на диске запись технически теперь тоже меньше, но всё равно после сжатия она в среднем будет равна тем же ~64 байтам. В оригинальном виде она намного меньше старого типа записи, но она поддаётся компрессии гораздо хуже, так как в ней нет этой длинной последовательности нулей, которые так легко сжать. Если учесть дополнительные затраты ресурсов на чанки ZAP, получается, мы мало что выгадали. Но ничего страшного: на самом деле, мы сейчас и не планировали сокращать размер записей в ZAP блоках дедупликации.

Лог дедупликации 

Как мы уже говорили, в динамическом списке записей фиксируется каждая дедуплицированная запись, изменённая во время текущей транзакции. В конце транзакции эти обновлённые записи записываются обратно в ZAP блоки дедупликации. Каждую запись окружают 187 других записей в том же блоке, и по мере увеличения таблицы дедупликации они всё с меньшей вероятностью будут задействованы в этой транзакции. Поэтому, чтобы обновить запись, нам в принципе надо было загружать полный блок записей, а это дополнительные IO-запросы, или, в сценарии получше, но всё равно неидеальном, «загружать» блок из ARC. А потом, после обновления всех записей, содержимое списка динамических записей очищается.

Изучив некоторые рабочие нагрузки заказчиков и поэкспериментировав, мы поняли, что вероятность дедупликации выше у блока, который создан или дедуплицирован «недавно». Другими словами, чем больше времени прошло с тех пор, как с блоком работали в последний раз, тем менее вероятно, что он будет дедуплицирован или высвобождён в будущем. Тут не нужна глубокая работа мысли, это интуитивно понятно: обычно мы какое-то время много работаем с одним и тем же фрагментом данных, а потом не трогаем его какое-то время или вовсе к нему не возвращаемся. Значит, выбрасывать список динамических записей в конце транзакции это чрезмерное расточительство, ведь велика вероятность, что в ближайшем будущем нам понадобятся некоторые или почти все из этих записей!

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

Конечно, ответ будет тем же самым, что и для любой системы хранения, когда хочется отсрочить работу: нужно добавить «журнал» или «лог», описывающий изменения, переприменять (replay) лог во время восстановления после сбоев, а во время обычной работы медленно переписывать указанные в логе изменения в окончательное место их упокоения. Но если представить, как разрабатывается такая система, основы архитектуры дедупликации всё несколько усложняют.

Итак, давайте представим самое простое рабочее решение. В конце транзакции мы не будем обновлять ZAP блок дедупликации. Вместо этого мы просто свалим весь список динамических записей как массив записей фиксированного размера в конце некоего объекта, который мы «назначим» логом. Возможно, одну и ту же запись обновляли две или более транзакции подряд. А поскольку мы только добавляем их к логу, он может содержать одну и ту же запись несколько раз. Отлично. Это означает всего лишь, что нам следует использовать последнюю запись, которую мы видим. Время от времени мы проходимся по логу, добавляем последний экземпляр каждой записи в ZAP блок дедупликации, а потом обнуляем лог. 

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

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

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

Нам нужен индекс лога, благодаря которому можно быстро найти в нём любую запись. Оказывается, если убрать оверхед из «динамического» объекта ресурсов, одна сохранённая в памяти запись лога будет занимать всего 144 байта. Это не так уж много, так что целесообразно хранить весь лог в памяти, а также на диске. Тогда мы ищем запись; если её нет в списке динамических записей, мы проверяем лог, хранящийся в памяти. Если её нет и там, переходим в ZAP дедупликации. А потом, в конце транзакции, сохраняем обновлённую версию записи и в in-memory лог, и в лог на диске.

И на диск тоже? Да. Всё-таки надо обезопаситься на случай сбоя. Но когда мы сводим вместе эти две версии, лог на диске становится write-only, а все обычные действия происходят только в in-memory логе. Другими словами, два лога содержат альтернативные репрезентации одних и тех же данных. В случае отката или восстановления после сбоя (и то, и другое иногда происходит во время импорта пула), мы просто загружаем in-memory лог из лога на диске, и жизнь продолжается.

Постепенный сброс данных из лога 

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

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

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

Так что мы поменяли схему работы: теперь часть лога переписывалась в ZAP каждую транзакцию. Мы сопоставляем объёмы сброса данных лога с затратами времени на фактические операции ввода-вывода, так что в периоды высоких нагрузок мы записываем меньше, а в периоды затишья — больше. Но есть и дополнительные соображения на этот счёт. Например, мы ускоряемся, если in-memory лог сильно вырос и потребляет слишком много ресурсов памяти.

Однако из-за постепенного сброса данных снова стала актуальна застарелая проблема. Нам нужна возможность обнулять лог на диске. Мы знаем, какие записи в логе появились «позже всех», потому что только эти записи хранятся в in-memory логе. Но мы не знаем, где эти версии этих записей находятся в логе на диске. А так как новые записи добавляются в лог во время тех же транзакций, в течение которых мы их переписываем, мы не знаем, какие записи в in-memory логе были переписаны. В этой модели нельзя обнулить лог на диске, пока in-memory лог не опустеет, а in-memory лог опустеет, только если не появились обновлённые записи. Это означает, что в конце концов нам придётся остановиться и сбросить оставшуюся часть лога. А поскольку записи в лог на диске только добавляются, то чем дольше мы затягиваем со сбросом, тем больше он становится.

Чтобы решить эту проблему, по факту мы используем два лога; у каждого из них есть версия in-memory и версия на диске. В одном из них выполняется только сброс данных, в другом — только обновление. Таким образом, в «активном» (active) логе накапливаются новые обновления, а записи из «сбрасываемого» (flushing) лога удаляются. Когда он становится пустым, сбрасываемый лог на диске обнуляется, и два лога меняются местами: старый «активный» лог теперь становится «сбрасываемым» и начинает переписывать записи, а в активный лог записываются новые изменения. Мы научились останавливать лог перед сбросом данных, не останавливая всю систему.

Конечно, это дополнительно усложняет этап поиска. Теперь сначала нужно искать запись в списке «активного» лога, потом в списке «сбрасываемого» лога, и только потом переходить в ZAP. По неясным причинам, к записям, «загруженным» из сбрасываемого списка и далее «сохранённым» в активном списке, нужен тонкий деликатный подход, потому что в конечном счёте на «сбрасываемом» дереве на диске мы можем получить записи, которые никогда не сбрасывали до повторного использования. Здесь нет серьёзного повода для беспокойства; просто этот «танец» становится немного сложнее.

Есть ещё «контрольная точка лога» (log checkpoint). Поскольку на самом деле контрольные суммы это просто очень большие числа, у них очень удобный порядок. Так что когда мы заканчиваем сброс по конкретной транзакции, мы записываем последнюю контрольную сумму, которую мы записали на диск (на самом деле, в bonus buffer объекта "сбрасываемого" лога). Но нужно повысить скорость будущего импорта пула; приходится перезагружать списки обоих логов. Можно использовать контрольную точку при чтении сбрасываемого лога. Это позволяет узнать, какие записи уже сброшены — тогда нам не придётся вносить их в in-memory список.

Наконец, есть интересные взаимодействия со сканированием пулов (то есть операции scrub и resilver). Обычно при включённой дедупликации сканирование начинается с просмотра таблицы дедупликации и чтения каждого block pointer (и выполнения необходимых действий) из каждой расположенной в ней записи. Потом сканирование переходит к остальному пулу. Время от времени процесс сканирования запоминает, где в пуле он остановился, чтобы продолжить с того же места.

Проблема с логом дедупликации заключается в том, что в нём нет полезного понятия «положение», которое можно запомнить. У лога на диске нет естественной структуры в привычном понимании. А в in-memory логе используется AVL-дерево, обычная для OpenZFS структура. В ней нет «стабильного курсора»; то есть, в ней нельзя сохранить нечто, описывающее логическую позицию в дереве с возможностью перенести её на другое дерево с той же структурой.

Мы испробовали разные способы создания AVL-курсора. Это в принципе возможно, но не с ограничениями «позиционных» данных, которые нам надо сохранить (для тех, кто попробует решить эту задачку дома: нужно добавить 40-байтовый ключ к scn_ddt_bookmark в dsl_scan_phys_t). В конце концов, мы решили действовать трусливо: когда поступает запрос на выполнение scrub, мы ускоряем сброс лога. Весь лог сбрасывается в ZAP блок дедупликации, и после этого мы действуем как обычно. В сканировании текущая транзакция указывается как точка «окончания сканирования», так что нам не нужно беспокоиться о последующих изменениях, а в ZAP блоке дедупликации хранится всё, что было до сброса. Это означает, что после сбоя нужно выполнить повторный сброс лога, после чего сканирование продолжится. Но мы всегда ожидаем, что размер лога будет меньше, чем размер ZAP блока дедупликации и данных пула в целом.

Уникальные записи 

Столько всего про логи! Если вы ещё с нами - так держать!

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

С уникальными записями очень помогает новая команда zpool ddtprune. С её помощью из всех таблиц дедупликации в системе можно удалять определённое количество уникальных записей, которые отбираются по сроку давности или проценту. Параметр срока давности отлично подходит для нашей идеальной рабочей нагрузки, где вероятность дедупликации выше для недавно использованных данных. Такой паттерн использования приводит к образованию длинного «дряхлеющего» хвоста уникальных записей, которым не нужна дедупликация. Теперь от них можно избавиться оптом. А новая доработка "ZAP shrink" позволяет просто удалить ZAP блоки дедупликации, которые в результате этой операции оказались пустыми.

Конечно, это значит, что если позднее скопировать блок, о котором были удалены записи в DDT, это будет новый записанный блок с новым распределением, дедупликации не произойдёт. То есть, если вдруг очень старый уникальный блок скопировать десять раз, получится десять сылок на один новый блок,при этом у вас фактически будет записано две копии вместо 11, которые у вас были бы без дедупликации. Да, придётся настроить поведение ddtprune в соответствии с вашими рабочими нагрузками, но это не то чтобы катастрофа, если вы почистите (prune) слишком много лишних записей.

В то же время теперь свойство пула dedup_table_quota позволяет установить максимально возможный размер таблиц дедупликации для пула. Если при создании новой записи будет превышен установленный предел размера таблицы дедупликации, запись не будет создана. Будет просто выполнена обычная операция записи без дедупликации. Удобно использовать в сочетании с выделенным vdev для дедупликации, когда нужно, чтобы при его переполнении DDT не пролилсяи на основные, зачастую более медленные, устройства.

Столько всего! Может что-то ещё? 

Да, есть ещё пачка операционных улучшений.

zpool prefetch -t ddt выполнит предварительную загрузку таблиц дедупликации в ARC, что может повысить производительность сразу после импорта пула. Вполне очевидно, в чём преимущества для традиционной дедупликации. Но даже в быстрой дедупликации записи, не внесёные в лог, всё ещё нужно загружать из ZAP, а при сбросе всё равно нужно, чтобы для близлежащих ZAP выполнялась операция записи, так что их наличие в ARC всё равно на пользу.

Появилась новая коллекция статистики kstats в /proc/spl/kstat/zfs/<pool>/ddt_stats_<checksum> в Linux или kstat.zfs.<pool>.misc.ddt_stats_<checksum> в FreeBSD. Она показывает актуальную статистику для подсистемы дедупликации, в том числе количество поисков, коэффициенты попадания по динамическим спискам и спискам логов, а также ZAP блоков дедупликации, и периодичность сброса данных логов.

Ещё появилась новая коллекция настраиваемых параметров: /sys/modules/zfs/parameters/zfs_dedup_log_* в Linux или vfs.zfs.dedup.log_* в FreeBSD. Они контролируют разные входные параметры, определяющие объём сброса данных лога во время каждой транзакции. Как обычно, мы тщательно подбираем значения по умолчанию, чтобы они подходили всем пользователям. Но возможность настроить их как вам удобно тоже может пригодиться.

Кроме того, мы обновили разные около-дедупликационные настройки (zpool status -D, zdb -D, zdb -S и т.п.) с учётом всех изменений, которые мы внедрили.

Здорово! Скорее бы уже применить всё это к моей таблице дедупликации!

Нельзя просто так взять и ... (с)

Нельзя просто так взять и ... (с)

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

Но мы не стали принципиально отказываться от идеи реализовать это когда-то в будущем.

Для имеющихся таблиц должно сработать всё, для чего не нужно менять on-disk формат:

  • Квоты таблицы (dedup_table_quota свойство пула)

  • Предварительная загрузка DDT в кеш (zpool prefetch -t ddt)

  • Число поисков и попаданий (ddt_stats_* kstats)

  • «Сжатие» ZAP (отбрасывание целых пустых блоков; это общая доработка ZAP в версии OpenZFS 2.3)

Функция лога дедупликации должна быть простой в интеграции с традиционными DDT. Она никак не связана с размером записей (фактически, «log» и «flat entry» — это две разные подфункции). Единственное, чего не хватает — для существующей таблицы вам придётся настроить новый объект — «container», что должно быть не так трудно. Конечно, вы не получите небольшие записи в динамическом списке и списке лога в памяти или на диске, так что тюнинг размеров будут различаться.

Удаление уникальных записей (zpool ddtprune) должно быть нетрудно добавить только для режима «percentage of uniques». Невозможно использовать режим «age» (срок давности), так как для него требуется новый формат записей, который не существует в традиционном формате.

Конвертация старых таблиц пока не поддерживается. В самом простом случае, когда параметр copies= ни разу не менялся, это будет так же просто, как создать новый ZAP, пройтись по имеющемуся ZAP, конвертировать запись и скопировать её. Это сложно сделать онлайн, поскольку нам понадобится либо выполнять чтение и из старых, и из новых ZAP, либо выполнять операцию записи в оба ZAP, а потом в конце переключаться между ними. Это проще сделать офлайн через утилиту в userspace, но, конечно, для этого нужно перевести пул в офлайн.

Если параметр copies= менялся, и в имеющихся записях сохранилось два типа, то конвертировать таблицы полностью невозможно: изменился в целом метод обновления имеющихся «вариантов» блока, и в новой записи просто не хватит места, чтобы хранить все данные. Самый удачный сценарий, это когда у одного-единственного варианта счётчик ссылок больше 1, поскольку это уникальные записи, которые можно удалить. Иначе мы ничего не можем с этим поделать.

И, конечно, для этой цели сгодится знакомый «трюк» — отправить (zfs send) дедуплицированный датасет в другой пул, где доступна новая дедупликация.

Теперь дедупликация стала действительно хорошей? 

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

Но действительно ли она хороша? Может быть, нет, ещё нет. Но «ещё» самое главное в этой оценке. Полагаем, мы взяли и существенно обновили самую нелюбимую функцию OpenZFS, так что надеемся, пользователи согласятся взглянуть на неё. Наверное, кто-то даже согласится дать ей второй шанс. Мы снабдили код комментариями, он стал более структурированным и понятным многим пользователям. В нём появилось больше очевидных преимуществ. Теперь его наконец можно добавить к остальным функциям  инструментария OpenZFS. И кто знает, как события будут развиваться дальше.

Я не понял. Если дедупликация стала лучше, почему же не использовать её везде? 

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

На самом деле, в теории мы с вами согласны.

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

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

Рассмотрим моделирование запуска дедупликации на ноутбуке автора, который использует его по 12 часов в день для любых рабочих и личных задач (можете смоделировать аналогично на своём пуле через zdb -S).

zpool list crayon
NAME     SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP    HEALTH  ALTROOT
crayon   444G   397G  47.3G        -         -    72%    89%  1.00x    ONLINE  -
$ zdb -S crayon
Simulated DDT histogram:

bucket              allocated                       referenced
______   ______________________________   ______________________________
refcnt   blocks   LSIZE   PSIZE   DSIZE   blocks   LSIZE   PSIZE   DSIZE
------   ------   -----   -----   -----   ------   -----   -----   -----
     1    11.7M    708G    362G    373G    11.7M    708G    362G    373G
     2    12.2K    666M    284M    293M    25.1K   1.32G    582M    602M
     4      294   4.44M    962K   1.55M    1.44K   22.0M   4.67M   7.76M
     8        5     99K     41K     44K       52   1.06M    440K    464K
    16        1   7.50K   3.50K      4K       26    195K     91K    104K
 Total    11.7M    708G    362G    373G    11.7M    709G    362G    373G

dedup = 1.00, compress = 1.96, copies = 1.03, dedup * compress / copies = 1.90

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

Но настоящая причина отказа от дедупликации не в этом: начиная с версии OpenZFS 2.2 у нас есть BRT (то есть «клонирование блоков» или операция «reflink»). Признаем, релиз этой функции не очень задался, но об этом уже и так много говорили и писали. Скажем только, что сейчас с ней всё в порядке.

Если вы помните, в начале статьи мы задались вопросом «что вообще такое дедупликация?» и ответили на него следующим образом:

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

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

В наши дни довольно редко случается так, что операция записи происходит из подвида операции копирования, а вы об этом не знаете. Раньше клиентская программа считывала данные из источника и записывала в место назначения, а система хранения воспринимала их как две несвязанные между собой операции. Но сегодня у нас есть «copy offloading». Вместо чтения и записи программа говорит системе хранения «скопируй сюда этот источник данных», и система хранения с готовностью делает, что ей сказали. Наивная реализации выполнит чтение и запись, как это сделал бы клиент. Но система поумнее пойдёт другим путём: например, вместо операции записи она будет просто повторно использовать имеющиеся данные и увеличивать счётчик ссылок.

Для файловых систем Linux и FreeBSD это offload-средство — syscall copy_file_range(). У большинства систем есть некий аналог этой функции; в macOS он называется copyfile(), в Windows — FSCTL_SRV_COPYCHUNK. NFS и CIFS тоже поддерживают нечто аналогичное, да и драйверы блочных устройств ОС уже получают эквивалент. Похожая функция есть даже в протоколах дисков (например, SCSI EXTENDED COPY или NVMe Copy).

Если собрать всё это вместе, то получается, что клиентская программа (типа /bin/cp) уже может выдавать правильный вызов «copy offload», а все промежуточные уровни уже могут его перевести (например, приложение Windows выполняет FSCTL_SRV_COPYCHUNK, а Samba конвертирует его в copy_file_range() и передаёт в OpenZFS). И опять же, это однозначный сигнал, что такие данные уже существуют и находятся вот здесь, так что OpenZFS может просто увеличить счётчик ссылок в BRT.

Важную роль играет место, занимаемое на диске. Если блок никогда не клонировали, то за это ничего и не платили, а если клонировали, BRT-запись весит всего 16 байт.

В моём пуле, где, насколько я знаю, два основных пользователя copy_file_range(): cp и ccache, статистика BRT радует глаз:

$ zdb -TTT crayon
BRT: used 292M; saved 309M; ratio 2.05x
BRT: vdev 0: refcnt 12.2K; used 292M; saved 309M

BRT: vdev 0: DVAs with 2^n refcnts:
			 1:  11788 ****************************************
			 2:    645 ***
			 3:     40 *
			 4:      3 *
			 5:      1 *

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

Да, это ещё не внедрено повсеместно. В zvol'ах BRT ещё не используется. В Samba только совсем недавно появилась поддержка OpenZFS. Offload в Windows — относительная новинка. Ситуация только начинает улучшаться, но, наверное, ещё недостаточно хороша. Наверное, у вас возникнет соблазн попробовать дедупликацию. Но по мне, овчинка выделки не стоит даже без клонирования блоков.

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

В заключение 

Суть дедупликации в компромиссе между пропускной способностью ввода-вывода, ресурсами памяти и размером таблицы дедупликации. В оригинальной реализации дедупликации золотая середина, в которой все эти факторы сходятся наиболее выгодным образом, очень невелика. А вот выход за её пределы грозит разрушительными последствиями. В fast dedup проработаны все три момента: теперь добиться баланса этих трёх факторов стало гораздо легче, а последствия негативного сценария не так серьёзны. Но всё-таки, её стоит использовать, только если у вас просто огромный объём данных, которые часто копируются, и при этом вам недоступны преимущества других «zero-copy» вариантов в OpenZFS, таких, как клонирование блоков или клоны из снапшотов.

Фух, если вы осилили 30 минут лонгрида — наше почтение! Как вам такой формат? Стоит ли следом переводить статью о проблемах релиза BRT?

Вступайте в русскоязычные стораджевые комьюнити в телеграме t.me/ru_zfs и t.me/sds_ru !

Автор: gmelikov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js