Предел одного диска: сколько параллельных потоков он переварит до взрыва очереди
Приложение открывает сто соединений к базе, каждое соединение читает и пишет на диск, и в какой-то момент ответы начинают приходить не быстрее, а медленнее — хотя диск, по всем показателям, «не забит». Это типичная ловушка: у любого диска, будь то механический HDD, SATA SSD или NVMe-накопитель, есть предел числа операций, которые он реально обрабатывает одновременно. Всё, что сверх этого предела, не ускоряет работу, а просто стоит в очереди и накручивает время ответа. Разберём, откуда берётся этот предел, чем он отличается для HDD и SSD/NVMe, и как измерить и подобрать свою оптимальную глубину параллелизма, а не гадать.
Содержание
Что вообще значит «предел одного диска»
У диска нет счётчика «свободных слотов», который приложение видит напрямую, но с технической точки зрения предел вполне реален. Он складывается из двух вещей: сколько команд контроллер накопителя может физически обрабатывать в один момент времени, и сколько времени уходит на обработку каждой команды. Пока запросов меньше, чем контроллер способен переварить параллельно, рост числа одновременных запросов увеличивает и пропускную способность (IOPS, MB/s), и время ответа растёт слабо. Как только запросов становится больше предела, каждый новый запрос не ускоряет систему, а просто встаёт в очередь и ждёт своей очереди — пропускная способность перестаёт расти (или растёт совсем незначительно), а время ожидания начинает расти почти линейно вместе с длиной очереди.
Здесь работает простое интуитивное соотношение (по сути закон Литтла): средняя длина очереди перед диском примерно равна произведению скорости поступления запросов на среднее время их обработки. Если время обработки растёт из-за перегрузки, очередь растёт ещё быстрее — запросы не успевают покидать систему, и процесс усиливает сам себя. Поэтому предел одного диска — не одна цифра IOPS, а точка на кривой «пропускная способность / задержка», после которой кривая почти горизонтальна по пропускной способности и почти вертикальна по задержке. Найти эту точку для своей нагрузки — и есть практическая задача.
HDD: механика ограничивает параллелизм жёстко
У вращающегося диска ровно одна головка (точнее, один блок головок на все пластины), которая физически может находиться только в одном месте. Чтобы прочитать или записать данные, головка должна переместиться на нужный трек (seek) и дождаться, пока нужный сектор подъедет под неё (rotational latency). Эти механические задержки измеряются миллисекундами — на несколько порядков дольше, чем время электронной обработки команды. Никакая параллельность запросов не ускорит физическое перемещение головки.
То, что HDD всё же выигрывает от небольшой очереди команд (NCQ/TCQ), объясняется не параллельной обработкой, а возможностью переупорядочить запросы: контроллер видит несколько ожидающих операций и строит маршрут головки так, чтобы суммарный путь был короче — это elevator-алгоритм, по аналогии с лифтом, который не бегает по каждому вызову отдельно, а обслуживает этажи по пути. Такой выигрыш достигается уже на небольшой глубине очереди — нескольких запросов обычно хватает, чтобы получить почти весь эффект от переупорядочивания. Дальнейший рост очереди почти не добавляет пропускной способности, зато линейно добавляет задержку каждому запросу — он просто дольше ждёт своей очереди среди остальных.
Отсюда практический вывод для HDD и RAID-массивов на HDD: гнать на такой диск десятки параллельных потоков случайного чтения — способ гарантированно получить огромный await при том же (или почти том же) числе операций в секунду. Если нагрузка преимущественно последовательная (потоковое чтение большого файла, бэкап), картина другая — там важнее размер блока и минимизация seek за счёт последовательного доступа, а не глубина очереди.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверSSD и NVMe: параллелизм — это их архитектура
У SSD и особенно NVMe устройства картина обратная. Внутри накопителя данные размазаны по нескольким независимым каналам памяти (NAND channels), а внутри каждого канала — по нескольким кристаллам (dies) и плоскостям (planes), которые могут выполнять операции независимо друг от друга. Один запрос загружает работой только часть этих внутренних ресурсов; чтобы контроллер занял все каналы одновременно, ему нужно одновременно иметь несколько незавершённых команд — то есть глубокую очередь. Именно поэтому синтетический тест «одним потоком» на NVMe обычно показывает цифры заметно ниже паспортных: паспортные цифры производитель получает на высокой глубине очереди и при большом числе параллельных потоков, а не на QD1.
Протокол NVMe спроектирован под это архитектурно: спецификация допускает до 65535 очередей команд, каждая глубиной до 65535 команд — это на порядки больше, чем у AHCI/SATA с его единственной очередью NCQ глубиной 32. На практике ни одно реальное приложение не использует такие глубины целиком: у каждого конкретного накопителя (в зависимости от числа каналов, кристаллов и контроллера) есть своя точка, после которой рост очереди перестаёт давать прирост пропускной способности и начинает только добавлять задержку — то же самое «колено» кривой, что и у HDD, просто расположенное на порядки выше по числу одновременных операций. Где именно расположено это колено — вопрос конкретной модели диска и характера нагрузки (случайное/последовательное чтение или запись, размер блока), и его нужно измерять на своём оборудовании, а не брать из чужого бенчмарка.
Для виртуальных дисков на VPS добавляется ещё один слой: гипервизор и, возможно, сетевое хранилище (Ceph, iSCSI и подобные) вносят свою очередь и свои лимиты параллелизма, которые могут оказаться уже, чем у физического NVMe под капотом. Подробнее об этом — в разборе почему один поток не выжимает NVMe и в материале о том, откуда берётся глубина очереди команд NVMe.
Как увидеть предел через iostat -x
Основной инструмент — iostat из пакета sysstat с флагом -x (расширенная статистика по устройствам):
iostat -x 1
Интересуют несколько столбцов (названия и точный набор колонок отличаются между версиями sysstat, но смысл сохраняется):
avgqu-sz(в новых версиях sysstat —aqu-sz) — средняя длина очереди запросов к устройству за интервал. Растущийaqu-szпри неизменной пропускной способности — прямой признак того, что запросы копятся быстрее, чем диск успевает их обрабатывать.await(а также раздельныеr_await/w_awaitв новых версиях) — среднее время от постановки запроса в очередь ОС до его завершения, включая время ожидания в очереди. Это то, что реально «чувствует» приложение.svctm— историческая колонка «время обслуживания одной операции устройством», которая вычислялась как приближение и в современных версиях sysstat из вывода убрана (или помечена как ненадёжная), потому что на устройствах с внутренним параллелизмом и глубокой очередью команд (NVMe, SSD) она перестаёт что-либо означать: обслуживание там не последовательное. Если колонка есть в вашей версии — не стройте на ней выводы, ориентируйтесь наawaitиaqu-sz.%util— доля времени, когда в очереди устройства был хотя бы один незавершённый запрос. Для однозадачного HDD это неплохой индикатор загрузки, но для NVMe с глубокой очередью 100%%utilдалеко не всегда значит «предел» — устройство может держать одну команду «в работе» почти постоянно и при этом быть загружено на малую долю своей реальной ёмкости. Разбор этой ловушки — в статье про то, почему 100% утилизации не значит предел.
Иллюстративный (не бенчмарочный, просто для понимания формы) пример того, как выглядит переход через предел при росте нагрузки:
Устройство IOPS avgqu-sz await(ms)
nvme0n1 низкая ~1 низкий
nvme0n1 выше растёт растёт умеренно
nvme0n1 ещё выше почти не растёт растёт резко <- предел пройден
Ключевой сигнал — не абсолютное значение await, а момент, когда aqu-sz продолжает расти, а IOPS/MB/s перестают. Это и есть точка, где вы упёрлись именно в параллелизм устройства, а не в CPU, сеть или приложение — как отличить одно от другого, подробно разобрано в статье как отличить упор в диск от упора во всё остальное.
Как найти оптимальную глубину очереди для своей нагрузки
Единственный надёжный способ — измерить, а не взять число из документации производителя или чужой статьи (оно будет верно для чужого диска и чужого паттерна доступа, но не обязательно для вашего). Практическая методика:
- Прогнать синтетический тест (например,
fio) с одинаковым паттерном доступа (случайное чтение блоками 4K — типично для БД, или последовательное чтение большими блоками — типично для бэкапов) при разной глубине очереди: 1, 2, 4, 8, 16, 32, 64. - Для каждой глубины зафиксировать пропускную способность (IOPS или MB/s) и среднюю/хвостовую задержку (p50, p99).
- Построить (хотя бы мысленно, а лучше в таблице) две кривые: throughput(depth) и latency(depth). Пропускная способность растёт, потом выходит на плато. Задержка растёт медленно, потом резко ускоряется. Точка, где вторая кривая начинает расти быстрее первой — это и есть «колено», ваш практический предел.
- Выбирать рабочую глубину чуть ниже колена, а не на максимуме теста: максимальная глубина обычно даёт самую высокую цифру IOPS в рекламных целях, но за счёт заметно выросшего p99 — то есть отдельные запросы будут ждать значительно дольше среднего.
Подробная методика самого замера — в статье про честный замер диска через fio.
Дальше найденную глубину нужно перенести с уровня «синтетический тест» на уровень «реальное приложение», а там параллелизм к диску обычно регулируется не напрямую, а через настройки конкретного сервиса:
| Компонент | Что регулирует параллелизм к диску |
|---|---|
| PostgreSQL | effective_io_concurrency, число фоновых воркеров, размер connection pool |
| MySQL/InnoDB | innodb_io_capacity, innodb_read_io_threads/innodb_write_io_threads |
| Nginx | aio threads=..., размер пула потоков для файловых операций |
| Общее приложение | размер пула соединений к БД, размер пула воркеров для файловых операций |
Смысл настройки один и тот же независимо от компонента: дать диску столько параллельных запросов, сколько он реально способен переварить с приемлемой задержкой, а не столько, сколько «может отправить» приложение — иначе диск сам не разрулит перегрузку, это придётся делать вам через ограничение параллелизма выше по стеку.
Что происходит при перегрузке очереди
Когда в диск летит больше запросов, чем он способен обработать с разумной задержкой, происходит не «отказ», а постепенная деградация, которую легко спутать с чем-то другим:
- Задержка растёт нелинейно. Пока очередь короткая, добавление запроса почти не влияет на время ожидания остальных. Когда очередь становится длинной, каждый новый запрос добавляет задержку всем, кто уже стоит перед ним — рост становится похож на снежный ком.
- Хвостовая задержка (p99, p999) растёт сильнее средней. Средний
awaitможет выглядеть терпимо, но конкретным «невезучим» запросам, вставшим в конец растущей очереди, достаётся многократно больше среднего. Для интерактивных сервисов именно хвост определяет жалобы пользователей. - Потоки/воркеры приложения блокируются на I/O. Если параллелизм к диску реализован через синхронные блокирующие потоки (а не асинхронный I/O), лишние ожидающие потоки не просто бездействуют — они занимают память под стек, а планировщик ОС тратит время на переключение контекста между ними, добавляя накладные расходы поверх и без того медленного диска.
- Эффект каскадом уходит выше по стеку. Медленные ответы БД превращаются в растущую очередь запросов веб-приложения, та — в исчерпание пула соединений, дальше — в таймауты у клиентов. Диск, который «просто был перегружен на секунды», превращается в видимый простой сервиса на минуты, потому что очереди на каждом уровне стека складываются.
%utilперестаёт расти раньше, чем реальная нагрузка. На NVMe с глубокой очередью команд метрика может показывать 100% задолго до фактического насыщения (см. выше), поэтому ориентироваться нужно на связкуaqu-sz+await, а не на одну цифру утилизации. Полезный смежный разбор — куда уходит время CPU при таком ожидании — в статье про iowait и куда уходит процессор.
Практические меры: ограничить параллелизм на уровне приложения (пулы, семафоры) до значения ниже «колена»; при нескольких нагрузках на одном диске — использовать cgroups (io.max) или ionice, чтобы фоновые задачи (бэкап, переиндексация) не отбирали очередь у интерактивной нагрузки; и разносить нагрузки по разным дискам, если предела одного систематически не хватает под пиковую параллельность.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли просто выставить глубину очереди на максимум и не думать?
Формально можно, но на практике почти всегда хуже: пропускная способность на очень высокой глубине растёт незначительно по сравнению с «коленом», а хвостовая задержка отдельных запросов заметно увеличивается. Разумнее найти рабочую точку измерением, а не ставить максимум «на всякий случай».
Если %util показывает 100%, это точно означает предел диска?
Для HDD — как правило да: у него один физический ресурс (головка), и 100% времени с непустой очередью означает, что головка занята постоянно. Для SSD/NVMe это не так однозначно — устройство может показывать 100% %util, обрабатывая лишь малую часть реальной пропускной способности, если глубина очереди от приложения недостаточна.
Виртуальный диск на VPS ведёт себя так же, как физический?
Похоже, но не идентично. Гипервизор и, если используется сетевое хранилище, добавляют свою очередь и задержку поверх аппаратной. Реальный предел конкретного виртуального диска нужно измерять на месте — характеристики физического накопителя под капотом не гарантируют тот же предел на уровне виртуальной машины.
Почему тест dd одним потоком показывает результат хуже, чем в характеристиках диска?
Потому что dd по умолчанию отправляет запросы последовательно, один за другим (глубина очереди 1), а паспортные цифры производителя обычно получены на высокой глубине и при нескольких параллельных потоках — режиме, под который спроектирована архитектура SSD/NVMe.
Нужно ли беспокоиться о пределе параллелизма на маленьком проекте с редкими запросами?
Если нагрузка стабильно далека от предела (виден низкий и не растущий aqu-sz), тема скорее теоретическая — держите её в уме на случай роста нагрузки или пиков, а не как повод оптимизировать то, что пока не является проблемой.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →