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

TL;DR

  • Проблема миллиардов мелких файлов решается упаковкой — все данные хранятся в больших контейнерах (append-only лог), а не как отдельные файлы в файловой системе
  • Отказ от удаления файлов — вместо удаления используется кольцевой буфер (wraparound) с перезаписью, что исключает фрагментацию и нагрузку от операций удаления
  • HDD могут быть эффективны — при правильной организации данных (последовательная запись, batch-операции) можно достичь хорошей производительности
  • Эволюция архитектуры: от системы с merge и сложными фоновыми процессами (2020) к более простой архитектуре без merge, где tiering (RAM/SSD/HDD) — это просто правило размещения, а не отдельный пайплайн
  • Zero-copy критичен для производительности — использование sendfile и оптимизированных путей чтения позволяет избежать копирования данных в user space
  • TLS в Go медленный — встроенный TLS не поддерживает zero-copy, поэтому на больших объёмах трафика это заметно бьёт по производительности (решение: kTLS)
  • GC создаёт проблемы — при работе с большими индексами в памяти (десятки миллионов объектов) garbage collector становится узким местом
  • Метрики и профилирование обязательны — без них невозможно понять, что происходит в системе и где реальные проблемы

Вступление: откуда взялась проблема

Мы работаем над сетью доставки контента и видеоплатформой. У нас есть собственный плеер, и мы можем выбирать сервер, с которого будем раздавать видео. Долгое время это хорошо работало и масштабировалось — без заметных проблем.

Но к нам пришли клиенты, которые хотели использовать только CDN — они не хотели пользоваться другими нашими услугами. Соответственно, мы использовали их как origin. Origin — это может быть целый кластер с огромным количеством данных.

Проблемы начались, когда мы достигли примерно 4 гигабит на сервер — мы упали, и упала часть сети, которая раздавала контент пользователям.

В чём были проблемы

Основная проблема — огромное количество файлов. На сервере их было миллиарды. Соответственно:

  • Не работал кэш открытых файлов — файлы постоянно открывались и закрывались
  • Постоянно происходило чтение с разных кусков диска — супер неудобно
  • Мы постоянно что-то писали и постоянно что-то удаляли
  • Кэш, который работал, нам не помогал — как он работает, было непонятно

Нужно было с этим что-то делать.

Требования к системе

Мы составили план того, что хотим от сервера. Требования были достаточно простые:

  • Хранить больше 100 терабайт данных на сервер — это был минимум
  • Логика доступа к контенту — подписи, время жизни, различные лимиты
  • Больше метрик, чем даёт nginx — нам нужна была детальная статистика
  • Дешёвые логи — данных постоянно пишем очень много, хотелось их оптимизировать
  • Хранить больше информации о контенте — метаданные, которые нам нужны
  • Горизонтально масштабироваться — один сервер не может хранить всю информацию origin’а клиента
  • Дешёвое решение — обычные жёсткие диски, которые крутятся, никакого NVMe

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

Версия 1 (2020): решение проблемы с мелкими файлами

Первое, во что мы упёрлись, — мелкие файлы. От них нужно было избавиться. Мы стали создавать на диске несколько больших партиций и складывать данные внутрь. Физически это два файла:

  1. Файл метаданных — фиксированного размера: ключ/идентификатор, смещение, флаги (например, «удалён»), checksum и т.п.
  2. Файл данных — «контейнер», куда последовательно пишется контент

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

Примечание: в новой системе (cdn/edge) используется та же идея (контейнеры, метаданные), но реализация упрощена: один файл data.bin на диск вместо раздельных файлов метаданных и данных. Метаданные хранятся в заголовке каждого объекта, а индекс в памяти позволяет быстро найти нужный объект.

Структура хранения: контейнеры и метаданные

disk partition
   |
   +-- metadata file (fixed size)
   |      |
   |      +-- key/id -> offset, flags, checksum
   |      +-- key/id -> offset, flags, checksum
   |      +-- ...
   |
   +-- data container (sequential writes)
          |
          +-- object #1 (offset: 0)
          +-- object #2 (offset: N)
          +-- object #3 (offset: M)
          +-- ...

Вместо миллиардов мелких файлов в файловой системе — один контейнер данных и индекс метаданных в памяти.

Версия 1 (2020): работа с дисками: быстрые и обычные

Мы ввели два класса носителей: быстрые (SSD/NVMe) и обычные (HDD).

Общая очередь на запись

У нас есть общая очередь на запись. На каждый диск — свой воркер (горутина): кто свободен, тот берёт следующую задачу и пишет на диск. Так запись получается более ровной и предсказуемой.

write requests
   |
   v
shared queue
   |
   +---> worker (disk 1) --> HDD #1
   +---> worker (disk 2) --> HDD #2
   +---> worker (disk 3) --> HDD #3
   +---> ...

Каждый воркер берёт следующую задачу из общей очереди и пишет на свой диск последовательно.

