Persistence · 13 мин

Контент v0.12.0

Жёсткие диски и планирование запросов

Зачем разбирать устаревшее железо

В облаке ваш сервис почти наверняка стоит на SSD. Но жёсткие диски (HDD, hard disk drive) никуда не делись: на них лежат холодные данные, бэкапы, архивы объектных хранилищ, потому что гигабайт на HDD в несколько раз дешевле. И главное — физика HDD объясняет, почему базы данных, файловые системы и брокеры сообщений устроены именно так. Привычка писать последовательно, а не вразнобой, родилась из боли работы с жёстким диском и сохранилась в журналах Postgres, логах Kafka и LSM-деревьях.

В прошлой главе мы разобрали, как ОС общается с любым устройством. Теперь посмотрим, что это за устройство и почему обращаться к нему надо с умом.

Что внутри

Похоже на проигрыватель винила: крутится пластинка, над ней игла на рычаге. Чтобы услышать нужную песню, иглу подводят к дорожке и ждут, пока начало трека доедет до неё.

  • Пластина (platter) — круглый диск с магнитным покрытием. Их в корпусе несколько, они насажены на общую ось и вращаются вместе.
  • Шпиндель (spindle) — ось с мотором. Скорость вращения в RPM (оборотов в минуту): 5400 и 7200 у обычных дисков, 10 000 и 15 000 у старых серверных.
  • Дорожка (track) — концентрическое кольцо на поверхности пластины. На одной поверхности их сотни тысяч.
  • Сектор (sector) — участок дорожки, минимальная единица чтения и записи. Исторически 512 байт, у современных дисков физически 4 КБ (часто с эмуляцией 512-байтных секторов для совместимости). Прочитать полсектора диск не умеет.
  • Головка (head) — читает и пишет, парит над поверхностью на расстоянии в единицы нанометров. Головки всех поверхностей закреплены на одном рычаге и двигаются вместе.
   Вид на пластину сверху:
 
        ________________
      /     дорожки      \
     /   ____________      \
    /   /   ______   \      \
   |   |   /  ()  \   |      |     () — шпиндель
   |   |   \______/   |      |
    \   \____________/      /
     \                     ● ←─── головка на рычаге
      \________________   /│
                           │ рычаг двигает головку
                             к центру и от центра

Чтобы прочитать сектор, нужно поставить головку над его дорожкой и дождаться, пока сектор проедет под ней.

Три составляющие времени доступа

  T_доступа = T_seek + T_rotation + T_transfer
 
  seek      — перевести головку на нужную дорожку       ~4–10 мс
  rotation  — дождаться, пока сектор доедет до головки  ~2–4 мс
  transfer  — прочитать данные, пока сектор под головкой  доли мс
  • Seek (позиционирование) — механическое движение рычага: разогнать, остановить, точно навестись на дорожку. Средний seek у дисков на 7200 RPM порядка 8–9 мс. Короткий прыжок на соседнюю дорожку — меньше миллисекунды, через весь диск — 15–20 мс.
  • Rotation (вращательная задержка) — головка над дорожкой, но нужный сектор может быть где угодно на окружности. В среднем ждём половину оборота.
  • Transfer (передача) — само чтение. Диск выдаёт 150–250 МБ/с, поэтому 4 КБ читаются за сотые доли миллисекунды.

Время одного оборота легко посчитать:

  T_оборота (мс) = 60 000 / RPM
 
   5400 RPM → 11,1 мс/оборот → средняя rotation ≈ 5,6 мс
   7200 RPM →  8,3 мс/оборот → средняя rotation ≈ 4,2 мс
  15000 RPM →  4,0 мс/оборот → средняя rotation ≈ 2,0 мс

Вывод: две из трёх составляющих — механика, и именно они занимают почти всё время. Электроника передаёт данные быстро, но сначала надо дождаться, пока железо физически встанет в нужное место.

Случайный доступ против последовательного

Посчитаем на числах. Диск 7200 RPM, средний seek 9 мс, последовательная скорость 150 МБ/с. Сколько стоит прочитать один случайный блок на 4 КБ?

package main
 
import "fmt"
 
func main() {
	const (
		rpm        = 7200.0
		seekMs     = 9.0   // средний seek из паспорта диска
		transferMB = 150.0 // МБ/с при чтении подряд
		blockKB    = 4.0
	)
 
	rotationMs := 60000.0 / rpm / 2                  // в среднем полоборота
	transferMs := blockKB / 1024 / transferMB * 1000 // 4 КБ при 150 МБ/с
	accessMs := seekMs + rotationMs + transferMs
 
	iops := 1000 / accessMs
	randomMBs := iops * blockKB / 1024
 
	fmt.Printf("rotation:   %.2f мс\n", rotationMs)
	fmt.Printf("transfer:   %.3f мс\n", transferMs)
	fmt.Printf("доступ:     %.2f мс\n", accessMs)
	fmt.Printf("random 4К:  %.0f IOPS = %.2f МБ/с\n", iops, randomMBs)
	fmt.Printf("sequential: %.0f МБ/с, разница в %.0f раз\n", transferMB, transferMB/randomMBs)
}
rotation:   4.17 мс
transfer:   0.026 мс
доступ:     13.19 мс
random 4К:  76 IOPS = 0.30 МБ/с
sequential: 150 МБ/с, разница в 507 раз

