Към съдържанието
wannacode

Мащабиране

Накратко: Хоризонталното мащабиране добавя копия на сървъра зад разпределител на натоварването; работи, когато сървърът не пази свое състояние, а репликите на базата закъсняват.

Проблем

Black Friday и един сървър

В обикновен ден магазинът получава около 50 заявки в секунда и един сървър стига. На Black Friday заявките стават десет пъти повече (пример, не измерване). Процесорът е на 100%, страниците падат по timeout, а количките се губят. Миналата година фирмата купи по-голяма машина. Тя струва двойно, стигна за година и пак е една: когато се рестартира за обновление, магазинът спира.

Как се добавя още сила, без да чакаш по-голяма машина, и без да спираш магазина, когато една машина падне?

Идеята

По-голяма машина или повече машини

Има два пътя:

  • Вертикално мащабиране: по-голяма машина, с повече процесори и памет. Кодът не се мени. Но машината има таван, цената расте по-бързо от силата, а сървърът остава един: падне ли, магазинът спира.
  • Хоризонтално мащабиране: няколко еднакви копия на сървъра, а пред тях разпределител (load balancer), който дели заявките помежду им. Няма таван: добавяш копие. Едно копие пада и другите поемат.

Хоризонталното има условие: всяко копие трябва да може да поеме всяка заявка. Разпределителят не знае коя заявка за какво е и ги дава подред.

Разпределителят като истинска инфраструктура (nginx, облачен load balancer, Kubernetes) е тема на DevOps курса ZeroToOps. Тук той е обект, който дава сървърите подред (round robin), за да се види какво става с кода.

Виж го

Три копия с памет

Сървърът пази количките в паметта си, както магазинът пазеше сесиите в речник в урока за сесиите:

class AppServer {
final String name;
// количките живеят в паметта на този сървър
private final Map<String, List<String>> carts = new HashMap<>();
AppServer(String name) {
this.name = name;
}
String add(String session, String product) {
List<String> cart = carts.computeIfAbsent(
session, k -> new ArrayList<>());
cart.add(product);
return name + ": " + String.join(", ", cart);
}
}
// разпределителят дава сървърите подред (round robin)
class Balancer {
private final List<AppServer> servers;
private int next = 0;
Balancer(List<AppServer> servers) {
this.servers = new ArrayList<>(servers);
}
AppServer pick() {
return servers.get(next++ % servers.size());
}
void remove(AppServer server) {
servers.remove(server);
}
}

Мария добавя четири продукта. Разпределителят дава заявките на A, B, C и пак на A:

Balancer balancer = new Balancer(List.of(
new AppServer("A"), new AppServer("B"), new AppServer("C")));
System.out.println(balancer.pick().add("maria", "кафе"));
System.out.println(balancer.pick().add("maria", "чай"));
System.out.println(balancer.pick().add("maria", "шоколад"));
System.out.println(balancer.pick().add("maria", "мед"));
Изход
A: кафе
B: чай
C: шоколад
A: кафе, мед

Всяко копие вижда своята количка. На B кафето го няма, на C го няма и чаят, а A вижда кафето, защото Мария пак е при него. При плащането количката е тази, която случайно е в копието, което поема заявката.

Идеята

Сървър, който не пази нищо свое

Сървърът със състояние помни нещо между две заявки: количката, сесията, брояч. Сървърът без състояние (stateless) не помни нищо свое. Всичко, което трябва да оцелее до следващата заявка, е отвън:

  • в общо хранилище за сесиите, което всички копия виждат (Redis);
  • в базата;
  • при клиента, подписано, за да не може да се подмени (в урока за JWT).

Тогава копията са взаимозаменяеми: всяко поема всяка заявка, а когато едно спре, нищо не се губи.

Виж го

Количката в общото хранилище

// общото хранилище на сесиите (в истински магазин Redis)
class SessionStore {
private final Map<String, List<String>> carts = new HashMap<>();
List<String> add(String session, String product) {
List<String> cart = carts.computeIfAbsent(session, k -> new ArrayList<>());
cart.add(product);
return List.copyOf(cart);
}
}
class AppServer {
final String name;
private final SessionStore store; // сървърът не пази нищо свое
AppServer(String name, SessionStore store) {
this.name = name;
this.store = store;
}
String add(String session, String product) {
List<String> cart = store.add(session, product);
return name + ": " + String.join(", ", cart);
}
}

Същите заявки, а след третата сървърът B спира:

SessionStore store = new SessionStore();
AppServer b = new AppServer("B", store);
Balancer balancer = new Balancer(List.of(
new AppServer("A", store), b, new AppServer("C", store)));
System.out.println(balancer.pick().add("maria", "кафе"));
System.out.println(balancer.pick().add("maria", "чай"));
System.out.println(balancer.pick().add("maria", "шоколад"));
balancer.remove(b); // сървър B спира
System.out.println(balancer.pick().add("maria", "мед"));
Изход
A: кафе
B: кафе, чай
C: кафе, чай, шоколад
C: кафе, чай, шоколад, мед

Всяко копие вижда цялата количка. Когато B спира, разпределителят дава заявката на C и количката е цяла, защото никога не е била в B. List.copyOf и [.. cart] връщат копие: сървърът не може да пипне списъка в хранилището, както не би могъл и с истински Redis.

Пробвай

Предвиди изхода

Два сървъра с памет, Иван добавя три продукта. Какво ще отпечата последният ред?

Balancer balancer = new Balancer(List.of(new AppServer("A"), new AppServer("B")));
balancer.pick().add("ivan", "кафе");
balancer.pick().add("ivan", "чай");
System.out.println(balancer.pick().add("ivan", "мед"));
Отговор

Идеята

Базата не се умножава толкова лесно

Копията на сървъра пишат в една база. Да умножиш базата е по-трудно, защото записите трябва да са на едно място, иначе два записа се бият.

Обичайната стъпка е реплика за четене: копие на базата, което получава промените на основната и отговаря само на четения. Повечето заявки в магазина са четения (каталог, страници, история), затова репликите вземат голяма част от товара. Как се пращат промените, видя в урока за репликацията.

Цената е закъснението на репликата (replication lag): промяната стига до нея след малко. Обикновено части от секундата, но при натоварване и секунди. Тук закъснението е 3 тика:

record Change(int at, int order, String customer) {}
// основната база: всеки запис минава през нея
class Primary {
final List<Change> log = new ArrayList<>();
void insert(int order, String customer, int now) {
log.add(new Change(now, order, customer));
}
List<Integer> orders(String customer) {
List<Integer> ids = new ArrayList<>();
for (Change c : log)
if (c.customer().equals(customer)) ids.add(c.order());
return ids;
}
}
// реплика за четене: промяната стига до нея lag тика след записа
class Replica {
private final Primary primary;
private final int lag;
Replica(Primary primary, int lag) {
this.primary = primary;
this.lag = lag;
}
List<Integer> orders(String customer, int now) {
List<Integer> ids = new ArrayList<>();
for (Change c : primary.log) {
boolean arrived = c.at() + lag <= now;
if (arrived && c.customer().equals(customer))
ids.add(c.order());
}
return ids;
}
}
static String show(List<Integer> ids) {
return ids.isEmpty() ? "няма поръчки" : "поръчки " + ids;
}

Мария плаща на тик 0 и веднага отваря „Моите поръчки“, а то чете от репликата:

Primary primary = new Primary();
Replica replica = new Replica(primary, 3);
primary.insert(1042, "maria", 0); // Мария плаща на тик 0
for (int now = 0; now <= 3; now++) {
System.out.println("тик " + now + ", реплика: " + show(replica.orders("maria", now)));
}
Изход
тик 0, реплика: няма поръчки
тик 1, реплика: няма поръчки
тик 2, реплика: няма поръчки
тик 3, реплика: поръчки [1042]

Три тика тя не вижда поръчката си. Плаща втори път или пише на поддръжката, че парите са взети, а поръчка няма.

Виж го

Три копия, общо хранилище и репликата

List<Integer> myOrders(String customer, int now) {
if (justWrote(customer, now))
return primary.orders(customer);
return replica.orders(customer, now);
}
boolean justWrote(String customer, int now) {
Integer last = lastWrite.get(customer);
return last != null && now - last < lag;
}
Стъпка по стъпка, с думи
  1. Мария добавя кафе. Разпределителят праща заявката на A, а количката остава в паметта на A.
  2. Следващата заявка (чай) отива на B. B не знае за кафето: количката там е само с чай.
  3. Поправката: сървърите не пазят нищо. Количката е в общо хранилище и всеки сървър я чете оттам.
  4. Сега C добавя шоколад и вижда кафето и чая. Няма значение кой сървър поема заявката.
  5. B спира. Разпределителят го маха, а количката оцелява: тя никога не е била в B.
  6. Мария плаща: поръчка 1042 се записва в основната база на тик 0. Репликата получава промяната 3 тика по-късно.
  7. На тик 1 тя отваря „Моите поръчки“. Четенето отива на репликата, а там поръчката още я няма.
  8. На тик 3 промяната стига до репликата и поръчката се появява. Дотогава Мария мисли, че плащането не е минало.
  9. Поправката: който току-що е писал, чете от основната 3 тика. Всички останали четат от репликата.
  10. Сървърите без състояние се умножават. Базата за запис остава една, а репликите ѝ винаги малко закъсняват.

Идеята

Който е писал, чете от основната

Повечето хора не забелязват, че каталогът е с половин секунда по-стар. Но своята промяна човек очаква да види веднага. Затова: който току-що е писал, чете от основната база, докато закъснението не мине. Всички останали четат от репликата. Това се казва read-your-writes.

