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

Мащабиране

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

Проблем

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

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

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

Идеята

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

Има два пътя:

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

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

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

Виж го

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

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

class AppServer:
def __init__(self, name):
self.name = name
self.carts = {} # количките живеят в паметта на този сървър
def add(self, session, product):
cart = self.carts.setdefault(session, [])
cart.append(product)
return f"{self.name}: {', '.join(cart)}"
# разпределителят дава сървърите подред (round robin)
class Balancer:
def __init__(self, servers):
self.servers = list(servers)
self.next = 0
def pick(self):
server = self.servers[self.next % len(self.servers)]
self.next += 1
return server
def remove(self, server):
self.servers.remove(server)

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

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

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

Идеята

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

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

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

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

Виж го

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

# общото хранилище на сесиите (в истински магазин Redis)
class SessionStore:
def __init__(self):
self.carts = {}
def add(self, session, product):
cart = self.carts.setdefault(session, [])
cart.append(product)
return list(cart)
class AppServer:
def __init__(self, name, store):
self.name = name
self.store = store # сървърът не пази нищо свое
def add(self, session, product):
cart = self.store.add(session, product)
return f"{self.name}: {', '.join(cart)}"

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

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

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

Пробвай

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

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

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

Идеята

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

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

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

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

@dataclass
class Change:
at: int
order: int
customer: str
# основната база: всеки запис минава през нея
class Primary:
def __init__(self):
self.log = []
def insert(self, order, customer, now):
self.log.append(Change(now, order, customer))
def orders(self, customer):
return [c.order for c in self.log if c.customer == customer]
# реплика за четене: промяната стига до нея lag тика след записа
class Replica:
def __init__(self, primary, lag):
self.primary = primary
self.lag = lag
def orders(self, customer, now):
ids = []
for c in self.primary.log:
arrived = c.at + self.lag <= now
if arrived and c.customer == customer:
ids.append(c.order)
return ids
def show(ids):
return f"поръчки {ids}" if ids else "няма поръчки"

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

primary = Primary()
replica = Replica(primary, 3)
primary.insert(1042, "maria", 0) # Мария плаща на тик 0
for now in range(4):
print(f"тик {now}, реплика: {show(replica.orders('maria', now))}")
Изход
тик 0, реплика: няма поръчки
тик 1, реплика: няма поръчки
тик 2, реплика: няма поръчки
тик 3, реплика: поръчки [1042]

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

Виж го

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

def my_orders(self, customer, now):
if self.just_wrote(customer, now):
return self.primary.orders(customer)
return self.replica.orders(customer, now)
def just_wrote(self, customer, now):
last = self.last_write.get(customer)
return last is not None and now - last < self.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:
def __init__(self, primary, replica, lag):
self.primary = primary
self.replica = replica
self.lag = lag
self.last_write = {}
def place(self, order, customer, now):
self.primary.insert(order, customer, now)
self.last_write[customer] = now
def my_orders(self, customer, now):
if self.just_wrote(customer, now):
return self.primary.orders(customer)
return self.replica.orders(customer, now)
def just_wrote(self, customer, now):
last = self.last_write.get(customer)
return last is not None and now - last < self.lag
def read(orders, customer, now):
source = "основната" if orders.just_wrote(customer, now) else "реплика"
return f"тик {now}, {customer} → {source}: {show(orders.my_orders(customer, now))}"
primary = Primary()
replica = Replica(primary, 3)
orders = OrderService(primary, replica, 3)
orders.place(1042, "maria", 0)
for now in range(4):
print(read(orders, "maria", now))
print(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 за достъпно обучение по програмиране ·