Эвристика для мелких и больших файлов

У нас есть простая эвристика: «крупные» объекты сразу пишем на HDD (порог порядка ~225 МБ), а мелочь — сначала на SSD.

SSD дороже, и при огромном количестве мелких объектов/операций там тоже начинаются свои проблемы — особенно на random I/O.

Merge: склеивание файлов (версия 1)

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

Что мы делали в версии 1: периодически «склеивали» (merge) эти куски в последовательные сегменты. Для этого собирали статистику: где лежит кусок, на каком диске, в каком контейнере — и старались выстроить порядок так, чтобы дальше читать/писать максимально последовательно.

Если какое-то видео начинали активно смотреть, мы брали соответствующие фрагменты, упорядочивали и переписывали на HDD уже «как нормальный файл» — одним последовательным куском.

В результате мы существенно (примерно на порядок) снизили random I/O на дисках.

Важно: в новой системе (cdn/edge) merge убран. Вместо этого tiering работает как правило размещения при записи: мелкие объекты сразу пишутся на SSD (если доступен), а не переписываются потом. Это упрощает архитектуру и делает поведение более предсказуемым.

До и после merge

До merge (random I/O):
SSD: [chunk1] ... [chunk5] ... [chunk3] ... [chunk7]
     |          |          |          |
     +---> random reads при воспроизведении

После merge (sequential I/O):
HDD: [chunk1][chunk3][chunk5][chunk7] (последовательно)
     |                                |
     +---> sequential read, быстрее и дешевле

Кольцевой буфер: отказ от удаления файлов

Отдельная боль — удаление. В «классических» схемах (например, с nginx как proxy‑cache) есть постоянный churn: что-то записали, что-то вытеснили, что-то удалили. И это тоже создаёт нагрузку на диск.

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

У такого подхода есть два приятных эффекта:

  1. Смещение у нас фиксированное — физически файлы остаются, то есть мы их не теряем
  2. Мы продолжаем читать, пока мы их не перетёрли — поэтому не получается так, что когда мы освобождаем место, данные сразу становятся недоступными

Примечание: этот подход сохранился и в новой системе (cdn/edge). Wraparound работает на уровне одного файла data.bin — когда файл заполняется, указатель записи сдвигается в начало, и начинается перезапись старых данных. Это исключает фрагментацию и нагрузку от операций удаления.

Кольцевой буфер вместо удаления

container (fixed size)
   |
   +-- [old data] [old data] [old data]
   |      |                              |
   |      +-- read pointer (можно читать)
   |                                        |
   +-- write pointer (перезаписываем с начала)
        |
        +-- [new data] [new data] ...

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

С дисками всё работает нормально.

Раздача контента (обе версии)

Файл ещё нужно отдать. Упрощённая схема выглядит так: есть интернет, к нам приходит запрос через DNS, он доходит до нашего сервера, и он его отдаёт.

Мы используем HTTP/1.1 — для раздачи видео в нашем случае он отлично подходит и достаточно прост1. Поэтому мы написали свой веб‑сервер и интегрировали его с дисковым хранилищем.

Примечание: в новой системе (cdn/edge) раздача контента работает по тому же принципу, но с оптимизациями: zero-copy через SendFile/ReadFrom, автоматическая компрессия для хранения и распаковка по условиям, поддержка HTTP Range запросов для потоковой передачи.

Шардирование данных

Так как мы не можем уместить весь контент клиента в один сервер, данные шардируются.

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

Edge proxy: как выбираем «правильный» сервер

Запросы, которые должны шардироваться (по доменным адресам), ведут не на edge, а на IP proxy. Дальше proxy принимает входящий HTTPS, читает HTTP/1.1 запрос и выбирает upstream‑edge по consistent hashing.

  • Ключ шардирования: Host + dirname(path). Это намеренно не весь URL: так мы стараемся «склеивать» близкие по структуре запросы в один шард и уменьшать лишние промахи/перегон по сети.
  • Деградация: если выбранный upstream не отвечает/коннектится, он помечается «выключенным» на короткое время (порядка нескольких секунд), и запрос идёт через fallback (например, на backend/другой upstream).
  • Пулы соединений: чтобы не платить цену dial на каждом запросе, держим небольшой пул keep‑alive соединений на upstream и переиспользуем их.
  • Метрики: latency и sent bytes по адресу upstream + статистика коннектов (reuse/open/error/bad).

Для большинства запросов проксирование не используется: мы сразу выбираем конкретные адреса edge (и клиент попадает сразу на нужный edge), поэтому лишнего “edge→edge” транзита по сети нет.

Как работает шардирование

client request
   |
   v
DNS
   |
   +---> sharded domains -> proxy IP
   |         |
   |         +---> proxy: build key = Host + dirname(path)
   |         |
   |         +---> consistent hash -> target edge
   |         |
   |         +---> proxy forwards request -> edge serves response
   |
   +---> non-sharded domains -> specific edge IPs
             |
             +---> edge serves response

