Cache-aware scheduling и data locality
В первом уроке модуля мы разобрали data locality в её классическом смысле: данные физически лежат на дисках конкретных машин, и uniform старается отправить сплит на воркер, где данные локальны. Но в облачном развёртывании, которое сегодня доминирует, этой локальности нет вовсе. Данные живут в объектном хранилище S3 или GCS, отдельно от вычислительных нод. Любой воркер тянет любой объект через сеть с примерно одинаковой латентностью.
И тут возникает другая локальность — не «где данные на диске», а «где данные в кэше». Trino умеет кэшировать прочитанные куски файлов на локальных дисках воркеров. Если повторный запрос к тому же файлу попадёт на тот же воркер, чтение пойдёт из локального кэша, а не из S3. Чтобы это работало стабильно, недостаточно просто включить кэш — нужно, чтобы node scheduler последовательно отправлял один и тот же сплит на один и тот же воркер. Это и есть cache-aware scheduling. Урок — про то, как scheduler делает кэш предсказуемо горячим.
Зачем кэшировать чтение из объектного хранилища
Объектное хранилище дёшево и бесконечно масштабируемо, но у него есть цена: каждое чтение — это сетевой запрос с латентностью в единицы и десятки миллисекунд на установление соединения плюс пропускная способность, поделённая на всех. Для аналитической нагрузки это часто и есть бутылочное горлышко: воркеры простаивают, ожидая байты из S3.
Parquet: row groups — что именно кэшируется воркеромПри этом аналитические нагрузки сильно повторяются. Дашборд бьёт в одни и те же таблицы каждые несколько минут. Серия ad-hoc запросов исследует один и тот же датасет. Один и тот же «горячий» Parquet-файл читается снова и снова. Если первое чтение положило его кусок на локальный SSD воркера, все последующие чтения этого куска тем же воркером идут со скоростью локального диска — на порядок быстрее и без нагрузки на сеть и на S3.
Почему обычного scheduling недостаточно
Кэш на воркере полезен ровно настолько, насколько часто запросы в него попадают. И тут всплывает проблема. Политика uniform по умолчанию для данных из S3 раздаёт сплиты с учётом баланса — то есть, по сути, случайно среди наименее загруженных воркеров. Сплит файла part-0042.parquet в первом запросе уехал на воркер 2, во втором — на воркер 5, в третьем — на воркер 1.
Результат: файл закэширован на воркере 2, но следующий запрос к нему попал на воркер 5, где его в кэше нет. Промах. Воркер 5 тянет файл из S3 и кэширует уже у себя. Третий запрос уходит на воркер 1 — снова промах. Кэш есть, место на дисках тратится, а hit rate близок к нулю, потому что назначение сплитов не помнит, кто что кэшировал.
Чтобы кэш работал, назначение должно быть детерминированным относительно идентичности сплита: сплит конкретного файла (или конкретного куска файла) должен предпочтительно и стабильно уходить на один и тот же воркер во всех запросах. Тогда первый запрос наполняет кэш этого воркера, а все последующие в него попадают.
Soft affinity: привязка сплита к воркеру по хэшу
Механизм, который это обеспечивает, — soft affinity scheduling (мягкая привязка). Идея простая и опирается на хэширование.
У каждого сплита есть стабильный идентификатор — для файлового коннектора это путь к файлу плюс, если файл режется на куски, диапазон байтов. Scheduler берёт хэш от этого идентификатора и по хэшу выбирает воркера: грубо говоря, worker = hash(split_id) mod N. Поскольку идентификатор сплита один и тот же в каждом запросе, хэш один и тот же, и воркер выбирается один и тот же. Сплит «прилипает» к своему воркеру.
Привязка называется мягкой (soft) по важной причине. Это предпочтение, а не жёсткое правило. Если воркер, к которому привязан сплит, сейчас перегружен — его очередь pending splits заполнена, — scheduler не станет ждать его любой ценой. Он отправит сплит на другого, менее загруженного воркера. Будет промах кэша на этот раз, но запрос не застрянет из-за одной горячей ноды. Жёсткая привязка (hard affinity) ради кэша легко привела бы к перекосу: все горячие файлы привязаны к одному воркеру, он завален, остальные простаивают. Soft affinity балансирует две цели — высокий hit rate в типичном случае и устойчивость к перекосу, когда баланс важнее кэша.
Что происходит при изменении числа воркеров
Привязка по hash(split_id) mod N имеет известное слабое место: при изменении N — добавили или потеряли воркера — почти все остатки меняются, и привязка целиком перетасовывается. После масштабирования кластера кэш фактически приходится прогревать заново.
Разберём почему. mod N отображает пространство хэшей на N ячеек, разрезая его равными по модулю долями. Сменился N — границы всех долей сдвинулись, и хэш, который при N = 10 давал остаток 7, при N = 11 почти наверняка даст другой остаток. Доля сплитов, чья привязка уцелела при смене N, мала. Для кэша это катастрофа: данные лежат закэшированными на воркере 7, но запрос после масштабирования отправляет их сплит на воркер 2 — промах, хотя кэш физически есть, просто не там.
Для стабильности привязки при меняющемся составе кластера применяется идея consistent hashing (согласованного хэширования). Её суть: и воркеры, и сплиты раскладываются на одно общее кольцо хэш-значений, и сплит привязывается к ближайшему по кольцу воркеру. Добавление или удаление воркера затрагивает лишь сплиты на соседнем участке кольца — переезжает малая доля, а не почти все. Большинство файлов остаются привязанными к прежним воркерам, и накопленный кэш в основном переживает изменение размера кластера. Это особенно важно для развёртываний с автоскейлингом, где число воркеров меняется регулярно вслед за нагрузкой: с mod N каждое изменение обнуляло бы кэш, с consistent hashing — нет.
Cache-aware scheduling помогает только повторяющимся обращениям к одним и тем же данным. Если каждый запрос читает уникальный набор файлов — например, всегда свежую партицию за последний час и никогда не обращается к ней снова, — кэш будет постоянно промахиваться, а место на дисках расходоваться впустую. Кэш чтения и soft affinity дают выигрыш на нагрузке с локальностью обращений: дашборды, итеративный анализ одного датасета. На потоке полностью уникальных чтений эффекта не будет.
Локальность не только про сеть: предсказуемость планировщика
Стоит зафиксировать более общую мысль. Cache-aware scheduling — это частный случай принципа: планировщик должен быть предсказуем. Случайная раскладка проста, но она не даёт системе накапливать пользу от прошлой работы. Детерминированная раскладка по хэшу позволяет кэшу осесть там, где он будет повторно использован.
Тот же принцип всплывёт в восьмом модуле в связке с dynamic filtering: координатор использует уже известную информацию (значения join-ключей), чтобы не порождать лишние сплиты. И там, и здесь идея одна — использовать знание, а не действовать вслепую. Случайность хороша для балансировки в моменте, но плоха для всего, что должно накапливаться между запросами.
Заметьте и встроенный конфликт целей. Случайная раздача даёт идеальный мгновенный баланс — но нулевую повторяемость. Жёсткая привязка по хэшу даёт идеальную повторяемость — но рискует перекосом, если горячие данные сгрудились на одном воркере. Soft affinity — это не «выбор одной из двух крайностей», а сознательный компромисс между ними: детерминизм по умолчанию ради кэша, отступление к балансу, когда привязка ведёт к перекосу. Понимание этого компромисса важнее, чем знание конкретных свойств: почти любая настройка планировщика в Trino — это точка на оси «повторяемость против мгновенного баланса», и инженер должен понимать, в какую сторону его нагрузка просит сместиться.
Для нагрузки дашбордов и итеративного анализа, где одни данные читаются десятки раз, ось смещена в сторону повторяемости — и cache-aware scheduling её туда и двигает. Для разового пакетного прогона по уникальным данным повторять нечего, и тот же механизм лишь тратит место на дисках. Поэтому кэш чтения — не «всегда включить», а решение под профиль обращений конкретного кластера.
Попробуй сам
На песочнице курса (Trino 481):
-
Песочница курса работает поверх MinIO (S3-совместимое хранилище). Выполните один и тот же запрос к Iceberg-таблице дважды подряд:
SELECT count(*), avg(value) FROM iceberg.demo.events WHERE event_date = DATE '2026-05-01';. Сравните время выполнения первого и второго запуска. Объясните разницу через промах и попадание кэша. -
Рассуждение: у вас кластер с автоскейлингом, число воркеров скачет от 3 до 12 в течение дня. Почему привязка по простому
hash(split_id) mod Nтут будет работать плохо и какой подход к хэшированию решает проблему? Опишите своими словами, что именно происходит с кэшем в момент добавления воркера при обычномmod Nи при consistent hashing. -
Назовите два типа нагрузки: один, где cache-aware scheduling даст большой выигрыш, и один, где не даст почти ничего. Для каждого объясните, при чём тут локальность обращений.