Что такое прямой ввод-вывод (Direct I/O) и почему ClickHouse Managed Postgres использует его для резервного копирования?

Источник: ClickHouse

Что такое прямой ввод-вывод (Direct I/O) и почему ClickHouse Managed Postgres использует его для резервного копирования?

Источник: ClickHouse

ClickHouse Managed Postgres использует прямой ввод-вывод и чтение размером со страйп, чтобы ускорить резервное копирование, защищая при этом страничное кэширование и задержки запросов.

•Обновлено: 3 октября 2026 г.

Что такое прямой ввод-вывод (Direct I/O) и почему управляемому Postgres важно использовать его при создании резервной копии? Каждый день резервное копирование копирует всю вашу базу данных — сотни гигабайт — с тех же NVMe-накопителей, которые обрабатывают ваши запросы, и отправляет их в объектное хранилище. Linux обрабатывает эти операции чтения так же, как и любое другое чтение файла: он сохраняет копию каждого байта в памяти, в страничном кэше (page cache), на случай, если кто-то снова запросит эти данные. Но никто их не запросит. Чтобы освободить место для этих копий, ядро вытесняет данные, которые Postgres держал в памяти и собирался использовать, и следующий запрос, которому они понадобятся, будет вынужден обращаться к диску.

Direct I/O — это флаг, который указывает ядру пропускать кэш для таких операций чтения. Если его включить, возникает вторая проблема. При чтении также теряется механизм упреждающего чтения (readahead) ядра, и на массиве из четырех страйпированных NVMe-накопителей небольшое чтение загружает один диск, в то время как остальные три простаивают.

Краткая версия: резервное копирование считывает каждый байт работающей базы данных с дисков, которые обслуживают запросы. По умолчанию эти операции чтения проходят через страничный кэш ядра, вытесняя данные, которые база данных держала в памяти, и требуя копирования каждого байта ядром во время выполнения запросов. Direct I/O позволяет обойти кэш. На нашем тестовом стенде это позволило сохранить «горячие» данные, сократить влияние на задержку запросов во время резервного копирования примерно на две трети и использовать примерно на 14% меньше ресурсов процессора. Это также отключает упреждающее чтение, поэтому на страйпированных NVMe размер чтения и количество потоков чтения должны соответствовать параметрам массива. ClickHouse Managed Postgres подбирает размер каждого прямого чтения так, чтобы оно охватывало весь страйп RAID0, и масштабирует количество потоков чтения в соответствии с аппаратным обеспечением. На сервере с 48 vCPU, четырьмя NVMe-накопителями и базой данных объемом 467 ГБ под реальной нагрузкой буферизованное резервное копирование вытеснило все 40 ГБ таблицы, которая была «горячей» в памяти и простаивала. Резервное копирование с использованием Direct I/O не вытеснило ничего и завершилось за 71 секунду.

Краткая версия

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

Direct I/O позволяет обойти кэш. На нашем тестовом стенде это позволило сохранить «горячие» данные, сократить влияние на задержку запросов во время резервного копирования примерно на две трети и использовать примерно на 14% меньше ресурсов процессора. Это также отключает упреждающее чтение, поэтому на страйпированных NVMe размер чтения и количество потоков чтения должны соответствовать параметрам массива.

ClickHouse Managed Postgres подбирает размер каждого прямого чтения так, чтобы оно охватывало весь страйп RAID0, и масштабирует количество потоков чтения в соответствии с аппаратным обеспечением. На сервере с 48 vCPU, четырьмя NVMe-накопителями и базой данных объемом 467 ГБ под реальной нагрузкой буферизованное резервное копирование вытеснило все 40 ГБ таблицы, которая была «горячей» в памяти и простаивала. Резервное копирование с использованием Direct I/O не вытеснило ничего и завершилось за 71 секунду.

Четыре конфигурации wal-g, протестированные на одном и том же экземпляре i8ge.12xlarge с базой данных объемом 467 ГБ и текущей нагрузкой на чтение. A (красный): буферизованное чтение, 24 параллельных потока чтения с диска. B (оранжевый): Direct I/O с чтением по 128 КБ, 24 потока. C (зеленый): Direct I/O с чтением по 4 МБ, 48 потоков. D (синий): Direct I/O с чтением по 4 МБ, 24 потока. Между запусками меняются только режим ввода-вывода резервной копии, размер чтения и количество потоков.