Из 13 мс на передачу данных уходит 0,026 мс, остальное — ожидание механики. Получается около 76 операций в секунду (IOPS, input/output operations per second) и 0,3 МБ/с. Последовательное чтение того же диска даёт 150 МБ/с: механика оплачивается один раз в начале, дальше данные идут потоком, пока пластина крутится. Разница в сотни раз.

На практике случайные IOPS у HDD чуть выше расчётных (диск переупорядочивает очередь запросов, о чём ниже), порядка 100–200. Но порядок разницы с последовательным доступом сохраняется: 100–200 IOPS — это потолок одного HDD, и это число полезно помнить. Если база на HDD делает 500 случайных чтений в секунду, ей не хватит одного диска, сколько бы CPU ни было.

Отсюда архитектурные решения, которые вы встретите дальше по курсу:

  • базы данных пишут изменения сначала в последовательный журнал (WAL), а разбрасывают их по местам потом, пачками (см. fsync и durability);
  • Kafka хранит сообщения как лог, который только дописывается в конец, поэтому нормально работает даже на HDD;
  • LSM-деревья копят записи в памяти и сбрасывают большими последовательными кусками (см. LFS);
  • файловые системы стараются класть блоки одного файла рядом (см. реализацию ФС).

Планирование запросов к диску

Раз seek такой дорогой, а в очереди к диску обычно ждут несколько запросов, их можно переупорядочить так, чтобы головка двигалась меньше. Этим занимается планировщик ввода-вывода (I/O scheduler). Задача похожа на планирование процессов, только ресурс — движение головки.

Пример, на котором будем сравнивать политики: головка над дорожкой 53, в очереди запросы к дорожкам 98, 183, 37, 122, 14, 124, 65, 67 (в порядке поступления). Мерить будем суммарный путь головки в дорожках — он примерно пропорционален суммарному времени seek.

FIFO — в порядке поступления

  53 → 98 → 183 → 37 → 122 → 14 → 124 → 65 → 67
 
  шаги: 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2 = 640

Головка мечется через весь диск: 183 → 37, 122 → 14, 14 → 124. Справедливо, но 640 дорожек пути.

SSTF — ближайший первым

SSTF (shortest seek time first) каждый раз выбирает запрос, ближайший к текущему положению головки.

  от 53: ближе всех 65 (12)        → 65
  от 65: 67 (2)                    → 67
  от 67: 37 (30) или 98 (31)? 37   → 37
  от 37: 14 (23)                   → 14
  от 14: остались 98..183, ближе 98 (84) → 98
  дальше 122 (24), 124 (2), 183 (59)
 
  53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183
  шаги: 12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 = 236

Почти втрое короче FIFO. Обратите внимание на шаг от 67: дорожка 37 ближе 98 на одну дорожку, и это решение разворачивает головку влево. Жадный выбор хорош локально, но у него есть беда — голодание. Если рядом с головкой постоянно появляются новые запросы, далёкий запрос (например, к дорожке 183) может ждать сколько угодно. Та же проблема, что у SJF при планировании процессора: жадность к ближнему обижает дальних.

SCAN — лифт

SCAN (его называют «лифтом», elevator) едет в одну сторону, обслуживая всё по пути, потом разворачивается и едет обратно. Как лифт, который сначала везёт всех вверх, потом всех вниз.

  едем вверх от 53: 65 → 67 → 98 → 122 → 124 → 183
  разворот, едем вниз: 37 → 14
 
  если разворачиваться на последнем запросе (183):
     (183 − 53) + (183 − 14) = 130 + 169 = 299
  если ехать до края диска (пусть это дорожка 199):
     (199 − 53) + (199 − 14) = 146 + 185 = 331

Строго говоря, SCAN доезжает до края диска, а вариант, который разворачивается на последнем запросе, называется LOOK. На практике используют именно его, и в задаче к этой главе, и в симуляторе ниже «SCAN» разворачивается на крайнем запросе — то есть даёт 299.

SCAN немного проигрывает SSTF по пути (299 против 236), зато никто не голодает: любой запрос будет обслужен самое позднее за два прохода головки.

C-SCAN — всегда в одну сторону

У SCAN есть перекос: дорожки в середине диска головка проезжает дважды за цикл (туда и обратно), а крайние — один раз, и там запросы в среднем ждут дольше. C-SCAN (circular SCAN) обслуживает запросы только при движении в одну сторону, а доехав до конца, быстро возвращается в начало без обслуживания. Время ожидания становится равномернее для всех дорожек.

Политика Путь в примере Плюс Минус
FIFO 640 справедливо, просто головка мечется
SSTF 236 меньше всего пути голодание дальних запросов
SCAN (LOOK) 299 нет голодания, предсказуемо середина обслуживается чаще краёв
C-SCAN — равномерное ожидание холостой возврат

Все три политики вы реализуете в Go-задаче «Суммарное перемещение головки диска», а в симуляторе ниже можно переключать политики на том же примере и смотреть путь головки.

