SkillGridИзучай computer science на практике

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

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

17 мин · 55 XP

В architecture:1 ты собрал систему из клиента, сервера, контракта API, кэша и монолита. Она работала. Этот урок — про тот день, когда она работает на десять тысяч человек вместо десяти. Здесь нет ни одного нового понятия: здесь то, сколько стоят понятия из architecture:1 и os:2, если отнестись к ним серьёзно.

Направлений роста ровно два. Вертикальное — машина побольше: больше ядер, больше памяти, диски быстрее. Горизонтальное — больше машин того же размера. Вертикальное незаслуженно недооценивают. Оно не требует менять код, оно сохраняет все допущения, которые ты уже сделал, а один современный сервер с 64 ядрами и 512 ГБ памяти обслуживает ошеломляющий объём трафика. Блог, небольшой магазин, внутренний инструмент и большинство B2B-продуктов не нуждаются ни в чём другом. Беда вертикального роста в том, что это лестница с верхней ступенькой: рано или поздно самой большой арендуемой машины не хватает, цена за ядро перестаёт быть линейной и начинает удваиваться, а замена этой одной машины — это окно обслуживания, а не плавное обновление.

VERTICAL                      HORIZONTAL

  1 x 32 cores                  32 x 1 core

  limit: silicon                limit: coordination
  cost: superlinear             cost: linear plus a tax
  failure: one box               failure: 1 of 32 lost
  restart: minutes of downtime  restart: drain and replace
  ceiling: ~ a few hundred GB   ceiling: none in practiceОба направления растит одну и ту же систему. Ломаются они по-разному, и обычно именно это и решает выбор.

Вот часть, которая решает всё, и это та же мысль, что и stateless HTTP из architecture:1, только увиденная с другой стороны. Сервис без состояния ничего не хранит между запросами: запрос приходит, получает ответ, и процесс о нём забывает. Такой сервис масштабируется изменением числа. Запусти три копии или тридцать — любая копия ответит на любой запрос. Сервис с состоянием помнит что-то, что принадлежит конкретному пользователю, и эта память привязана к одной машине. Теперь добавление машины само по себе не помогает: нужно ещё решить, где живёт состояние, как о нём узнаёт новая машина и что с ним станет, когда машина, которая его держит, умрёт. В этом одном предложении вся сложность масштабирования: компьютеры размножать легко, память о разговоре — нет.

Классический пример — корзина покупок. Если корзина живёт в памяти процесса сервера, то пользователь обязан попадать в тот же самый процесс, иначе корзина опустеет. Лечится это выносом состояния из процесса в нечто общее: строка в базе, ключ в Redis или подписанная кука, которую носит с собой клиент. Как только корзина оказалась в общем хранилище, сервис перед ней снова не имеет состояния, и размножается как любой другой. Обрати внимание на форму этого хода: состояние не исчезло, оно переехало в единственное место, которому положено иметь состояние. Ровно для этого и нужна база данных, и ровно поэтому она становится следующим узким местом.

STATELESS SERVICE              STATEFUL SERVICE

  any replica answers            only the owner answers
  add a pod: instant             add a node: rebalance keys
  kill a pod: no loss            kill a node: lose its shard
  scale by count                 scale by partition

  cart lives in a shared store, not in the processТот же сервис, после того как его состояние вынесли из процесса в общее хранилище.

Когда одинаковых реплик несколько, что-то должно выбирать, какая из них получит каждый запрос. Это что-то — балансировщик нагрузки, и интересна тут не столько сама стратегия, сколько выбор того, по чему балансировать. Round robin отправляет первый запрос на первую реплику, второй на вторую и так далее: просто и хорошо, когда все запросы стоят примерно одинаково. Least connections отправляет следующий запрос той реплике, у которой сейчас меньше всего запросов в работе: лучше, когда запросы сильно различаются по цене, потому что один медленный запрос не будет продолжать тянуть трафик на уже занятую реплику. Хеширование берёт из запроса значение и отображает его в реплику — это единственный из трёх, кто даёт закрепление.

ClientLoad balancerReplica AReplica Brequest without a session cookieleast connections, 41200 OKrequest with a session cookiehash the cookie, not the source IPsame replica as last time200 OK, cart still there

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

Есть различие, которое перестраивает всю задачу: чтения и записи масштабируются неодинаково, и не обязаны масштабироваться вместе. Большинство систем читают гораздо больше, чем пишут. Новостной сайт может отдать сто тысяч просмотров на одну опубликованную статью; магазин может показать товар десять тысяч раз на каждое изменение остатка. Чтения ещё и естественно параллельны: у десяти разных пользователей, читающих одну и ту же строку, нет причин конфликтовать. Записи — наоборот, потому что они меняют общее состояние и кто-то должен устанавливать порядок. Поэтому стандартный первый шаг — разделить их: один primary принимает записи, несколько реплик копируют его и обслуживают чтения. Заметь, что это обмен, а не бесплатный обед. Ты только что купил пропускную способность на чтение, и цена в том, что чтение теперь может вернуть значение, которое слегка устарело.

-- writes go to the primary
INSERT INTO orders (id, user_id, total)
VALUES (9001, 42, 12.90);

-- reads may go to a replica, up to 200 ms behind
SELECT id, total
FROM   orders
WHERE  user_id = 42
ORDER  BY id DESC
LIMIT  20;

-- one connection per role, chosen explicitly
-- write pool -> primary.internal:5432
-- read pool  -> replica.internal:5432Разделение чтений и записей — это несколько строк конфигурации и новый класс багов.