В ClickHouse Managed Postgres локальные NVMe являются «горячим» путем: Postgres читает и записывает там свою кучу (heap), индексы и WAL, именно поэтому сервис в первую очередь работает на локальных дисках. Объектное хранилище содержит базовые резервные копии и архивированные WAL, которые вместе обеспечивают восстановление на момент времени (point-in-time recovery). То, как архивированный WAL попадает туда, не переполняя диск, — это отдельная история.

На экземплярах с более чем одним NVMe-накопителем (instance-store) мы объединяем их в страйп с помощью mdadm --level=0 в /dev/md0 и монтируем в /dat. Postgres живет на /dat. Агент резервного копирования, wal-g, направлен в ту же директорию и в бакет для конкретной временной шкалы в объектном хранилище.

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

Одна машина, один набор дисков. Запросы и резервное копирование считывают данные из одного и того же массива RAID0 одновременно.

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

У Postgres есть собственный кэш в shared_buffers, составляющий четверть оперативной памяти на наших серверах и закрепленный в огромных страницах (huge pages), поэтому эти страницы в безопасности. Все остальное опирается на страничный кэш ОС: файлы отношений, которые не помещаются в shared_buffers, недавно записанный WAL, временные файлы, карты видимости. Двухбуферная архитектура предполагает, что сторона ОС остается «горячей».

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

Ядро действительно защищает самые загруженные страницы. Все, к чему постоянно идет обращение, находится в активном списке и переживает потоковое чтение, поэтому самые «горячие» несколько гигабайт загруженной таблицы обычно остаются на месте. Все, что было «теплым» и простаивало в этот момент, исчезает: таблица, к которой отчет обращался час назад, индекс, который понадобится ночному заданию, сегменты WAL, которые вот-вот запросит реплика. На нашем тестовом стенде буферизованное резервное копирование вытеснило все 40 ГБ таблицы, которая была считана в память несколько минут назад и простаивала во время выполнения резервного копирования.

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

Direct I/O — это способ чтения файла, при котором ядру дается указание не кэшировать его. Процесс открывает файл с флагом O_DIRECT, и данные перемещаются напрямую с блочного устройства в собственный буфер процесса. В страничном кэше не остается никаких копий. В нашей конфигурации wal-g это одна строка:

С этой настройкой операции чтения резервной копии полностью обходят страничный кэш, и Postgres сохраняет свои «горячие» страницы. Это решает проблему вытеснения, но создает новую: Direct I/O также отказывается от упреждающего чтения ядра. При буферизованном вводе-выводе, когда процесс считывает файл последовательно, ядро замечает это и считывает данные заранее большими порциями, поддерживая очередь устройства заполненной. При O_DIRECT процесс получает только те байты, которые он запросил, и ничего больше.

На одном диске это еще можно контролировать. На страйпе RAID0 это приводит к резкому падению пропускной способности. RAID0 разбивает данные на куски (по 512 КБ на наших массивах) и распределяет последовательные куски по дискам-участникам. Небольшое чтение O_DIRECT попадает внутрь одного куска, который находится на одном диске. Остальные диски простаивают во время этого запроса. Без упреждающего чтения для группировки данных резервное копирование, выполняющее небольшие прямые операции чтения, нагружает только один NVMe за раз на сервере, где их четыре.

По умолчанию wal-g выполняет прямое чтение блоками по 32 блока по 4 КиБ, то есть 128 КиБ. Каждый такой блок помещается в один чанк размером 512 КиБ, поэтому один запрос затрагивает только один диск.

Таким образом, первая версия «включения Direct I/O» защищает страничное кэширование, но замедляет резервное копирование.

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

WALG_DIRECT_IO_BLOCK_COUNT — это количество 4-КиБ блоков, которые wal-g считывает за один запрос прямого ввода-вывода (Direct I/O). Мы задаем 256 блоков на каждый диск массива, то есть 1 МиБ на диск:

Количество дисков берется из текущего списка устройств хранения на сервере: в AWS это все NVMe-накопители instance-store, которые сервер обнаружил после исключения загрузочного тома EBS. Четыре диска в /dev/md0 дают чтение по 4 МиБ; один диск дает 1 МиБ. Прямое чтение должно быть как минимум таким же широким, как массив, потому что любой меньший размер оставляет часть страйпа простаивающей при каждом запросе.

Чтение размером 4 МиБ охватывает два полных страйпа, поэтому каждый диск обслуживает часть каждого запроса, а страничное кэширование остается нетронутым.

Размер чтения — это один параметр. Другой — сколько читателей выполняют эти операции чтения одновременно. WALG_UPLOAD_DISK_CONCURRENCY управляет тем, сколько параллельных дисковых читателей подают данные в конвейер загрузки wal-g. Правильное число зависит от того, что является узким местом: процессор или устройство, а Direct I/O меняет этот баланс.