// който току-що е писал, чете от основната
class OrderService {
private final Primary primary;
private final Replica replica;
private final int lag;
private final Map<String, Integer> lastWrite = new HashMap<>();
OrderService(Primary primary, Replica replica, int lag) {
this.primary = primary;
this.replica = replica;
this.lag = lag;
}
void place(int order, String customer, int now) {
primary.insert(order, customer, now);
lastWrite.put(customer, now);
}
List<Integer> myOrders(String customer, int now) {
if (justWrote(customer, now))
return primary.orders(customer);
return replica.orders(customer, now);
}
boolean justWrote(String customer, int now) {
Integer last = lastWrite.get(customer);
return last != null && now - last < lag;
}
}
static String read(OrderService orders, String customer, int now) {
String from = orders.justWrote(customer, now) ? "основната" : "реплика";
return "тик " + now + ", " + customer + " → " + from + ": "
+ show(orders.myOrders(customer, now));
}
Primary primary = new Primary();
Replica replica = new Replica(primary, 3);
OrderService orders = new OrderService(primary, replica, 3);
orders.place(1042, "maria", 0);
for (int now = 0; now <= 3; now++) {
System.out.println(read(orders, "maria", now));
}
System.out.println(read(orders, "ivan", 1));
Изход
тик 0, maria → основната: поръчки [1042]
тик 1, maria → основната: поръчки [1042]
тик 2, maria → основната: поръчки [1042]
тик 3, maria → реплика: поръчки [1042]
тик 1, ivan → реплика: няма поръчки

Мария вижда поръчката си веднага, от основната. Иван не е писал нищо и чете от репликата: товарът на основната расте само с хората, които току-що са писали.

Идеята

Кое се мащабира и кога вреди

  • Се мащабира: сървърите на приложението, когато са без състояние; четенията, с реплики и кеш (в урока за кеша).
  • Не се мащабира лесно: записите в основната база. Когато една база не стига за записите, данните се делят на части (шардинг) или се делят на услуги със собствени бази (в урока за монолита и услугите). И двете са големи решения.
  • Скрито състояние: кеш в паметта на всяко копие (всяко има своята стара цена), файлове, качени на диска на едно копие, броячи в паметта, задача по график, която тръгва на всяко копие и праща три имейла вместо един.
  • Кога вреди: копията искат общо хранилище, реплики и наблюдение на всяко копие. Ако един сървър стига, хоризонталното мащабиране е сложност без полза. Първо измерваш кое е тясното място. Понякога по-голямата машина е по-евтиният първи ход, а често и индекс в базата.

Пробвай

Намери грешката

Магазинът пуска само 5 опита за вход, после блокира. Пентестът показва, че при три копия атакуващият пробва 15 пароли. На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Sticky sessions. Разпределителят може да праща един и същ купувач винаги на едно копие (по бисквитка). Това кърпи количката, но копията пак не са взаимозаменяеми: падне ли копието, количките в него ги няма, а товарът се разпределя неравно.

Други начини за read-your-writes. Клиентът помни номера на последния си запис и чете от реплика, която вече го има. Или всички четения на „моите“ данни (профил, поръчки) винаги са от основната. Или репликацията е синхронна за важните записи: по-бавно, но без закъснение.

Автоматично мащабиране. Облакът добавя и маха копия според товара. Това и разпределителят, проверките дали копието е живо и Kubernetes са инфраструктура, затова са в DevOps курса ZeroToOps.

Истинските неща: сесиите в Redis са готови в Spring Session, в ASP.NET Core с AddStackExchangeRedisCache и AddSession, в Django с SESSION_ENGINE, в Express с connect-redis. Репликите за четене имат PostgreSQL, MySQL и SQL Server, а рамките позволяват да насочиш четенията към тях.

Упражнение

Общият брояч на опитите

Реши задачата в редактора си.

Поправи грешката от горе: броячът на опитите е един клас Attempts, общ за всички копия. Сървърът пита него и връща "блокиран" след петия опит. Пусни три копия зад разпределителя и седем грешни опита за вход като maria.

Изход
опит 1 (A): грешна парола
опит 2 (B): грешна парола
опит 3 (C): грешна парола
опит 4 (A): грешна парола
опит 5 (B): грешна парола
опит 6 (C): блокиран
опит 7 (A): блокиран

Шестият и седмият опит са блокирани, макар че C и A ги виждат за втори път.

Още задачи

  • Изтичане. Добави часовник с тикове и нулирай брояча 10 тика след първия опит.
  • Качени файлове. Сървърът пази снимката на продукта на своя диск. Какво вижда купувач, когато разпределителят го прати на друго копие? Къде трябва да живее снимката?

Тест

Провери се

1. Кое е хоризонтално мащабиране?
2. Защо количката изчезва, когато има три копия на сървъра?
3. Мария плаща и веднага отваря „Моите поръчки“, а поръчката я няма. Какво се е случило най-вероятно?
4. Лимитът е 5 опита за вход, а броячът е в паметта на всеки сървър. Колко опита получава атакуващият при три копия?
5. Кое НЕ става по-бързо, като добавиш копия на сървъра?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·