Идея использовать NVMe-накопитель как продолжение оперативной памяти стара примерно как сам Linux. Раньше это делали через swap на ротационном диске, потом через swap на SATA-SSD, теперь - через swap на NVMe или через настоящие модули DRAM, подключённые по протоколу CXL и торчащие в системе как блочное устройство либо как дополнительный NUMA-узел. Для маленькой базы данных, которая помещается в 30-50 гигабайт, эта схема превращается из аварийного буфера в осознанный архитектурный выбор. Но только при условии, что владелец понимает, во что обходится каждое обращение к странице, которой нет в физической RAM, и почему счётчик major page faults в выводе vmstat - это не абстрактная цифра, а звук горящих денег.
Что вообще скрывается за словами DRAM через NVMe и почему вариантов несколько
Терминологическая путаница в этой области достигает уровня, при котором на собеседовании можно проиграть час, выясняя, что собеседник имеет в виду. Под одной и той же фразой часто понимают три принципиально разные вещи.
Первая - обычный swap на NVMe. Раздел или файл подкачки лежит на быстром накопителе, ядро Linux вытесняет туда холодные страницы памяти и подгружает обратно при обращении. Технология древняя как мир, и никакого настоящего DRAM на устройстве нет - там флеш-память.
Вторая - CXL-устройства типа 3, в которых физически установлены модули DDR5, а наружу торчит интерфейс, похожий на NVMe-диск формата E3.S или EDSFF E1.S. Эти устройства подключаются в обычные слоты для NVMe SSD, но внутри у них стоят чипы DRAM. Операционная система видит их как дополнительный NUMA-узел без процессорных ядер, и страницы памяти можно явно мигрировать туда через numactl или специальные политики. По характеристикам это медленный DRAM с латентностью в районе 200-300 наносекунд против 80-100 у обычной системной памяти.
Третья - NVMe over CXL, относительно молодая спецификация, в которой стандартный NVMe SSD получает дополнительный канал доступа через протокол CXL.mem. Хост может обращаться к буферу памяти на устройстве байтовыми операциями, а не только блочными. Для баз данных это означает, что страницу можно поднять с накопителя в кеш с гранулярностью 64 байта вместо 4 килобайт.
В этой статье речь пойдёт в основном о первом и втором сценариях, поскольку третий пока остаётся уделом узких лабораторных стендов и нескольких пилотов в гиперскейлерах. Для подавляющего большинства команд, которые ставят PostgreSQL или MySQL на сервер с 32 ГБ памяти и базой в 80 ГБ, выбор между этими двумя вариантами и определяет успех или провал проекта.
Базовая разница в латентности, которую невозможно обойти никакими настройками
Прежде чем обсуждать конфигурации, стоит твёрдо запомнить порядок цифр. Обращение к строке кэша L1 у современного процессора занимает около одной наносекунды. Доступ к локальной DDR5 - примерно 80-100 наносекунд. Удалённый NUMA-узел добавляет ещё 30-50%. CXL-память на отдельной карте даёт 200-300 наносекунд. NVMe SSD на чтение случайной страницы из 4 КБ при низкой очереди - от 10 до 100 микросекунд, то есть в 100-1000 раз медленнее DRAM. Между быстрейшим NVMe и обычной памятью лежит пропасть в три порядка.
Когда база данных получает major page fault и страница летит со swap-устройства, поток буквально замирает на десятки микросекунд. За это же время процессорное ядро могло бы выполнить десятки тысяч инструкций. Если такой fault случается раз в секунду на фоне сотен тысяч обращений к памяти - всё в порядке. Если он случается тысячи раз в секунду в горячем цикле - производительность падает в разы, и никакие индексы не помогут.
CXL-память лежит между этими крайностями и ведёт себя как медленный, но настоящий DRAM. Доступ к ней происходит через обычные load/store-инструкции процессора, без перехода в ядро, без обработчика прерываний, без поднятия страницы с диска. Это и есть та самая фундаментальная разница, ради которой технология вообще появилась.
Настройка swap на NVMe для базы данных, у которой рабочий набор почти влез в RAM
Сценарий типичный. Сервер с 64 ГБ оперативной памяти, PostgreSQL с базой в 80 ГБ, рабочий набор - около 50-55 ГБ. В RAM почти всё помещается, но не совсем. Без swap база периодически получает OOM-killer от ядра, что неприемлемо. Со swap на старом HDD сервер встаёт колом в моменты вытеснения. NVMe-swap здесь действительно решает проблему, но требует аккуратной настройки.
Базовая создание swap-файла на NVMe-разделе выглядит так:
# Создаём файл фиксированного размера на ext4 с правильными атрибутами
sudo fallocate -l 32G /var/lib/swap/swapfile
sudo chmod 600 /var/lib/swap/swapfile
# На некоторых файловых системах fallocate не подходит, используем dd
# sudo dd if=/dev/zero of=/var/lib/swap/swapfile bs=1M count=32768
# Помечаем файл как swap и активируем
sudo mkswap /var/lib/swap/swapfile
sudo swapon /var/lib/swap/swapfile
# Проверяем приоритет и тип
swapon --show
Дальше начинается самое важное. Параметры ядра по умолчанию рассчитаны на компромисс между типичным десктопом и сервером, и для базы данных они почти всегда неправильные. Основной рычаг управления - swappiness, который определяет, насколько агрессивно ядро будет вытеснять страницы анонимной памяти на диск вместо сброса страничного кеша.
# Просмотр текущих значений
cat /proc/sys/vm/swappiness
cat /proc/sys/vm/vfs_cache_pressure
cat /proc/sys/vm/dirty_ratio
# Применение новых значений в рантайме
sudo sysctl -w vm.swappiness=10
sudo sysctl -w vm.vfs_cache_pressure=50
sudo sysctl -w vm.dirty_background_ratio=5
sudo sysctl -w vm.dirty_ratio=10
# Сохранение через /etc/sysctl.d
sudo tee /etc/sysctl.d/99-database-swap.conf <<EOF
vm.swappiness=10
vm.vfs_cache_pressure=50
vm.dirty_background_ratio=5
vm.dirty_ratio=10
vm.page-cluster=0
EOF
sudo sysctl --system
Параметр vm.page-cluster=0 особенно интересен в контексте NVMe. Он означает, что при подъёме одной страницы со swap ядро не будет читать соседние, а возьмёт ровно ту, которая нужна. Для ротационных дисков значение 3 (восемь страниц за раз) имело смысл из-за стоимости seek. Для NVMe с латентностью в десятки микросекунд на любом блоке оптимально брать ровно столько, сколько нужно прямо сейчас, потому что предсказать соседние обращения невозможно, а лишний трафик увеличивает износ накопителя и засоряет очередь.
Отдельная история - выбор IO scheduler для NVMe. По умолчанию большинство дистрибутивов ставит none, и для большинства сценариев это правильно. NVMe-устройства имеют собственные глубокие очереди и сами разруливают порядок команд лучше, чем планировщик ядра.
# Проверка текущего планировщика
cat /sys/block/nvme0n1/queue/scheduler
# Установка значения none (без планирования на стороне ядра)
echo none | sudo tee /sys/block/nvme0n1/queue/scheduler
# Закрепление через udev-правило
sudo tee /etc/udev/rules.d/60-nvme-scheduler.rules <<EOF
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
EOF
Для PostgreSQL дополнительно стоит настроить huge pages. Большие страницы по 2 МБ снижают нагрузку на TLB процессора и косвенно уменьшают количество page faults, потому что одна большая страница заменяет 512 обычных. База данных с shared_buffers в 16 ГБ получает заметный прирост производительности от этой настройки.
# Подсчёт нужного количества huge pages под shared_buffers
# Например, для 16 ГБ shared_buffers нужно 16384 / 2 = 8192 страницы по 2 МБ
# С запасом 10% получаем около 9000
sudo sysctl -w vm.nr_hugepages=9000
# В postgresql.conf включаем использование
# huge_pages = on
# shared_buffers = 16GB
Почему major page faults убивают базу данных и как их вообще измерять
В Linux есть два типа page faults. Minor fault случается, когда страница уже в физической памяти, но в таблице страниц процесса для неё нет записи - например, после fork или при первом обращении к свежевыделенной странице. Major fault означает, что данные нужно поднять с диска или из swap. Цена этих событий отличается на четыре порядка: minor fault обрабатывается за сотни наносекунд, major - за десятки или сотни микросекунд.
Для базы данных оба типа важны, но major - критичны. Каждый такой fault замораживает выполнение запроса до завершения IO-операции. Если очередь к NVMe загружена, fault может занять и миллисекунды. На фоне обычной задержки запроса в несколько миллисекунд это означает удвоение или утроение времени отклика без видимой причины в логах базы.
Базовый мониторинг ведётся через несколько инструментов:
# Глобальная статистика по системе
vmstat 1 10
# Колонки si и so показывают swap-in и swap-out в килобайтах в секунду
# Статистика по конкретному процессу PostgreSQL
ps -o pid,minflt,majflt,cmd -p $(pgrep -d, postgres)
# Подробная картина через /proc
cat /proc/$(pgrep -f "postgres: writer")/stat | awk '{print "min:", $10, "maj:", $12}'
# Динамика за период через pidstat из пакета sysstat
pidstat -r -p $(pgrep -f "postgres: writer") 1 30
Для глубокой диагностики используется perf, который умеет писать стек-трейсы каждого page fault. Это позволяет понять, какие именно запросы или операции базы триггерят подгрузку страниц со swap.
# Запись событий major page fault для конкретного процесса
sudo perf record -e major-faults -p $(pgrep -f "postgres: walwriter") -g sleep 60
# Просмотр результата с группировкой по стекам
sudo perf report --stdio
# Альтернатива через bpftrace, более лёгкая по нагрузке
sudo bpftrace -e 'tracepoint:exceptions:page_fault_user /comm == "postgres"/ { @[ustack] = count(); }'
Когда счётчик majflt у процесса базы стабильно растёт быстрее, чем на десятки в секунду, это сигнал, что рабочий набор не помещается в физическую память. Дальше есть три варианта: добавить RAM, уменьшить shared_buffers и доверить кеширование операционной системе, или подключить CXL-память как второй tier.
Когда обычный swap уже не справляется и в дело вступает CXL DRAM
Граница, на которой swap на NVMe перестаёт быть приемлемым решением, проходит примерно по соотношению "горячий рабочий набор больше 80% RAM". До этого порога периодические подъёмы холодных страниц со swap незаметны. После - производительность начинает деградировать нелинейно, потому что вытеснение и подгрузка страниц забивают очередь к NVMe и конкурируют с обычным IO базы данных.
CXL-устройства типа 3 решают именно эту проблему. Физически это карта расширения с модулями DDR5 на борту, подключаемая в слот PCIe Gen5 или в специальный слот EDSFF. Операционная система видит её как отдельный NUMA-узел без CPU, на который можно явно перенести часть памяти процесса. Латентность доступа выше, чем у локальной RAM, но в десятки раз ниже, чем у NVMe-swap.
Базовая проверка наличия CXL-устройств в системе:
# Список CXL-устройств через утилиту cxl из пакета cxl-cli
sudo cxl list -d -m
# Просмотр памяти как NUMA-узла
numactl --hardware
# Если CXL-память настроена как отдельный узел, она появится здесь
# например, node 2 size: 128000 MB без упоминания CPU
Если CXL-устройство сконфигурировано как memory-only NUMA node (обычно это node 2 или выше при двухсокетной системе), процесс базы данных можно явно привязать к локальным ядрам, но разрешить аллокацию памяти на CXL-узле:
# Запуск PostgreSQL с привязкой к CPU узла 0, память берётся из 0 и 2
sudo -u postgres numactl --cpunodebind=0 --membind=0,2 \
/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main
# Альтернатива через политику preferred - сначала локальная RAM, потом CXL
sudo -u postgres numactl --cpunodebind=0 --preferred=0 \
/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main
Для production-сценария чаще используют автоматический tiered memory через numad или kernel-параметр numa_balancing, которые сами мигрируют горячие страницы на быструю DRAM, а холодные оставляют на CXL. Эта функциональность активно развивалась в ядре с версии 5.15 и стала достаточно зрелой к 6.x.
# Включение автоматической балансировки между NUMA-узлами
sudo sysctl -w kernel.numa_balancing=1
# Проверка частоты и интервалов сканирования
cat /proc/sys/kernel/numa_balancing_scan_period_min_ms
cat /proc/sys/kernel/numa_balancing_scan_period_max_ms
# Ускорение обнаружения горячих страниц для активной базы
sudo sysctl -w kernel.numa_balancing_scan_period_min_ms=500
sudo sysctl -w kernel.numa_balancing_scan_period_max_ms=10000
Принципиальное отличие CXL от swap состоит в том, что обращение к CXL-памяти не вызывает page fault. Процессор просто выполняет load/store по адресу, который оказался физически дальше, и платит за это лишние сотни наносекунд. Никакого прерывания, никакого ухода в ядро, никакой обработки IO. Major faults в счётчике процесса остаются на нуле, даже если половина страниц лежит на CXL.
Подводные камни, которые проявляются только в продакшене и стоят денег
Первая ловушка - вытеснение grand buffer pool базы данных в swap. PostgreSQL и MySQL агрессивно используют shared_buffers и InnoDB buffer pool, которые в идеале должны лежать в физической RAM целиком. Если ядро решит, что эти страницы давно не использовались, и вытеснит их в swap - производительность базы провалится в пол. Решение - либо vm.swappiness в районе 1-10, либо явный mlock буферов через postgresql.conf:
# Параметры для предотвращения вытеснения shared_buffers
# В postgresql.conf
# shared_buffers = 16GB
# huge_pages = on
# По умолчанию huge pages не свопятся ядром
# Дополнительно - блокировка через лимиты
sudo tee /etc/security/limits.d/postgres.conf <<EOF
postgres soft memlock unlimited
postgres hard memlock unlimited
EOF
Вторая ловушка - износ NVMe от частого свопинга. Современные потребительские NVMe рассчитаны на 300-600 TBW (терабайт записи) за весь срок службы. База данных, которая активно свопит несколько гигабайт в час, может высадить ресурс диска за полтора-два года вместо заявленных пяти. Для серверных задач нужны enterprise-диски с DWPD (drive writes per day) от 1 и выше.
# Проверка пробега NVMe и оставшегося ресурса через smartctl
sudo smartctl -a /dev/nvme0 | grep -E "Data Units Written|Percentage Used|Available Spare"
# Альтернатива через nvme-cli
sudo nvme smart-log /dev/nvme0
Третья ловушка - thermal throttling. NVMe под постоянной нагрузкой греется, и при достижении пороговой температуры контроллер начинает снижать частоту операций. Для swap-нагрузки это означает, что в моменты пиковой активности базы латентность подъёма страниц может вырасти в несколько раз именно тогда, когда это критично.
# Мониторинг температуры NVMe в реальном времени
watch -n 2 'sudo nvme smart-log /dev/nvme0 | grep -E "temperature|throttle"'
# Длительный мониторинг через collectd, telegraf или prometheus-node-exporter
# с алертами на температуру выше 70°C
Четвёртая ловушка касается именно CXL и проявляется только при перегрузке шины. PCIe Gen5 x16 даёт около 64 ГБ/с в каждую сторону, и этого хватает с запасом для нормальной работы DRAM-устройства. Но если в той же системе крутятся NVMe SSD, GPU или сетевые карты на тех же линиях, общая полоса делится между всеми. Базу данных это касается напрямую: её фоновая запись в журнал, чекпоинты и репликация конкурируют с трафиком к CXL за одни и те же линии.
Стратегия выбора между добавлением RAM, swap на NVMe и CXL для разных типоразмеров баз
Для базы до 10 ГБ при сервере с 16-32 ГБ RAM никакие хитрости не нужны. Всё помещается, swap создаётся как страховка от OOM и лежит мёртвым грузом. Достаточно базовых настроек swappiness=10 и мониторинга.
Для базы 30-80 ГБ при сервере с 32-64 ГБ RAM начинаются интересные решения. Если рабочий набор стабильно меньше 80% памяти, swap на быстром NVMe закроет редкие пики без заметной деградации. Если рабочий набор больше или непредсказуемо колеблется, имеет смысл рассматривать либо вертикальный апгрейд RAM, либо CXL-карту на 64-128 ГБ. Цена CXL-устройства в районе 200 долларов за гигабайт против 8-12 долларов за гигабайт обычной серверной DDR5 пока делает их экономически выгодными только при невозможности добавить DIMM-слотами.
Для базы свыше 200 ГБ, которая категорически не помещается в разумный объём RAM, ни swap, ни CXL не дадут приемлемой производительности на горячих запросах. Здесь нужно либо корректное проектирование индексов и партиций для уменьшения горячего набора, либо переход на архитектуру с явным разделением hot/cold-данных и агрессивным кешированием на уровне приложения.
Любое решение должно подкрепляться измерениями. Запуск pgbench, sysbench или собственного бенчмарка под боевой нагрузкой с включённым мониторингом major page faults, латентности NVMe и пропускной способности шины покажет реальную картину. Цифры из спецификаций производителей и теоретические расчёты в этой области расходятся с практикой регулярно и на десятки процентов. Сервер с CXL-картой, который на бумаге должен был дать прирост в три раза, на конкретной нагрузке может проиграть тому же серверу с банально удвоенной обычной RAM - и наоборот, ситуации бывают разные.
Главное правило, которое выводится из всего этого, простое. NVMe не заменяет DRAM ни в каком виде, и попытки относиться к нему как к памяти заканчиваются плохо. Но как буфер последней надежды против OOM или как медленный, но рабочий tier для холодных данных - он выручает регулярно. CXL-память, в свою очередь, действительно даёт настоящий DRAM по более доступной цене за счёт большей латентности, и для баз данных нужного размера это становится осмысленным компромиссом. А счётчик major faults в /proc/<pid>/stat остаётся самым честным индикатором того, насколько правильно настроена вся эта конструкция.