головка
0469213718353 (старт)6567371498122124183
суммарное перемещение головки: 236 цилиндров · порядок: 53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183
Головка ходит по цилиндрам. Переключайте политику (FIFO, SSTF, SCAN) и смотрите, как меняется путь головки: суммарное перемещение примерно пропорционально времени на seek. SCAN здесь разворачивается на самом дальнем запросе, а не на краю диска (в литературе это вариант LOOK), поэтому его цифры чуть лучше, чем у «настоящего» лифта.

Кто на самом деле планирует

Современный диск знает свою геометрию точнее ОС: где какой сектор, с какой скоростью вращается пластина прямо сейчас. Поэтому значительная часть планирования живёт в прошивке. Через механизм NCQ (native command queuing) ОС отдаёт диску до 32 запросов сразу, и диск сам выбирает порядок, учитывая и seek, и вращение. Такую политику называют SPTF (shortest positioning time first): иногда выгоднее съездить на дальнюю дорожку, где нужный сектор подъедет через миллисекунду, чем на ближнюю, где ждать почти полный оборот.

Планировщик ОС делает остальное: склеивает соседние запросы в один большой (merging), следит, чтобы запросы не ждали слишком долго, и делит диск между процессами. В Linux текущий планировщик устройства видно так:

cat /sys/block/sda/queue/scheduler

В квадратных скобках — активный, например [mq-deadline] kyber bfq none. mq-deadline упорядочивает запросы, но гарантирует каждому крайний срок (лечит голодание), bfq делит пропускную способность между процессами по весам, none не делает ничего. Для HDD обычно стоит mq-deadline или bfq, для NVMe — none: SSD нечего экономить на позиционировании, и лишний слой только добавляет задержку (почему — в следующей главе).

Проверь себя· Жёсткие диски и планирование

Какая составляющая времени доступа к HDD обычно самая дорогая?

Почему случайный доступ к HDD на порядки медленнее последовательного?

Диск вращается со скоростью 7200 RPM. Чему примерно равна средняя вращательная задержка (rotation), в миллисекундах? Округлите до целого.

В чём главный недостаток политики SSTF (ближайший первым)?

Какие политики планирования борются с голоданием дальних запросов за счёт движения головки «как лифт»?

Чем C-SCAN отличается от обычного SCAN?

Частые ошибки

  • Ожидать от HDD тысяч случайных IOPS: потолок одного диска — порядка 100–200.
  • Оценивать диск по последовательной скорости из паспорта, когда нагрузка случайная.
  • Путать SCAN и LOOK: в задаче и симуляторе курса разворот происходит на крайнем запросе.
  • Ставить на NVMe планировщик, рассчитанный на HDD, и наоборот оставлять none на HDD.
  • Забывать, что SSTF может бесконечно откладывать дальние запросы.

Проверьте себя

  1. Почему из 13 мс случайного чтения 4 КБ на передачу данных уходит меньше 0,1 мс?
  2. Чему равна средняя вращательная задержка у диска 5400 RPM?
  3. Почему в примере SSTF после дорожки 67 идёт к 37, а не к 98?
  4. Посчитайте суммарный путь для примера из главы по C-SCAN: едем вверх до дорожки 199, возвращаемся на 0 без обслуживания и снова едем вверх. Сколько получится с учётом холостого возврата и без него?
Показать ответы

Ответы. (1) Остальное время — механика: seek около 9 мс и в среднем полоборота около 4,2 мс. (2) Оборот 60 000 / 5400 ≈ 11,1 мс, половина — около 5,6 мс. (3) До 37 — 30 дорожек, до 98 — 31. (4) Вверх 53 → 199: 146; возврат 199 → 0: 199; вверх до 37 (обслужив 14): 37. С возвратом 382, без него 183. Учитывать ли возврат — вопрос модели: головка физически движется, но быстрее и без обслуживания.

Что спрашивают на собесе

  • Из чего складывается время доступа к HDD? Seek + rotation + transfer. Seek и rotation — механика, миллисекунды; transfer для небольших блоков — доли миллисекунды.
  • Как посчитать вращательную задержку? Оборот = 60 000 / RPM мс, в среднем ждём половину: 7200 RPM → ~4,2 мс.
  • Сколько IOPS даёт HDD и почему random в сотни раз медленнее sequential? Порядка 100–200 IOPS: каждая случайная операция платит ~10 мс механики. При последовательном чтении механика оплачивается один раз.
  • Как это повлияло на архитектуру БД? WAL и логи пишутся последовательно, LSM сбрасывают данные большими кусками, ФС кладёт блоки файла рядом.
  • Чем плох SSTF и как помогает SCAN? SSTF может бесконечно откладывать дальние запросы. SCAN/LOOK обходит диск как лифт и обслуживает каждый запрос за ограниченное время.
  • Зачем C-SCAN? Выравнивает время ожидания между серединой и краями.
  • Кто планирует на самом деле? И ОС (слияние, дедлайны, справедливость), и прошивка диска через NCQ (SPTF с учётом вращения).

Продолжить