При буферизированном вводе-выводе мы используем половину vCPU. Упреждающее чтение (readahead) поддерживает загрузку устройства со стороны ядра, поэтому умеренного количества читателей вполне достаточно, а оставшиеся ресурсы процессора остаются для Postgres. При Direct I/O упреждающего чтения нет. Каждый читатель отправляет запрос, ждет его выполнения и отправляет следующий, поэтому использование устройства напрямую зависит от того, сколько читателей находится в процессе работы. Это меняет расчеты в двух случаях:

  • Семейства с высокой плотностью NVMe. В AWS мы относим i8g, i8ge, i7i и i7ie к категории плотных NVMe. Они обладают большим объемом локального хранилища по отношению к вычислительной мощности, и каждое устройство достигает своего предела только при большом количестве невыполненных запросов. При использовании Direct I/O на них мы используем полное количество vCPU.

Семейства с высокой плотностью NVMe. В AWS мы относим i8g, i8ge, i7i и i7ie к категории плотных NVMe. Они обладают большим объемом локального хранилища по отношению к вычислительной мощности, и каждое устройство достигает своего предела только при большом количестве невыполненных запросов. При использовании Direct I/O на них мы используем полное количество vCPU.

  • Очень маленькие серверы. На сервере с 2 vCPU половина vCPU означала бы одного читателя. Один синхронный прямой читатель без упреждающего чтения оставил бы устройство по большей части простаивающим, поэтому для двух vCPU или меньше мы также используем полное количество.

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

Везде в остальных случаях, для обычного NVMe-инстанса с более чем двумя vCPU, половина vCPU в качестве читателей — это правильный компромисс. Удвоение этого числа стоило бы процессору Postgres ресурсов при незначительном увеличении пропускной способности резервного копирования. Большее количество активных читателей означает, что резервное копирование завершится быстрее, а собственные операции чтения базы данных будут ждать в очередях устройства после большего количества запросов резервного копирования во время его работы; приведенные ниже измерения показывают обе стороны.

Для i8ge.12xlarge, 48 vCPU, 384 ГиБ ОЗУ, четыре диска instance-store, генератор записывает:

Резервное копирование конкурирует с базой данных за три ресурса: процессор, память и диски. Мы устанавливаем ограничение для каждого из них, и Direct I/O является ограничением для последнего.

  • Процессор. Резервное копирование выполняется в собственной cgroup, запущенной с помощью systemd-run --scope с параметром CPUWeight=25 против стандартного веса 100. Когда машина загружена, планировщик отдает Postgres в четыре раза больше процессорного времени, чем резервному копированию; когда она простаивает, резервное копирование может использовать свободные ресурсы. Сжатие — это затратная часть, и lz4 делает его дешевым.

Процессор. Резервное копирование выполняется в собственной cgroup, запущенной с помощью systemd-run --scope с параметром CPUWeight=25 против стандартного веса 100. Когда машина загружена, планировщик отдает Postgres в четыре раза больше процессорного времени, чем резервному копированию; когда она простаивает, резервное копирование может использовать свободные ресурсы. Сжатие — это затратная часть, и lz4 делает его дешевым.

  • Память. Буферы загрузки wal-g масштабируются в зависимости от машины: пиковое количество частей в процессе передачи, умноженное на размер части, удерживается в пределах 5% ОЗУ, и процесс живет в том же ограниченном срезе, что и другие вспомогательные службы, которые мы описали в нашей статье о бюджетах процессов.

Память. Буферы загрузки wal-g масштабируются в зависимости от машины: пиковое количество частей в процессе передачи, умноженное на размер части, удерживается в пределах 5% ОЗУ, и процесс живет в том же ограниченном срезе, что и другие вспомогательные службы, которые мы описали в нашей статье о бюджетах процессов.

  • Страничное кэширование и диски. Direct I/O. Чтения при резервном копировании никогда не попадают в страничное кэширование, поэтому то, что Postgres поместил туда, остается там, и ничего не копируется через ядро по пути к компрессору.

Страничное кэширование и диски. Direct I/O. Чтения при резервном копировании никогда не попадают в страничное кэширование, поэтому то, что Postgres поместил туда, остается там, и ничего не копируется через ядро по пути к компрессору.

Экономия процессорного времени поддается измерению. В приведенных ниже запусках загрузка процессора всей машины изменилась с 36% до 65% во время буферизированного резервного копирования и до 60% во время резервного копирования с Direct I/O размером со страйп: около 860 ядро-секунд работы против примерно 1000, что примерно на 14% меньше нагрузки на процессор для того же резервного копирования.