Итог: для шардируемых доменов “точка входа” — proxy (он и делает consistent hashing). Для остальных доменов мы стараемся сразу направлять клиента на конкретные edge‑адреса без проксирования.

Доступ к файлу

Как это выглядит внутри: пришёл запрос, мы быстро делаем lookup по индексу (где лежит объект, какое смещение и т.п.), а затем читаем данные из контейнера.

Индекс держим в памяти (поднимаем при старте). На чтении мы в ряде мест используем обычный read.

Путь запроса: от клиента до диска

HTTP request
   |
   v
parse URL -> build key
   |
   v
in-memory index lookup
   |      |
   |      +---> object metadata (offset, size, disk, container)
   |
   v
open container file
   |
   v
seek to offset
   |
   v
read data (size bytes)
   |
   v
send to client (HTTP response)

Индекс в памяти позволяет быстро найти, где лежит объект, без обращения к файловой системе.

Кэширование

В первой версии мы действительно держали отдельный кэш в памяти на каждом edge (LRU, десятки гигабайт) — иначе чтение «в лоб» с диска быстро упиралось бы в латентность.

В текущей реализации (cdn/edge) отдельного «кэша как компонента» нет: часть данных держим в памяти по эвристике (она описана в статье), а в целом за кэширование отвечает page cache операционной системы — и на практике это работает.

Что получилось: сравнение версий

Версия 1 (2020): система с merge

Типовая конфигурация сервера: 32 ядра, около 195 ГБ памяти и два сетевых адаптера.

Система работала и справлялась с нагрузкой, но:

  • Merge создавал периодические всплески нагрузки
  • Операционные расходы на поддержку merge были высокими
  • Поведение системы было менее предсказуемым из‑за фоновых процессов

Исторически (на том этапе, о котором речь выше) у нас было много SSD «с запасом», но со временем выяснилось, что их можно использовать меньше: на SSD приходилось около 20% чтения, остальное — HDD.

Актуальное состояние на 2025: разница в цене SSD/HDD уже не такая, и с оптимизациями новой версии edge мы постепенно вытесняем HDD. Сейчас типовой сетап — 2/3 HDD и 1/3 SSD, и доля SSD будет расти: это стало экономически выгоднее (с одного unit получаем больше трафика).

Нагрузка варьируется по времени и по клиентам, но в целом всё работает. Иногда мы упирались примерно в 50 Гбит/с на сервер — дальше ограничение задаёт сеть, а не диск.

Версия 2 (новая система): без merge

Новая система (cdn/edge) сохранила производительность, но стала проще:

  • Нет merge — tiering работает как правило размещения, без фоновых процессов
  • Более предсказуемое поведение — нет периодических всплесков от перезаписи
  • Проще эксплуатация — меньше компонентов, которые нужно настраивать и отлаживать
  • Та же производительность — система справляется с теми же нагрузками, но стабильнее

Что осталось сложным:

  • GC всё ещё создаёт проблемы при больших индексах (десятки миллионов объектов)
  • TLS требует оптимизаций (kTLS) для достижения максимальной производительности
  • Мониторинг и метрики критичны — без них невозможно понять, что происходит в системе

Версия 1 (2020): грабли и давление реальности

После того как мы построили первую версию системы с контейнерами, merge и кольцевым буфером, она работала, но со временем накопились проблемы, которые заставили нас переосмыслить архитектуру.

Проблема номер один: TLS

Мы много пишем на Go и хорошо его знаем. Но на этой задаче Go местами создал проблемы.

Первая проблема, с которой мы столкнулись — это TLS. Сейчас практически весь трафик шифруется, редко где можно встретить незашифрованный трафик. Соответственно, нужно обрабатывать TLS-запросы.

В Go есть встроенный TLS — у него отличный API, который нас вполне устраивает: мы можем менять сертификаты на лету, даже без остановки сервера. Но по производительности он не очень быстрый.

Практическая заметка. Встроенный TLS в Go (crypto/tls) удобен по API, но на больших объёмах трафика может стать узким местом из-за отсутствия поддержки zero-copy и накладных расходов на копирование данных. Для высоконагруженных систем стоит рассмотреть kTLS или терминацию TLS на уровне прокси (например, nginx или специализированные решения). Источник: опыт раздачи сотен гигабит трафика через Go-сервисы.

Мы стали смотреть, чем можно терминировать TLS. Hitch (проект Varnish) делает одну вещь — принимает TLS, расшифровывает и передаёт дальше (в TCP или Unix socket).

Дальше важная деталь про проксирование: если между двумя соединениями мы можем использовать splice, то данные почти не попадают в user space — и это сильно экономит CPU/память2.

Но если мы упираемся в классический read/write (например, на Unix socket), данные начинают гоняться через user space — и это уже ощутимо дороже.

