Мащабиране
Накратко: Хоризонталното мащабиране добавя копия на сървъра зад разпределител на натоварването; работи, когато сървърът не пази свое състояние, а репликите на базата закъсняват.
Проблем
Black Friday и един сървър
В обикновен ден магазинът получава около 50 заявки в секунда и един сървър стига. На Black Friday заявките стават десет пъти повече (пример, не измерване). Процесорът е на 100%, страниците падат по timeout, а количките се губят. Миналата година фирмата купи по-голяма машина. Тя струва двойно, стигна за година и пак е една: когато се рестартира за обновление, магазинът спира.
Как се добавя още сила, без да чакаш по-голяма машина, и без да спираш магазина, когато една машина падне?
Идеята
По-голяма машина или повече машини
Има два пътя:
- Вертикално мащабиране: по-голяма машина, с повече процесори и памет. Кодът не се мени. Но машината има таван, цената расте по-бързо от силата, а сървърът остава един: падне ли, магазинът спира.
- Хоризонтално мащабиране: няколко еднакви копия на сървъра, а пред тях разпределител (load balancer), който дели заявките помежду им. Няма таван: добавяш копие. Едно копие пада и другите поемат.
Хоризонталното има условие: всяко копие трябва да може да поеме всяка заявка. Разпределителят не знае коя заявка за какво е и ги дава подред.
Разпределителят като истинска инфраструктура (nginx, облачен load balancer, Kubernetes) е тема на DevOps курса ZeroToOps. Тук той е обект, който дава сървърите подред (round robin), за да се види какво става с кода.
Виж го
Три копия с памет
Сървърът пази количките в паметта си, както магазинът пазеше сесиите в речник в урока за сесиите:
class AppServer(string name){ // количките живеят в паметта на този сървър private readonly Dictionary<string, List<string>> carts = new();
public string Add(string session, string product) { if (!carts.TryGetValue(session, out var cart)) carts[session] = cart = []; cart.Add(product); return $"{name}: {string.Join(", ", cart)}"; }}// разпределителят дава сървърите подред (round robin)class Balancer(List<AppServer> servers){ private int next;
public AppServer Pick() => servers[next++ % servers.Count];
public void Remove(AppServer server) => servers.Remove(server);}Мария добавя четири продукта. Разпределителят дава заявките на A, B, C и пак на A:
var balancer = new Balancer([new AppServer("A"), new AppServer("B"), new AppServer("C")]);Console.WriteLine(balancer.Pick().Add("maria", "кафе"));Console.WriteLine(balancer.Pick().Add("maria", "чай"));Console.WriteLine(balancer.Pick().Add("maria", "шоколад"));Console.WriteLine(balancer.Pick().Add("maria", "мед"));A: кафеB: чайC: шоколадA: кафе, медВсяко копие вижда своята количка. На B кафето го няма, на C го няма и чаят, а A вижда кафето, защото Мария пак е при него. При плащането количката е тази, която случайно е в копието, което поема заявката.
Идеята
Сървър, който не пази нищо свое
Сървърът със състояние помни нещо между две заявки: количката, сесията, брояч. Сървърът без състояние (stateless) не помни нищо свое. Всичко, което трябва да оцелее до следващата заявка, е отвън:
- в общо хранилище за сесиите, което всички копия виждат (Redis);
- в базата;
- при клиента, подписано, за да не може да се подмени (в урока за JWT).
Тогава копията са взаимозаменяеми: всяко поема всяка заявка, а когато едно спре, нищо не се губи.
Виж го
Количката в общото хранилище
// общото хранилище на сесиите (в истински магазин Redis)class SessionStore{ private readonly Dictionary<string, List<string>> carts = new();
public List<string> Add(string session, string product) { if (!carts.TryGetValue(session, out var cart)) carts[session] = cart = []; cart.Add(product); return [.. cart]; }}// сървърът не пази нищо своеclass AppServer(string name, SessionStore store){ public string Add(string session, string product) { var cart = store.Add(session, product); return $"{name}: {string.Join(", ", cart)}"; }}Същите заявки, а след третата сървърът B спира:
var store = new SessionStore();var b = new AppServer("B", store);var balancer = new Balancer([new AppServer("A", store), b, new AppServer("C", store)]);Console.WriteLine(balancer.Pick().Add("maria", "кафе"));Console.WriteLine(balancer.Pick().Add("maria", "чай"));Console.WriteLine(balancer.Pick().Add("maria", "шоколад"));balancer.Remove(b); // сървър B спираConsole.WriteLine(balancer.Pick().Add("maria", "мед"));A: кафеB: кафе, чайC: кафе, чай, шоколадC: кафе, чай, шоколад, медВсяко копие вижда цялата количка. Когато B спира, разпределителят дава заявката на C и количката е цяла, защото никога не е била в B. List.copyOf и [.. cart] връщат копие: сървърът не може да пипне списъка в хранилището, както не би могъл и с истински Redis.
Пробвай
Предвиди изхода
Два сървъра с памет, Иван добавя три продукта. Какво ще отпечата последният ред?
var balancer = new Balancer([new AppServer("A"), new AppServer("B")]);balancer.Pick().Add("ivan", "кафе");balancer.Pick().Add("ivan", "чай");Console.WriteLine(balancer.Pick().Add("ivan", "мед"));Разпределителят дава A, B, A. Кафето и медът са на A, а чаят остана на B. A вижда само своите два.
Идеята
Базата не се умножава толкова лесно
Копията на сървъра пишат в една база. Да умножиш базата е по-трудно, защото записите трябва да са на едно място, иначе два записа се бият.
Обичайната стъпка е реплика за четене: копие на базата, което получава промените на основната и отговаря само на четения. Повечето заявки в магазина са четения (каталог, страници, история), затова репликите вземат голяма част от товара. Как се пращат промените, видя в урока за репликацията.
Цената е закъснението на репликата (replication lag): промяната стига до нея след малко. Обикновено части от секундата, но при натоварване и секунди. Тук закъснението е 3 тика:
record Change(int At, int Order, string Customer);
// основната база: всеки запис минава през неяclass Primary{ public List<Change> Log { get; } = [];
public void Insert(int order, string customer, int now) => Log.Add(new Change(now, order, customer));
public List<int> Orders(string customer) => Log.Where(c => c.Customer == customer).Select(c => c.Order).ToList();}
// реплика за четене: промяната стига до нея lag тика след записаclass Replica(Primary primary, int lag){ public List<int> Orders(string customer, int now) { var ids = new List<int>(); foreach (var c in primary.Log) { var arrived = c.At + lag <= now; if (arrived && c.Customer == customer) ids.Add(c.Order); } return ids; }}static string Show(List<int> ids) => ids.Count == 0 ? "няма поръчки" : $"поръчки [{string.Join(", ", ids)}]";Мария плаща на тик 0 и веднага отваря „Моите поръчки“, а то чете от репликата:
var primary = new Primary();var replica = new Replica(primary, 3);primary.Insert(1042, "maria", 0); // Мария плаща на тик 0for (var now = 0; now <= 3; now++){ Console.WriteLine($"тик {now}, реплика: {Show(replica.Orders("maria", now))}");}тик 0, реплика: няма поръчкитик 1, реплика: няма поръчкитик 2, реплика: няма поръчкитик 3, реплика: поръчки [1042]Три тика тя не вижда поръчката си. Плаща втори път или пише на поддръжката, че парите са взети, а поръчка няма.
Виж го
Три копия, общо хранилище и репликата
public List<int> MyOrders(string customer, int now){ if (JustWrote(customer, now)) return primary.Orders(customer); return replica.Orders(customer, now);}
public bool JustWrote(string customer, int now) => lastWrite.TryGetValue(customer, out var last) && now - last < lag;public string Add(string session, string product){ if (!carts.TryGetValue(session, out var cart)) carts[session] = cart = []; cart.Add(product); return $"{name}: {string.Join(", ", cart)}";}public string Add(string session, string product){ var cart = store.Add(session, product); return $"{name}: {string.Join(", ", cart)}";}public List<int> Orders(string customer, int now){ var ids = new List<int>(); foreach (var c in primary.Log) { var arrived = c.At + lag <= now; if (arrived && c.Customer == customer) ids.Add(c.Order); } return ids;}public List<int> MyOrders(string customer, int now){ if (JustWrote(customer, now)) return primary.Orders(customer); return replica.Orders(customer, now);}
public bool JustWrote(string customer, int now) => lastWrite.TryGetValue(customer, out var last) && now - last < lag;Стъпка по стъпка, с думи
- Мария добавя кафе. Разпределителят праща заявката на A, а количката остава в паметта на A.
- Следващата заявка (чай) отива на B. B не знае за кафето: количката там е само с чай.
- Поправката: сървърите не пазят нищо. Количката е в общо хранилище и всеки сървър я чете оттам.
- Сега C добавя шоколад и вижда кафето и чая. Няма значение кой сървър поема заявката.
- B спира. Разпределителят го маха, а количката оцелява: тя никога не е била в B.
- Мария плаща: поръчка 1042 се записва в основната база на тик 0. Репликата получава промяната 3 тика по-късно.
- На тик 1 тя отваря „Моите поръчки“. Четенето отива на репликата, а там поръчката още я няма.
- На тик 3 промяната стига до репликата и поръчката се появява. Дотогава Мария мисли, че плащането не е минало.
- Поправката: който току-що е писал, чете от основната 3 тика. Всички останали четат от репликата.
- Сървърите без състояние се умножават. Базата за запис остава една, а репликите ѝ винаги малко закъсняват.
Идеята
Който е писал, чете от основната
Повечето хора не забелязват, че каталогът е с половин секунда по-стар. Но своята промяна човек очаква да види веднага. Затова: който току-що е писал, чете от основната база, докато закъснението не мине. Всички останали четат от репликата. Това се казва read-your-writes.
// който току-що е писал, чете от основнатаclass OrderService(Primary primary, Replica replica, int lag){ private readonly Dictionary<string, int> lastWrite = new();
public void Place(int order, string customer, int now) { primary.Insert(order, customer, now); lastWrite[customer] = now; }
public List<int> MyOrders(string customer, int now) { if (JustWrote(customer, now)) return primary.Orders(customer); return replica.Orders(customer, now); }
public bool JustWrote(string customer, int now) => lastWrite.TryGetValue(customer, out var last) && now - last < lag;}static string Read(OrderService orders, string customer, int now){ var from = orders.JustWrote(customer, now) ? "основната" : "реплика"; return $"тик {now}, {customer} → {from}: {Show(orders.MyOrders(customer, now))}";}var primary = new Primary();var replica = new Replica(primary, 3);var orders = new OrderService(primary, replica, 3);orders.Place(1042, "maria", 0);for (var now = 0; now <= 3; now++){ Console.WriteLine(Read(orders, "maria", now));}Console.WriteLine(Read(orders, "ivan", 1));тик 0, maria → основната: поръчки [1042]тик 1, maria → основната: поръчки [1042]тик 2, maria → основната: поръчки [1042]тик 3, maria → реплика: поръчки [1042]тик 1, ivan → реплика: няма поръчкиМария вижда поръчката си веднага, от основната. Иван не е писал нищо и чете от репликата: товарът на основната расте само с хората, които току-що са писали.
Идеята
Кое се мащабира и кога вреди
- Се мащабира: сървърите на приложението, когато са без състояние; четенията, с реплики и кеш (в урока за кеша).
- Не се мащабира лесно: записите в основната база. Когато една база не стига за записите, данните се делят на части (шардинг) или се делят на услуги със собствени бази (в урока за монолита и услугите). И двете са големи решения.
- Скрито състояние: кеш в паметта на всяко копие (всяко има своята стара цена), файлове, качени на диска на едно копие, броячи в паметта, задача по график, която тръгва на всяко копие и праща три имейла вместо един.
- Кога вреди: копията искат общо хранилище, реплики и наблюдение на всяко копие. Ако един сървър стига, хоризонталното мащабиране е сложност без полза. Първо измерваш кое е тясното място. Понякога по-голямата машина е по-евтиният първи ход, а често и индекс в базата.
Пробвай
Намери грешката
Магазинът пуска само 5 опита за вход, после блокира. Пентестът показва, че при три копия атакуващият пробва 15 пароли. На кой ред е грешката?
Избери реда с грешката.
Броячът на опитите е в паметта на копието. Разпределителят дава опитите на A, B и C подред, и всяко копие брои само своите. Броячът е състояние, затова трябва да е общ за всички копия: в Redis (с INCR и TTL) или в базата. Проверката на паролата е от урока за паролите.
По-точно, за любопитните
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 тика след първия опит.
- Качени файлове. Сървърът пази снимката на продукта на своя диск. Какво вижда купувач, когато разпределителят го прати на друго копие? Къде трябва да живее снимката?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Сесии, бисквитки и CSRF · JWT и капаните му · Монолит, модулен монолит, услуги · Кеш
- Продължава в: ADR: записани архитектурни решения · Компромиси · Твоята първа архитектурна задача
- Задачи по темата: Софтуерни архитектури