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

Мащабиране

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

Проблем

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

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

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

Идеята

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

Има два пътя:

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

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

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

Виж го

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

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

class AppServer {
carts = new Map(); // количките живеят в паметта на този сървър
constructor(name) {
this.name = name;
}
add(session, product) {
if (!this.carts.has(session)) this.carts.set(session, []);
const cart = this.carts.get(session);
cart.push(product);
return `${this.name}: ${cart.join(", ")}`;
}
}
// разпределителят дава сървърите подред (round robin)
class Balancer {
next = 0;
constructor(servers) {
this.servers = [...servers];
}
pick() {
return this.servers[this.next++ % this.servers.length];
}
remove(server) {
this.servers.splice(this.servers.indexOf(server), 1);
}
}

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

const balancer = new Balancer([new AppServer("A"), new AppServer("B"), new AppServer("C")]);
console.log(balancer.pick().add("maria", "кафе"));
console.log(balancer.pick().add("maria", "чай"));
console.log(balancer.pick().add("maria", "шоколад"));
console.log(balancer.pick().add("maria", "мед"));
Изход
A: кафе
B: чай
C: шоколад
A: кафе, мед

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

Идеята

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

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

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

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

Виж го

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

// общото хранилище на сесиите (в истински магазин Redis)
class SessionStore {
carts = new Map();
add(session, product) {
if (!this.carts.has(session)) this.carts.set(session, []);
const cart = this.carts.get(session);
cart.push(product);
return [...cart];
}
}
class AppServer {
constructor(name, store) {
this.name = name;
this.store = store; // сървърът не пази нищо свое
}
add(session, product) {
const cart = this.store.add(session, product);
return `${this.name}: ${cart.join(", ")}`;
}
}

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

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

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

Пробвай

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

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

const balancer = new Balancer([new AppServer("A"), new AppServer("B")]);
balancer.pick().add("ivan", "кафе");
balancer.pick().add("ivan", "чай");
console.log(balancer.pick().add("ivan", "мед"));
Отговор

Идеята

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

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

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

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

// основната база: всеки запис минава през нея
class Primary {
log = [];
insert(order, customer, now) {
this.log.push({ at: now, order, customer });
}
orders(customer) {
return this.log.filter((c) => c.customer === customer).map((c) => c.order);
}
}
// реплика за четене: промяната стига до нея lag тика след записа
class Replica {
constructor(primary, lag) {
this.primary = primary;
this.lag = lag;
}
orders(customer, now) {
const ids = [];
for (const c of this.primary.log) {
const arrived = c.at + this.lag <= now;
if (arrived && c.customer === customer)
ids.push(c.order);
}
return ids;
}
}
function show(ids) {
return ids.length === 0 ? "няма поръчки" : `поръчки [${ids.join(", ")}]`;
}

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

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

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

Виж го

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

myOrders(customer, now) {
if (this.justWrote(customer, now))
return this.primary.orders(customer);
return this.replica.orders(customer, now);
}
justWrote(customer, now) {
const last = this.lastWrite.get(customer);
return last !== undefined && now - last < this.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 {
lastWrite = new Map();
constructor(primary, replica, lag) {
this.primary = primary;
this.replica = replica;
this.lag = lag;
}
place(order, customer, now) {
this.primary.insert(order, customer, now);
this.lastWrite.set(customer, now);
}
myOrders(customer, now) {
if (this.justWrote(customer, now))
return this.primary.orders(customer);
return this.replica.orders(customer, now);
}
justWrote(customer, now) {
const last = this.lastWrite.get(customer);
return last !== undefined && now - last < this.lag;
}
}
function read(orders, customer, now) {
const from = orders.justWrote(customer, now) ? "основната" : "реплика";
return `тик ${now}, ${customer} → ${from}: ${show(orders.myOrders(customer, now))}`;
}
const primary = new Primary();
const replica = new Replica(primary, 3);
const orders = new OrderService(primary, replica, 3);
orders.place(1042, "maria", 0);
for (let now = 0; now <= 3; now++) {
console.log(read(orders, "maria", now));
}
console.log(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 за достъпно обучение по програмиране ·