Отдельная надежда — kTLS: ядро умеет часть работы по TLS брать на себя. В Linux это появилось относительно недавно (например, в районе 5.3), и это потенциально может заметно ускорить терминацию3. Подробнее о том, как мы решили эту проблему, см. «Разгоняем Go TLS до 100 Gbps».

Проблема номер два: Garbage Collector

Вторая проблема — GC. В Go garbage collector в целом хороший, но на очень больших объёмах объектов в памяти он становится фактором.

Когда мы строили индекс, сначала «по‑умному» сделали дерево.

Когда дошли до десятков миллионов файлов на сервер (порядка 50–80 млн), стало видно, что значимая часть CPU уходит на работу GC.

В итоге самое рабочее решение оказалось простым: перейти на обычный map. Да, это дороже по памяти, но заметно разгружает CPU.

Из‑за GC мы не можем бесконечно «набивать» память мелкими объектами под кэш: в какой-то момент GC начинает доминировать.

Ещё один эффект: из‑за накладных расходов модели памяти в Go мы в среднем тратим заметно больше RAM, чем хотелось бы. В результате страдает page cache — а в Linux это очень полезная штука для ускорения чтения. Это связано и с тем, что у нас нет O_DIRECT.

Проблема номер три: предсказание нагрузки

Третья проблема — предсказание и предзагрузка.

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

На бумаге всё выглядело логично: есть данные — значит, можно спрогнозировать, что пользователь будет смотреть, и заранее положить это в память.

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

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

Понятно, что эту часть нужно переделывать — и планы на это есть.

Проблема номер четыре: сложность merge

У нас есть фаза merge, когда мы собираем куски в более крупные сегменты на основе статистики. Это всё хорошо, но нам нужно накопить достаточно большой промежуток времени, чтобы понять, что именно стоит склеивать. Обычно этот процесс запускается раз в несколько часов — и это долго.

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

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

Эволюция: что мы хотели улучшить

После года эксплуатации первой версии стало понятно, что нужно упростить архитектуру. Основные цели:

  1. Убрать «фоновую магию» — меньше процессов, которые работают «сами по себе» и влияют на производительность непредсказуемым образом
  2. Сделать поведение более предсказуемым — если система работает, она должна работать стабильно, без периодических всплесков нагрузки от merge
  3. Упростить эксплуатацию — меньше компонентов, которые нужно настраивать и отлаживать
  4. Сохранить производительность — упрощение не должно означать деградацию

Ключевое решение: отказаться от merge как отдельного процесса. Вместо этого сделать tiering (RAM/SSD/HDD) простым правилом размещения на этапе записи, без последующей перезаписи.

Версия 2 (новая система): cdn/edge без merge

Новая система (cdn/edge) построена на тех же принципах (контейнеры, кольцевой буфер, индекс), но архитектура стала проще и модульнее. Главное отличие: нет merge как фонового процесса — tiering работает как правило размещения при записи.

Модульная архитектура

Система состоит из нескольких независимых модулей:

HTTP Server (server/)
   |
   +---> Pull Zone Manager (pullzone/)
   |        |
   |        +---> JWT/SecureLink проверка
   |        +---> Firewall (IP блокировка)
   |        +---> Генерация ключа кэша
   |
   +---> Storage Engine (storage/)
   |        |
   |        +---> Index (index/) - mmap таблица
   |        +---> Disk (disk/) - append-only лог
   |
   +---> Download Manager (download/)
   |        |
   |        +---> Загрузка с origin
   |        +---> Блокировки (предотвращение дублирования)
   |        +---> Асинхронная обработка больших файлов
   |
   +---> Manager Client (manager/)
            |
            +---> Синхронизация конфигурации

Каждый модуль отвечает за свою область и может развиваться независимо.

Путь запроса: от клиента до диска

Когда приходит HTTP‑запрос, система обрабатывает его по следующему пути:

HTTP Request
   |
   v
[1] Проверка домена (excluded hosts)
   |
   v
[2] Поиск Pull Zone по Host заголовку
   |
   v
[3] Проверка Firewall (блокировка IP)
   |
   v
[4] Проверка доступа (JWT/SecureLink)
   |
   v
[5] Генерация ключа кэша (zone + URL + query params)
   |
   v
[6] Lookup в Storage (поиск в индексе)
   |
   +---> HIT? --> [7a] Отправка клиенту (zero-copy)
   |
   +---> MISS? --> [7b] Download Lock (предотвращение дублирования)
                    |
                    v
                    [8] Проверка Exists (double-check)
                    |
                    +---> существует? --> [7a]
                    |
                    +---> нет? --> [9] Fetch с origin
                                   |
                                   v
                                   [10] Store в Storage
                                   |
                                   v
                                   [11] Отправка клиенту

Важные детали:

  • Download Lock: если несколько запросов приходят одновременно для одного объекта, только один идёт на origin, остальные ждут и получают результат из кэша
  • Double-check: между lock и fetch делается проверка Exists, чтобы не загружать объект, который уже появился
  • Асинхронная обработка: большие файлы (>200 МБ) или chunked контент обрабатываются асинхронно — клиент получает ответ сразу, а запись на диск идёт в фоне