Мы проверили это на работающем сервере: i8ge.12xlarge в us-east-1, четыре NVMe-диска instance-store в RAID0 с чанком 512 КиБ, Postgres 18.6 с shared_buffers = 96 ГБ, upstream wal-g v3.0.9 и база данных pgbench объемом 467 ГБ, что больше, чем 384 ГиБ ОЗУ машины. Резервные копии загружаются в S3 в том же регионе и сжимаются примерно в 9 раз с помощью lz4.

Две вещи имитируют реальные рабочие нагрузки. Шестнадцать клиентов выполняют точечные поиски по четверти таблицы аккаунтов в течение всего запуска — рабочий набор больше, чем shared_buffers, поэтому часть каждого поиска обслуживается из страничного кэша ОС. И отдельная таблица объемом 40 ГБ считывается в страничное кэширование перед каждым запуском, а затем остается без изменений, имитируя данные, которые были «теплыми» несколько минут назад. fincore сообщает, какая часть из них все еще находится в памяти.

Каждый тест начинается с одного и того же состояния: кэши сброшены, Postgres перезапущен, «холодная» таблица и диапазон «горячего» индекса считаны в страничное кэширование, пять минут рабочей нагрузки для заполнения shared_buffers. Затем три минуты базовой линии, резервное копирование и три минуты после. Резервное копирование выполняется с CPUWeight=25 в каждом тесте, как и в продакшене. Меняется только путь чтения wal-g:

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

Вытеснение происходит полностью и быстро. Через двадцать секунд после начала буферизированного резервного копирования 0,3 ГБ из 40 ГБ таблицы все еще находились в оперативной памяти. Страницы, к которым интенсивно обращалась рабочая нагрузка, пострадали меньше, поскольку ядро сохраняет наиболее часто используемые страницы, однако после завершения буферизированного резервного копирования рабочая нагрузка все еще считывала данные с диска со скоростью, в четыре раза превышающей базовую, а показатель p99 оставался в три раза выше в течение следующих двух с половиной минут, пока кэш заполнялся заново. В случае с Direct I/O простаивающая таблица остается в объеме 40 ГБ на протяжении всего процесса, а задержка возвращается к базовому уровню сразу после завершения резервного копирования.

Задержка p99 для точечных запросов (point lookups) в 5-секундных интервалах. Буферизированное резервное копирование увеличивает p99 с 0,04 мс до пикового значения 0,33 мс и оставляет его на уровне 0,13 мс в течение нескольких минут после завершения. Резервное копирование с использованием Direct I/O удерживает этот показатель около 0,06 мс во время работы и сразу возвращает его к норме.

Каждое резервное копирование требует от базы данных определенных ресурсов во время выполнения, поскольку оно считывает данные с тех же дисков и выполняет сжатие на тех же процессорах. Буферизированное чтение увеличило p99 до 4,7 раз по сравнению с базовым уровнем во время резервного копирования и оставило длинный «хвост» холодных чтений после него. Чтение через Direct I/O увеличило этот показатель до 1,5 раз по сравнению с базовым уровнем и не оставило никаких последствий. Буферизированный вариант также потреблял больше ресурсов процессора (65% загрузки против 60%), поэтому дополнительная задержка возникает из-за вытеснения страниц из кэша и последующих холодных чтений.

Насколько сильно был загружен каждый из четырех NVMe-дисков во время резервного копирования, с учетом скорости чтения массива и общего времени выполнения для каждого варианта. Небольшие прямые операции чтения максимально нагружают диски при передаче минимального объема данных. Чтение размером со страйп (stripe-sized) передает больше данных за единицу времени, поэтому у массива остается больше запаса производительности для базы данных.

При 24 активных потоках чтения даже прямые операции по 128 КБ загружают все четыре диска; картина для одного диска на диаграмме выше — это взгляд одного потока чтения. Стоимость небольших операций чтения проявляется в эффективности: вариант с небольшими операциями чтения выдает 5,8 ГБ/с при 80% загрузке, в то время как варианты с 4 МБ выдают от 7,4 до 7,7 ГБ/с при 56%. Меньшее количество более крупных запросов позволяет получить больше от каждого диска и оставить больше свободного времени для выполнения запросов. Резервное копирование заняло 96 секунд при стандартных настройках чтения, 71 секунду при чтении размером со страйп и 48 потоках, и 75 секунд при 24 потоках. На этой машине размер блока чтения сыграл решающую роль, а правило для плотных NVMe-накопителей позволяет сэкономить несколько секунд времени резервного копирования за счет увеличения количества потоков в очереди.