Отставание репликации — это задержка между моментом, когда запись легла на primary, и моментом, когда она стала видна на реплике. Под лёгкой нагрузкой это миллисекунда-две, и никто не замечает. Под тяжёлой оно растёт, потому что primary занят обслуживанием записей, а реплика должна применить их по одной и по порядку. Числа, которые стоит держать в голове: здоровая потоковая реплика PostgreSQL отстаёт меньше чем на 10 мс; занятая при всплеске записей может дойти до нескольких сотен миллисекунд; реплика, отставшая на большой пачке или на изменении схемы, может отставать на минуты и при этом отчитываться как живая. Отставание — это не баг, который надо починить, а свойство копирования данных по сети на вторую машину, которая тоже чем-то занята.

Баг, который порождает отставание, называется «прочитать собственную запись», и это самый частый способ, которым реплика чтения приносит обращение в поддержку. Пользователь сохраняет изменение, запись уходит на primary и проходит успешно, страница перезагружается, чтение уходит на реплику, которая ещё не догнала, и пользователь видит свои же старые данные. С его стороны сохранение просто не сработало, поэтому он делает это снова. Честное лекарство — заметить, что пользователю вообще-то не нужны самые свежие данные, ему нужно, чтобы его собственные записи были видны ему самому. Поэтому направь чтения на primary на короткое окно после того, как этот пользователь писал: несколько секунд или пока реплика не отчитается, что догнала ту позицию. Заметь, чего это стоит: ты отдаёшь часть пропускной способности на чтение ради подмножества чтений, которые этот же пользователь делает после записи. Подмножество обычно небольшое, и обмен того стоит.

Репликация копирует весь набор данных на каждую реплику, поэтому она масштабирует чтения. Для записей она не делает ничего, потому что каждая реплика всё равно должна применить каждую запись. Когда узким местом становятся записи, остаётся единственный ход: перестать держать все данные в одном месте — разбить сами данные на куски, называемые шардами, каждый на своей машине, и каждый обрабатывает записи для своего куска. Теперь запись трогает один шард, и десять шардов поглощают в десять раз больше записи. Это та точка, где одна логическая база превращается во флот, по которому ты сам должен маршрутизировать, и где в приложении появляется код, отвечающий на вопрос «в каком шарде эта строка».

KEY            DISTRIBUTION     HOT SPOT          CROSS-SHARD QUERY

  user_id        even             none              joins on user_id only
  created_at     even over time   today only        every range scan
  country        skewed           one big country   any global report
  auto id        even at write    none              no locality at allЧетыре возможных ключа шардирования для таблицы заказов и цена, которую берёт каждый.

Ключ шардирования — это решение, которое почти необратимо, и стоит понять почему, прежде чем его выбирать. Каждый запрос приложения теперь должен либо называть ключ шардирования, либо рассылаться по всем шардам со слиянием результатов, а это кардинально медленнее и быстро становится основной статьёй расходов. То есть ключ шардирования молча решает, какие запросы останутся быстрыми на следующие пять лет. Выбери user id для таблицы заказов — и каждый запрос по пользователю это поиск в одном шарде, а отчёт по всем заказам придётся рассылать и собирать. Выбери created_at — и отчёт по месяцу быстрый, но все сегодняшние записи падают на один шард, ровно на тот, который и так самый горячий. Сменить ключ позже означает вычитать каждую строку из каждого шарда и записать её обратно в другой раскладке, причём система всё это время жива и обслуживает трафик.

Практика

  1. Команда держит один primary PostgreSQL и три реплики чтения. Пользователи жалуются: после правки профиля они перезагружают страницу и видят старые значения, а через секунду всё становится правильным. Что на самом деле происходит?

    Подсказки
    • Запись прошла успешно; спроси, какой сервер ответил на перезагрузку.
    • Сравни состояние копий, а не скорость сети.
    • Дело в том, кто читает, а не в том, чтобы копии догоняли быстрее.
  2. Сопоставь каждый механизм с задачей, которую он действительно решает, и с ценой, которую он берёт.

    Подсказки
    • Один из них копирует весь набор данных, другой его делит.
    • Кэш меняет то, что ты читаешь, а не то, где хранится правда.
    • Только один из четырёх стоит тебе необратимого решения о форме запросов.
  3. У магазина 8 000 пользователей в день, одна база и нет кэша. Пиковая нагрузка — 40 запросов в секунду. Расставь действия в том порядке, в каком их попробует аккуратный инженер.

    1. 1Поставить кэш перед повторяющимися чтениями
    2. 2Добавить реплики чтения, когда именно чтения перестали помещаться
    3. 3Добавить недостающие индексы и починить самый медленный запрос
    4. 4Шардировать данные, только если узким местом остаются записи, а не чтения
    5. 5Перейти на машину побольше, если измерение всё ещё говорит, что база загружена
    6. 6Измерить, на что эти 40 запросов в секунду на самом деле тратят время
    Подсказки
    • Начни с шага, который вообще ничего не меняет структурно.
    • Измеряй до переезда, а данные дели только когда копирование перестало помогать.
    • Реплики и шарды — две последние ступеньки, а не первые.

Вопросы по этому уроку

Это бесплатно?

Да. Первый уровень каждого предмета бесплатный и не требует аккаунта. Дальше уровни и старшие грейды платные — именно это позволяет проекту существовать.

На какой возраст рассчитано?

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

Нужно ли уже уметь программировать?

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

Нужно что-то устанавливать?

Нет. Всё работает в браузере: теория, практика и интерактивные симуляции. Скачивать нечего и настраивать нечего.

Сколько времени занимает один предмет?

Урок — от 10 до 15 минут. Грейд «джун» это примерно 12–20 уроков, то есть несколько недель регулярных занятий, а не один вечер.

Обязательно идти по порядку?

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