Хранилище на диске: append-only лог с wraparound

Каждый диск представлен одним файлом data.bin — это append-only лог, куда последовательно пишутся объекты. В начале файла хранится указатель на текущую позицию записи (8 байт).

Структура записи на диске:

data.bin (фиксированный размер = размер диска)
   |
   +-- [offset pointer] (8 байт в начале файла)
   |
   +-- [header #1][content #1]
   |      |           |
   |      |           +-- данные объекта (возможно сжатые gzip)
   |      |
   |      +-- метаданные (protobuf):
   |            - ключ объекта
   |            - размер заголовка и контента
   |            - тип контента
   |            - флаги (сжатие, TTL, workspace ID, zone ID)
   |
   +-- [header #2][content #2]
   |
   +-- [header #3][content #3]
   |
   +-- ...
   |
   +-- [wraparound] когда место заканчивается, указатель
        |            сдвигается в начало (offsetLen = 8)
        |
        +-- перезапись старых данных по кругу

Wraparound (кольцевой буфер):

Когда файл заполняется, указатель записи сдвигается в начало (после первых 8 байт), и начинается перезапись старых данных. Это работает как кольцевой буфер:

До wraparound:
data.bin: [offset=1000] [obj1] [obj2] [obj3] ... [objN] [свободно]
           ^            ^                              ^
           |            |                              |
           |            +-- write pointer              +-- конец файла

После wraparound (файл заполнен):
data.bin: [offset=8] [obj1] [obj2] ... [objN] [objN+1] [objN+2] ...
           ^         ^                              ^
           |         |                              |
           |         +-- write pointer (перезапись)  +-- старые данные
           |
           +-- указатель сброшен в начало

Почему это работает:

  • Старые объекты можно читать, пока они не перезаписаны
  • Нет операций удаления — только перезапись
  • Нет фрагментации — данные всегда последовательные
  • Простота: один файл, один указатель, одна логика

Запись объекта:

  1. Читаем текущий offset из начала файла
  2. Проверяем, есть ли место (если нет — wraparound)
  3. Пишем header (метаданные в protobuf)
  4. Пишем content (возможно сжатый gzip, если размер >1 КБ и тип подходит)
  5. Обновляем offset в начале файла
  6. Добавляем запись в индекс

Запись идёт через буферизованный writer (2 МБ буфер) для эффективности.

Индекс: mmap-таблица с wraparound

Индекс хранится в памяти через mmap и позволяет быстро найти объект по ключу. Структура: main индекс (для больших объектов >5 МБ) + 10 chunks (для мелких объектов).

Структура индекса:

Index
   |
   +-- main.idx (mmap, ~2M записей)
   |      |
   |      +-- [pos counter] (4 байта)
   |      |
   |      +-- [item #1] [item #2] ... [item #N]
   |            |
   |            +-- Key (8 байт)
   |            +-- Meta (15 байт):
   |                  - Disk ID (1 байт)
   |                  - Offset (8 байт)
   |                  - HeaderSize (2 байта)
   |                  - ContentSize (4 байта)
   |            +-- Zone (2 байта)
   |            +-- Deleted flag (1 байт)
   |            +-- Checksum (4 байта)
   |
   +-- chunks/ (10 файлов, mmap, ~3.5M записей каждый)
          |
          +-- chunk_1.idx
          +-- chunk_2.idx
          +-- ...
          +-- chunk_10.idx

Выбор индекса: для объекта с ключом K выбирается chunk = K % 10. Большие объекты (>5 МБ) всегда идут в main индекс.

Wraparound в индексе:

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

До wraparound:
main.idx: [pos=1000] [item1] [item2] ... [item1000] [пусто]
           ^         ^                              ^
           |         |                              |
           |         +-- текущая позиция            +-- конец файла

После wraparound:
main.idx: [pos=1] [item1] [item2] ... [item1000] [item1001] ...
           ^      ^                              ^
           |      |                              |
           |      +-- новая позиция (перезапись)  +-- старые данные
           |
           +-- позиция сброшена

Операции с индексом:

  • Lookup: поиск по map[Key]uint32 (позиция в mmap), затем чтение структуры из mmap
  • Upsert: если ключ существует — обновление на месте, если нет — запись в текущую позицию и инкремент счётчика
  • Delete: установка флага deleted=1 (физически запись остаётся, но игнорируется при lookup)

Почему mmap:

  • Быстрый доступ к данным (без системных вызовов для чтения)
  • Автоматическая синхронизация с диском (MAP_SHARED)
  • Эффективное использование памяти (page cache ОС)

Tiering без merge: правило размещения

В новой системе tiering (RAM/SSD/HDD) работает как правило размещения при записи, а не как отдельный процесс merge.

Правила размещения:

Объект приходит на запись
   |
   v
[1] Preview контент (< 2 дней)?
   |
   +---> да --> RAM (если доступен)
   |
   +---> нет --> [2] Audio/Poster?
                 |
                 +---> да --> SSD (если доступен)
                 |
                 +---> нет --> [3] Video (< 30 дней)?
                                |
                                +---> да --> SSD (если доступен)
                                |
                                +---> нет --> HDD

Важно: это правило, а не процесс. Объект сразу пишется на нужный диск и остаётся там до истечения TTL или перезаписи через wraparound. Нет отдельного процесса, который бы переписывал данные с SSD на HDD.

Что это даёт:

  • Простота: нет фоновых процессов, которые нужно настраивать и отлаживать
  • Предсказуемость: поведение системы не зависит от работы merge
  • Меньше нагрузки: нет периодических всплесков от перезаписи
  • Проще эксплуатация: меньше компонентов, которые могут сломаться

Проверка целостности и TTL

Проверки делаются на пути чтения и в управляющих операциях:

  1. Проверка целостности при чтении: при доступе к объекту проверяем его целостность (magic sum, checksum).
  2. TTL проверка: при чтении проверяется ExpiresIn — если объект истёк, он удаляется из индекса
  3. Purge по зоне: отдельный процесс удаляет объекты по зоне (например, при удалении зоны или по расписанию)

Purge поток:

Purge Thread (каждую минуту)
   |
   v
[1] Получить список зон для удаления (от Manager)
   |
   v
[2] Traverse индекс, найти все объекты зоны
   |
   v
[3] Проверить TTL/условия удаления
   |
   v
[4] Delete из индекса (установить флаг deleted)
   |
   v
[5] Физически объект остаётся на диске до перезаписи

Физически объекты не удаляются — они остаются на диске до перезаписи через wraparound. Это упрощает логику и исключает фрагментацию.

Производительность: где экономим

Zero-copy на отдаче: система использует оптимизированный путь отправки данных клиенту. Если объект не сжат или клиент поддерживает gzip, данные отправляются через SendFile/ReadFrom, что позволяет избежать копирования в user space.

Компрессия для хранения: объекты размером >1 КБ и подходящего типа автоматически сжимаются gzip при записи на диск. При чтении, если клиент поддерживает gzip, данные отдаются сжатыми; если нет — распаковываются на лету.

Пул открытых файлов: для чтения используется пул открытых файловых дескрипторов (до 50 на диск), что уменьшает overhead от открытия/закрытия файлов.

Почему «меньше фоновых процессов» = больше предсказуемости: когда нет merge, который периодически создаёт всплески нагрузки, поведение системы становится более стабильным. Легче планировать capacity, проще диагностировать проблемы, меньше неожиданных деградаций.

Что почитать

Что почитать:

  • статьи ScyllaDB про доступ к данным и работу с диском в Linux (в том числе про mmap и его trade‑off);
  • статьи Cloudflare про сеть и высоконагруженные системы;
  • материалы про zero‑copy в Linux (splice, sendfile) и современные варианты на базе BPF/сокетного слоя.

Отдельный вывод автора: «всё проверять руками» — бенчмарки и обещания «у нас всё летает» часто не выдерживают встречи с реальной нагрузкой.

Вопросы и ответы

Про работу с HDD и очередь запросов

Вопрос: У HDD есть предел по параллельным чтениям: если «перегнуть», латентность резко растёт. Вы это как-то ограничиваете (эвристикой/динамикой) или полагаетесь на кэш/раскладку?

Ответ: Отдельной «умной» эвристики на уровне диска сейчас нет: многое закрывается кэшем и тем, как мы раскладываем данные.

По профилю контента у нас примерно 70% — это то, что смотрят каждый день (условно «тёплое/горячее»), и оно всё равно будет регулярно запрошено. Классический TTL‑кэш «на сутки» для такого профиля работает плохо, поэтому мы держим значимый объём данных в памяти (DRAM) и практически не вытесняем то, что долго остаётся востребованным.

Для чтения есть промежуточный кэш в памяти плюс нам сильно помогает page cache ОС. Сам по себе HDD в «чистом» виде отдаёт порядка сотен мегабайт в секунду (то есть меньше 1 Гбит/с), но за счёт кэшей и последовательного доступа на практике можно увидеть и большие цифры.

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

Вопрос: То есть «везёт» — это тоже логика раскладки?

Ответ: Да: мы стараемся класть части одного файла на один диск. Это повышает шанс, что в моменте не получится так, что один диск станет точкой концентрации сразу по многим популярным видео.

Про размеры партиций и структуру хранения

Вопрос: Правильно ли я понял: вы «упаковываете» мелкие файлы в крупные контейнеры? Контейнер фиксированного размера?

Ответ: В версии 1 — да, размер фиксирован: партиции создавались при инициализации диска. Метаданные — фиксированного размера (порядок сотен мегабайт, например ~200 МБ), потому что записи там фиксированной структуры.

Сами партиции тогда получались из разбиения диска на равные части (на 50; это решение потом захотелось пересмотреть). При объёмах уровня 100+ ТБ «крупных» контейнеров всё равно получалось очень много.

В новой версии партиций нет: один диск — один файл (append‑only лог, условный data.bin), и вся логика wraparound/индексации строится вокруг этого.

Мы сознательно не пытаемся «разделить мир» на «маленькие файлы» и «большие файлы» на уровне API — всё хранится одинаково. А вот внутри пайплайна есть правило: мелкие куски сначала попадают на SSD. Если писать их на HDD «как есть», random I/O быстро убивает производительность.

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

Про файловую систему и RAID

Вопрос: Почему вы ушли от nginx в сторону самописного решения — из‑за функциональности или производительности?

Ответ: Проблема была не только (и не столько) в HTTP‑части. Нам нужно было обеспечить нужную модель хранения на диске и свою логику доступа к контенту (подписи, TTL, лимиты, метаданные) — и проще оказалось строить это как единое решение.

Плюс мы «наивно» рассчитывали, что встроенный TLS в Go нас вывезет. Он удобный по API (например, можно менять сертификаты на лету), но по производительности в нашей задаче оказался узким местом.

Вопрос: А RAID используете?

Ответ: RAID не используем.

Про «везёт» и статистику

Вопрос: Есть ли у вас статистика «как часто везёт / не везёт», и что происходит в моменты, когда «не повезло»?

Ответ: Прямой метрики «везёт/не везёт» у нас нет. Как правило, это выглядит как деградация, а не как полный отказ: серверов много, и система может перераспределяться.

Если клиент «выжирает» полосу на группе edge, в крайних случаях можно отказаться от шардинга и временно раздавать данные более широко, чтобы сгладить пик.

Многое зависит от баланса чтения/записи. Читать с диска можно быстро, но одновременно быстро писать туда же — уже сильно сложнее. Поэтому мы смотрим в сторону более нативного/асинхронного I/O. И да: когда начинается активная запись (merge, восстановление после отказов), отдача может слегка замедляться — но текущая латентность всё равно лучше, чем «раньше, до всех оптимизаций».

Про переход на Rust

Вопрос: Вы упомянули, что хотели заменить Go на Rust. Перешли ли вы в итоге?

Ответ: Нет, мы не перешли на Rust. В итоге все проблемы удалось решить на Go.

Если выбирать язык «на каждый день», я часто выберу Go — он проще и быстрее даёт результат. Rust сложнее и требует больше усилий.

Изначально мы рассматривали переход на Rust из‑за желания получить жёсткий контроль над памятью и отсутствие GC, а также возможности глубже уйти в нативное I/O (в том числе io_uring для TCP и файлов). Мы пробовали и бенчмарки, и разные подходы, но из‑за высокой конкуренции запросов и больших объёмов трафика решения, которые выглядят хорошо «на бумаге», не всегда выигрывают в реальности — появляются накладные расходы на взаимодействие.

Что помогло решить проблемы на Go:

  • kTLS — интеграция kernel TLS позволила решить проблемы производительности TLS и вернуть zero-copy на отдачу (подробнее см. «Разгоняем Go TLS до 100 Gbps»)
  • Упрощение архитектуры — отказ от merge и других сложных фоновых процессов сделал систему более предсказуемой и снизил нагрузку на GC
  • Оптимизация индекса — переход на mmap и простые структуры данных уменьшил давление на GC

В итоге Go оказался достаточным для решения всех задач, и переход на Rust не потребовался.

Про sharding и отказоустойчивость

Вопрос: Как устроен шардинг? Что происходит при падении диска/сервера?

Ответ: Шардинг самописный. Используем consistent hashing, чтобы выбрать «правильный» сервер для объекта.

Если ломается диск, это выглядит как cache miss и деградация. Если «повезло», на диске было мало востребованного — влияние минимально. Если «не повезло», там мог оказаться тёплый/горячий контент — тогда нагрузка перераспределяется, и деградация заметнее.

Когда теряем диск, может резко вырасти запись (восстановление/перераспределение) — это и даёт деградацию. Аналогично при падении сервера: какое-то время мы пытаемся «дожать» подключение, но быстро сдаёмся и перестраиваемся на оставшиеся узлы.

Про фрагментацию

Вопрос: А фрагментация при перезаписи «удалённых» данных — это проблема?

Ответ: Пока не видим. Периодически проверяем вручную и снимаем отдельные метрики, но признаём: мониторим не всё, поэтому продолжаем наблюдать.

Чеклист: как строить подобное у себя

Если вы хотите построить похожую систему, вот практический чеклист:

Метрики и мониторинг

Обязательные метрики:

  • Диски: iowait, throughput (read/write), latency (p50/p95/p99), свободное место
  • Индекс: количество записей, промахи (index miss), операции (lookup/upsert/delete)
  • Запросы: RPS, latency (p50/p95/p99), cache hit rate, ошибки по типам
  • Память: использование heap, GC паузы, размер индекса в памяти
  • Сеть: throughput, drops, TCP connections

Что смотреть в профилях:

  • pprof CPU: где тратится время (GC, копирование, системные вызовы)
  • pprof heap: крупные аллокации, утечки памяти
  • perf: системные вызовы, копирование (copy_user_*, memcpy*), сетевой стек

Инварианты данных

Что проверять:

  • Индекс и диск синхронизированы (объект в индексе существует на диске)
  • Wraparound работает корректно (offset не выходит за границы файла)
  • TTL проверяется при чтении (истёкшие объекты удаляются из индекса)
  • Checksum валиден (при чтении проверяется целостность данных)

Типовые деградации и как их диагностировать

Проблема: высокий iowait, деградация latencies

Диагностика:

  • Проверить, не запущен ли фоновый процесс (merge, проверка целостности)
  • Посмотреть на распределение чтения/записи по дискам
  • Проверить, не переполнен ли какой‑то диск

Проблема: рост промахов индекса (index miss)

Диагностика:

  • Проверить wraparound индекса (возможно, старые записи перезаписываются)
  • Проверить синхронизацию индекса и диска
  • Посмотреть на распределение ключей по chunks (возможно, один chunk переполнен)

Проблема: рост использования памяти

Диагностика:

  • Проверить размер индекса (сколько записей в памяти)
  • Посмотреть на GC профиль (возможно, GC не справляется)
  • Проверить утечки памяти (рост heap без роста нагрузки)

Проблема: деградация производительности TLS

Диагностика:

  • Проверить профиль CPU (сколько времени уходит на crypto/tls)
  • Проверить, используется ли kTLS (см. статью про kTLS)
  • Посмотреть на метрики handshake (количество, время)

Тестирование на деградации

Что тестировать:

  • Переполнение диска: что происходит, когда диск заполняется и начинается wraparound
  • Переполнение индекса: что происходит, когда индекс заполняется и начинается wraparound
  • Потеря диска: как система ведёт себя при отказе диска
  • Высокая нагрузка: как система ведёт себя при пиковых нагрузках (сотни гигабит)
  • Конкуренция чтения/записи: что происходит, когда одновременно много чтения и записи

Заключение

Мы работаем с HDD, и чтобы их «победить», пришлось много читать, пробовать и консультироваться с людьми. Система эволюционировала от сложной архитектуры с merge до более простого решения, которое легче эксплуатировать и понимать.

Основные выводы из нашего опыта:

  • Проблему миллиардов мелких файлов можно решить — упаковка в большие контейнеры (append-only лог) с метаданными работает эффективно
  • HDD могут быть эффективны — при правильной организации данных и последовательной записи можно достичь хорошей производительности
  • Простота важнее сложности — отказ от merge упростил систему и сделал её поведение более предсказуемым
  • Go имеет ограничения — особенно в работе с памятью и TLS, но для многих задач подходит отлично
  • Метрики критичны — без них невозможно понять, что происходит в системе и где реальные проблемы
  • Ограничения помогают — они заставляют думать о правильной архитектуре и не реализовывать лишнее

Если у нас получилось — получится и у вас. Обычно самые неприятные проблемы оказываются довольно базовыми: данные должны быть отсортированы, чтобы по ним можно было быстро искать; структуры — фиксированного размера, чтобы эффективно хранить их на диске/в памяти и быстро доставать.

Любое решение должно быть аргументировано — нужно понимать, что именно вы делаете и почему. Нерешаемых задач почти не бывает: вопрос в цене и времени. Иногда это не «день работы», а месяц или даже год — и это нормально, если ценность достаточно высокая.

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

Нас регулярно спасают метрики: когда их много и они правильные, «косяки» видно быстро. В Go с профилированием тоже всё хорошо — это реально помогает.

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

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

Полезные материалы

Практическая заметка. При работе с большими объёмами мелких файлов (миллиарды объектов) классические файловые системы быстро становятся узким местом. Решение через упаковку в контейнеры (append-only лог) с фиксированными метаданными и индексом в памяти позволяет избежать проблем с кэшем открытых файлов и фрагментацией. Источник: опыт построения CDN на HDD и эволюция системы от версии с merge до более простой архитектуры без фоновых процессов.

Сноски


  1. HTTP/1.1 для раздачи видео часто предпочтительнее HTTP/2, потому что не требует мультиплексирования и приоритизации потоков. Для больших файлов простота HTTP/1.1 может быть преимуществом. Подробнее о выборе протокола см. HTTP/2 Prioritization with NGINX↩︎

  2. О zero-copy механизмах (sendfile, splice) и их применении для раздачи контента см. «Разгоняем Go TLS до 100 Gbps»↩︎

  3. Подробнее о kTLS, как он работает и как его использовать в Go для достижения высоких скоростей, см. «Разгоняем Go TLS до 100 Gbps»↩︎