Приведенные выше варианты проходят через весь конвейер wal-g, включая сжатие и загрузку после чтения. Чтобы увидеть работу RAID0 в чистом виде, мы также запустили fio против массива с синхронным чтением, так, как это делает механизм Direct I/O в wal-g, варьируя только размер чтения и количество потоков.

Пропускная способность последовательного чтения из массива. Один поток прямого чтения по 128 КБ получает 0,8 ГБ/с, при этом каждый диск занят примерно четверть времени. Тот же поток при 4 МБ получает 8,5 ГБ/с, при этом каждый диск полностью загружен.

Один поток чтения подтверждает то, что описывают диаграммы: прямые операции чтения по 128 КБ затрагивают по одному диску за раз и достигают 0,8 ГБ/с, тогда как чтение по 4 МБ охватывает весь страйп и достигает 8,5 ГБ/с на тех же дисках. Буферизированное чтение также показывает высокие результаты, поскольку упреждающее чтение (readahead) ядра выполняет пакетную обработку за них. При 24 или 48 активных потоках даже небольшие операции чтения насыщают массив, ценой чего становится гораздо большее количество запросов и более загруженная очередь устройств. Увеличение размера блока чтения позволяет небольшому количеству потоков выполнить задачу, что и является целью правила по количеству потоков.

Все это генерируется для каждого сервера. Генератор конфигурации берет количество vCPU сервера, объем памяти, количество дисков и информацию о том, относится ли он к семейству с плотными NVMe-накопителями, и записывает результат в /etc/postgresql/wal-g.env рядом с префиксом бакета. Инструменты резервного копирования и восстановления считывают этот файл.

Поддержка Direct I/O появилась в одном из релизов wal-g и попала в наш парк серверов, когда мы обновили образ машины, содержащий wal-g, наряду с другими улучшениями резервного копирования, которые мы выпустили в этом году. Серверы, созданные из нового образа, получают параметры Direct I/O в своей сгенерированной конфигурации. График резервного копирования и путь восстановления не изменились.

Существуют два функциональных флага на случай непредвиденных обстоятельств. Один отключает параметры Direct I/O и возвращает систему к буферизированному чтению с количеством потоков vCPU/2. Другой возвращает систему к настройкам wal-g по умолчанию. Оба существуют потому, что путь чтения, работающий настолько близко к базе данных, должен иметь возможность быстрого отката, если ядро, драйвер или тип инстанса ведут себя неожиданно.

Каждый запуск, описанный выше, является полным резервным копированием. Он считывает все 467 ГБ и загружает 54 ГБ сжатых данных, независимо от того, изменилась ли одна строка со вчерашнего дня или миллиард. Для многотерабайтной базы данных это самая большая статья расходов на поддержание резервных копий. Сейчас мы разрабатываем прототип инкрементального резервного копирования: считывать каталог данных, находить страницы, которые изменились с момента последнего резервного копирования, и загружать только их, чтобы стоимость ежедневного резервного копирования соответствовала объему изменений за день.

Путь чтения в этой статье остается неизменным. Инкрементальное резервное копирование по-прежнему считывает активный каталог данных, чтобы найти изменения, с тех же дисков, параллельно с теми же запросами. Direct I/O, чтение размером со страйп и веса cgroup позволяют делать это незаметно для базы данных, независимо от того, составляет ли объем загрузки 54 ГБ или 500 МБ.

Самая сложная часть резервного копирования происходит локально: чтение каждого байта активного каталога данных с дисков, которые обслуживает Postgres. Буферизированное чтение вытесняет «теплые» страницы базы данных из кэша страниц. Direct I/O пропускает кэш и, поскольку он также пропускает упреждающее чтение, теряет пропускную способность на RAID0. Чтение размером со страйп, масштабированное под количество дисков, и количество потоков, учитывающее плотные NVMe-накопители, возвращают эту пропускную способность.

Результатом является резервное копирование, которое оставляет кэш страниц для Postgres, поддерживает загрузку каждого диска в массиве и завершается так же быстро, как и буферизированное резервное копирование, которое оно заменило. Каждый сервер ClickHouse Managed Postgres поставляется с этим по умолчанию. Разверните Postgres и убедитесь в этом сами: ClickHouse Managed Postgres

О чём эта статья

Что-то непонятно? Спросите по статье — объясню простыми словами.

Не хотите разбираться сами? Мы поможем.

Ещё в разделе «Данные и аналитика»

Все →

Ещё от ClickHouse