Intro to Stateful Services or How to get 1 million RPS from a single node [ukr]
Презентація доповіді
Stateless є найбільш розповсюдженим підходом для розробки мікросервісів. Цьому є безліч причин, але якщо коротко: дуже просто, дуже надійно та дуже масштабовано. Але як всі ми знаємо - "there is no such thing as a silver bullet". От і ми в компанії зіткнулись з певними обмеженнями даного підходу. Спойлер, Stateless виявився дуже повільним, менш надійним, а також дорожчим.
У своїй доповіді я постараюсь розкрити такі теми:
- Чому ми відмовилися від Stateless підходу на противагу Stateful та як виглядає наша архітектура.
- Як ми будуємо Stateful сервіси: масштабування сервісу, консистентність та синхронізація даних, партиціювання даних.
- Чому Stateless сервіси менш надійні й досить повільні.
- Які є інструменти в наявності для побудови Stateful сервісів.
Транскрипція доповіді
Привіт усім, мене звати Антон, і сьогодні ми поговоримо про Stateful Services. Я працюю в компанії DraftKings на посаді архітектора, пишу на F-Sharp, цікавлюся базами даних, різними дистриб'юційними системами, читаю папери і час від часу виступаю на конференціях. Також у мене є кілька пет-проєктів, один із них - це N-Bomber, Lua Testing Tool для .NET. Він написаний на F-Sharp, має понад 1 мільйон скачувань і продається. Нещодавно я розпочав роботу над іншим проєктом - StereoDB, In-Memory Store, спрямований на побудову Stateful Services чи Stateful ETL-Workers. Загалом, це все про мене. Рухаємося далі.
Адженда. Про що ми будемо говорити? На доповіді я розповім про наш домен, про проблеми нашого домену, ми поговоримо про те, як ми готуємо Stateful системи, і далі буде теорія і трошки про тулінг. Отже, перша частина - інтро в наш домен. Що цікавого в нашому домені? Це, напевно, відомо багатьом - ставки на спорт. У нас є гра, різні події, що впливають на коефіцієнти, і юзери, які відвідують веб-сайт, дивляться події, квітують, підписуються на апдейт-стрім і отримують push-chenji зі змінами коефіцієнтів. Потім вони роблять ставки. В принципі, це все про наш домен. Що ще цікавого?
Тепер щодо даних. У нас досить великі peylods- 30 кбайт закомпресованих даних, які юзер повинен отримати миттєво при вході на сайт. Є значний update-rate - 2000 рпс на Tenant, query-rate- 3-4 рпс на Tenant. Наші live-дані, які ви бачите на сайті, динамічні і складно кешувати через їх часті зміни. У нас є вимоги, що дані повинні бути query-бол, не просто key-value семантика, а складні квері з фільтрацією та secondary-індексами. Подивимося на Redis для прикладу. У тесті, коли ми пишемо і читаємо одночасно, peylods 20 кб, а результати - 4к на читання і 7к на запис. Хоча RPS може бути в нормі, але латентність, особливо P99, велика, що неприйнятно для наших потреб. Це типові труднощі з Redis при збільшенні розміру peylods.
Якщо я йому дам 30 кб, там отримаємо RPS-и, які впадуть приблизно вдвічі. Тобто, який висновок з цього? От ми взяли, як базову точку, супер швидку, в пам'яті, однонодову архітектуру, яка не витрачає ресурси на кластеризацію або комунікацію з іншими вузлами. Просто один вузол, подібний до того, що використовує Redis. Я роблю це для того, щоб ви зрозуміли, взагалі, що є базовим пунктом, від якого ми можемо відштовхуватися.
З нашими показниками. Окей. Зараз я хочу трошки поглибитися в архітектуру безстанційності (stateless) і пояснити, чому вона для нас не працювала. Пояснимо саму суть стейтфул (stateful) і те, як ми впроваджуємо її у нашій системі. Після цього ми розглянемо більш теоретичні аспекти. Отже, наприклад, ми хочемо побудувати наше рішення. У нас є бази даних, якісь API, і ми можемо звертатися до них, розуміючи наш update-rate, кількість конкурентних користувачів і їхню активність. Однак ми виявили, що бази даних будуть дуже повільно працювати через великі update-rate, а також проблеми із синхронізацією у великому кластері. Це ми помітили під час експериментів з MongoDB та іншими технологіями.
Далі, логічним вирішенням стало спробувати збудувати стейтфул (stateful) сервіс, розмістивши дані в пам'яті. Після цього ми виявили, що квері до даних виконуються надзвичайно швидко, і нам не потрібні бази даних. Для синхронізації стану ми використовуємо Kafka, який показав себе добре впродовж багатьох років використання. Далі виникає питання: як ми вирішуємо ситуацію, коли обсяг даних перевищує обсяг оперативної пам'яті? Ми відповідаємо на це, купуючи вузли із дуже великою кількістю оперативної пам'яті, наприклад, 100 гігабайт.
Тому це рідко, взагалі, є проблемою. Але з точки зору ефективності, можливо, буде так, що навіть те, що у вас є стейтфул, не використовується в повному обсязі, і ви хочете щось флашити, так? І тоді ви просто використовуєте рішення, яке надає вам цю можливість. Є такі хранилища, які вміють флашити все, що не вміщається після певного розміру. Наприклад, ви можете скористатися FASTER, про який я розповім пізніше. У ньому можна налаштувати, що тримати в пам'яті, наприклад, 300 мегабайт, а все, що перевищує цей обсяг, буде автоматично флашитися на диск.
Проблема вирішується саме таким чином. Другий підхід стосується оптимізації обробки даних. Якщо їх багато і вони не вміщаються, можна застосувати партиціонування за тенантами. Наприклад, дані можна розділити за регіонами або операторами, щоб забезпечити ефективніше використання ресурсів. Третій варіант, який я зустрічав мало, стосується деяких технологій, таких як Apache Ignite. Ці технології дозволяють будувати розподілені системи і здійснювати оптимізований доступ до даних через різні ноди кластера.
Це всі можливі підходи до роботи зі stateful даними на високому рівні. Тепер дозвольте перейти до теоретичної частини і обговорити, чому саме stateful додатки часто працюють повільніше. Звідки ми розпочнемо? StartNEM. StartNEM є типовим прикладом, де існують API, база даних і Redis cache. З точки зору дизайну, у нас є stateless networking, що забезпечує низьку затримку. Але чому саме networking-затримка така важлива?
Подивимось на статистику затримок. У 2010 році затримка для вичитки одного мегабайта з оперативної пам'яті становила 30 мікросекунд, а в 2023 році - 3 мікросекунди. З іншого боку, затримка мережі round trip залишається незмінною і становить 0,5 мілісекунди. Це демонструє, що networking-затримка в десятки разів вища, ніж затримка вичитки даних. Таким чином, проблема затримки networking залишається актуальною і не піддається значним поліпшенням в порівнянні із швидкістю доступу до даних.
Ми просто говоримо про якийсь round trip. Йдемо далі. У нас, крім просто networking-а, є ще й інші, як би сказати, причини, чому stateless тупить. І одна з них, наприклад, це серіалізація та десеріалізація. У stateless рішеннях вона, мов би, вбудована, вона включена. Тобто дані з RADIUS, дані з бази даних, вони десеріалізуються, потім серіалізуються і повертаються користувачу. У stateful ми маємо, в основному, тільки десеріалізацію і, вибачте, серіалізацію.
Бо дані в нас в пам'яті, ми нічого звідкись не дістаємо, прямо з пам'яті пройшлися по ентитетам нашим, їх фільтрували і вже засеріалізували відповідь для клієнтів. Тобто ми робимо тільки серіалізацію, десеріалізації немає. Вже ця річ, як би, знімає 50% навантаження на CPU, яка потрібна для серіалізації та десеріалізації. Пізніше ми розглянемо, чому це дорого. Але зараз просто на високому рівні, щоб було зрозуміло, що ця річ в stateful легше на половину. Далі, те, що є в stateless і чого немає в stateful, це асинхронність. Асинхронність, ця річ, типу, допомагає вам використовувати ресурси вашої системи ефективно. Але при цьому вона має свою ціну. І пізніше я покажу на статистиці, на даних, на бенчмарках, що ця річ також використовує CPU, але вона не так сильно вас гальмує, порівняно із латентністю. І фішка в тому, що в stateful цієї річі взагалі немає. У вас реквести, можна сказати, синхронні. Вони приходять на сервер і обробляються, віддаються. Так, якщо у вас там є якийсь Nginx, Castrel або щось подібне, то там є якась синхронність. Але, якщо ми дивимося чисто на ваш сервіс як процес, там цього немає.
Наступна річ, яка є в цих stateless, це сокети. Але навіть сокети, вони трошки відрізняються використанням. У stateless архітектурі сокети використовуються користувачами або API, які використовують ваш сервіс. Також сокети виділяються для роботи з базою даних та кешем. У stateful цього немає. У вас просто сокети для клієнтів, а немає сокетів для баз даних чи кешів. Тому з цього погляду stateful важчий.
Наступна річ, це querying. У stateful є querying, і фішка в тому, що ці запити виконуються, їх потрібно екзек'ютити, але вони екзек'ютяться, як би, дешево. Бо в більшості випадків, з нашого досвіду, це часто якісь point look-up, навіть якщо це secondary index. Якщо у вас є якийсь користувач і ви хочете його знайти не за primary key, а за e-mail, то робиться secondary index для e-mail, який базується на хешах. Завдяки цьому отримуєте константний доступ, який дуже швидкий, схожий на роботу з дікшенері. Є деякі secondary index, які дозволяють вам робити range-scanner, і вони можуть бути не такими швидкими, але точно не повільнішими, ніж на різних базах даних. І вони виконуються в пам'яті.
І це, так би мовити, дуже дешево, порівнюючи з серіалізацією, бо якщо взяти якийсь прохід по дереву, там декілька нод, їх фільтруєте та серіалізуєте в JSON. Процес значно навантажується. Тому я оцінюю, наприклад, querying, як те, що займає, мабуть, максимум 5% від усього навантаження на ваш сервіс. Тобто воно не так сильно вам заважає. І наступна річ, яка є в stateless зараз, але відсутня в stateful, це overridden. Часто ви хочете представити якийсь контент користувачам, але цей контент потрібно представити інакше в кожному випадку. Можливо, він менший для мобільних клієнтів, інший для робочого столу і так далі.
Фішка полягає в тому, що при роботі з кешем, з Redis, ви можете отримати набагато більше даних і потім вже фільтрувати їх на API-ноді, передавати їх. І фішка в тому, що цей overridden відбувається постійно. Ви, здебільшого, тягнете більше, ніж реально потрібно, і за це ви платите. Ви повністю його десеріалізуєте, отже, ви за це платите. У 2019-му, або в 2017-му, точно не пам'ятаю, році вийшов дуже цікавий пейпер. Він називається "Fast Key-Value Store, an idea whose time has come and gone". Троє чоловік з Google та один з Stanford University розповідають про теперішню ситуацію в екосистемі з stateless сервісами та API. Вони обговорюють невіддачі цієї системи, такі як робота з CPU, маршалінг, пам'ять, ефективність, мережі та overridden. У кінці вони приводять приклади порівнянь між роботою stateless та stateful систем. Вони показують, наскільки значно відмінюється використання CPU, мережі у stateful застосунках порівняно з stateless. Далі вони демонструють, як overridden впливає на ці показники, коли потрібно звертатися до кешу за більше даних. Отже, за результатами їхнього дослідження, видно, що використання CPU та мережі в stateful застосунках зменшується на 50%, що робить його ефективнішим. Однак overridden може спричинити просадку у використанні мережі на 70-85%, що слід врахувати.
Завершуючи цю тему, перехожу до менш технічної, але також важливої теми, яка може додавати додатковий лейтенс у наш сетап: Object Heat Rate і Transactional Heat Rate. Суть полягає в тому, як ми працюємо з кешем та базою даних. Ми кешуємо "гарячі" дані, які часто використовуються. Однак іноді для певних випадків це не працює добре через неефективність алгоритму витіснення даних. Ми зазвичай використовуємо підходи last recently used або last frequently used, але це може не завжди працювати. Може статися ситуація, коли нам потрібно підтягнути дані для юзерфлову, але деякі з цих даних вже витіснилися з кешу, і це призводить до звернення до бази даних замість кешу, збільшуючи відзначену латентність.
І отже, щодо користі від кешу тут, можна сказати, що він розгружає базу даних трошки, оскільки всі ці три запити інакше б потрапили в базу, а так тільки два. Проте, щодо затримок і прискорення, stateless працює повільніше. У цьому році вийшов пейпер від чуваків із Берклі, які описують саме цю проблему. У пейпері вони стверджують, що на продакшн ворклоді Facebook вони проводили тести, і при певних сценаріях відбувається така ситуація, коли 90% закешованих об'єктів не впливають на фінальну затримку, оскільки все одно потрібно звертатися до реального сховища на диску. У пейпері вони наводять алгоритм для ефективного використання хінтів та уникнення викидання об'єктів, які рідше використовуються. Є також система трекінгу. Коротше кажучи, у пейпері це описано, і Point, вони демонструють діаграму, показуючи різницю в 50%.
Щодо іншої теми, про яку я вже згадував - це AsyncAwait. Типово, що з ним не так? Давайте уявимо, що у нас є Redis, і ми просто хочемо його локально опитати. Він у нас на локальному хості, без мережі, без додаткових 0,5 мілісекунд затримки. Все має працювати швидко. Проте в нас є AsyncAwait. Ми проводимо аналіз з використанням простого бенчмарку, де є метод Add, який не інлайниться компілятором, і в результаті тестів отримуємо 849 мільйонів операцій в секунду. Додаємо AsyncAwait, і цифри падають у 4 рази до 200 мільйонів, але це все одно велика цифра. Тут є нюанс - AsyncAwait виконується синхронно, без шедулінгу, відсутнього у інших IO-bound операціях. Щоб продемонструвати вплив шедулінгу, вводимо TaskYield, що примушує Runtime виконати Add на іншому треді. Результати суттєво знижуються. Додавання ще однієї синхронної операції погіршує ситуацію.
І ми побачимо, що у нас ще сильніше просідають операції в секунду. Перед цим у нас були мільйони, зараз ми просто перейшли в сотні тисяч. От. І отже, зараз можна подумати про випадки використання, коли ви заходите на сайт, переходите до кешу, робите синхронну операцію, в кеші нічого немає, і ви переходите до бази даних. Це відбувається згідно з даними, так?
Тепер ще одна цікава статистика. Я відобразив ці бенчмарки та дослідив, як навантажуються ядра на CPU. Цікаво в тому, що синхронна версія, будь-який синхронний метод, який ви бачили, набирає під мільярд операцій в секунду, або синхронний SYNC-await, який набирає під 200 мільйонів, взагалі не навантажує ядра. Тобто це відбувається через те, що сам бенчмарк не навантажує потоки, він виконується на одному потоці, є ще деякі допоміжні елементи, але в основному він не є багатопотоковим, не навантажує нічого. Таким чином, якби я зробив цей бенчмарк багатопотоковим, я б міг покращити ситуацію, мінімум вдвічі-тричі. І операцій було б дійсно під мільярд. Тепер подивимось на ситуацію з SYNC-await Yield. Як ми бачимо, всі ядра в нас повністю завантажені. З мільйонів, можливо, з мільярдів ви переходите до сотень тисяч. І це лише SYNC-await, який фактично ще нічого не робить. Далі у вас буде мережеві операції, потім маршалінг, демаршалінг, і ще кілька операцій.
Наостанок переходимо до ще одного цікавого аспекту стосовно Stateless і того, чому він менш надійний. Найкращим поясненням буде ця схема. Як видно, в Stateless архітектурі ми маємо значно більше точок відмов. API може відмовити, може відмовити мережевий шар між кешем і API, може відмовити мережевий шар між базою і API, і сама база може збоїти, кеш також може відмовити. Іншими словами, більше можливостей для виникнення проблем. Це пояснюється статистикою, яка вказує на більшу кількість проблем саме тут, і більшу ймовірність того, що щось може піти не так. Проте це ще не все. Після цього висновку з'ясовується, що тепер нам потрібно додати компонентів до нашого рішення, яке має бути Stateless, оскільки ці архітектурні рішення не обробляються легко, і нам потрібні такі рішення, як Circuit Breaker, Retry, Fallback. Ми додаємо це до нашого коду в усіх точках, де ми звертаємося до бази даних, і ми маємо відстежувати це, щоб у нас не виникла яка-небудь точка, яка виходить в базу, і вона не захищена цими всіма механізмами Circuit Breaker, Ballhead, Retry, оскільки це може призвести до проблем.
Стосовно Data Invalidation. Це дуже важка, основна проблема в розподілених системах. Data Invalidation і з нього випливає Data Consistency. Дуже важко зробити це ефективно, якщо ваш Use Case рідко використовує запити або дані рідко змінюються, можливо, це не проблема. Однак, якщо дані змінюються частіше, ви потрапляєте в проміжки, коли вам потрібно оновлювати як кеш, так і базу даних, і вам доводиться думати, як це зробити консистентно. Я не бачив жодного ефективного варіанту в реальному виробництві, де хтось б це робив належним чином. Тому це є відкрите питання.
Щодо Prediction. Коли ви проекту є архітектура, важливо розуміти, що ви можете побудувати щось передбачуване. Ви розумієте, яке буде RPS, яке воно здатне витримати. Наскільки легко це зробити у випадку Stateless? Зазвичай це важко реалізувати, оскільки ви дійсно не розумієте, що буде, коли додаєте одну ноду до Redis, Postgres, Aurora, DynamoDB, Cockroach, TiDB чи будь-якої іншої технології, яку ви використовуєте. Тобто це не визначено.
Ти розумієш, чи це дійсно покращить твій RPS і наскільки. Це просто якесь вгадування. Тобі потрібно якось перевірити це. Для мене це проблема. Stateful сервіси набагато краще продумані в цьому плані. Тобто тобі легше оцінити, якщо твій Stateful тримає, скажімо, 10K RPS, то ти розумієш, що для тримання 20 юзерів там потрібно підняти 3 ноди, і вони будуть витримувати 20K RPS від юзерів. Це дуже прогнозована модель.
Stateless системи розмазані і складні. Отже, наступний пункт - Metastable Failure. Перший пейпер про Metastable Failures з'явився два роки тому. До цього такі Failures просто існували, але не мали назви. Це певний тип Failures, який виникає в високонавантажених системах і, як правило, провокується різкою зміною навантаження. Високонавантажені системи більш схильні до цього типу проблем, і в пейпері розглядають багато прикладів, один з яких - RetryStorm.Після читання цього пейперу і аналізу типів помилок зрозумієте, що стейтлес системи більш схильні до цього типу проблем. Окрім того, ще один пейпер, який вийшов після, це "Metastable Failures in the World", який розглядає конкретні фейлори Twitter з аналізом і статистикою. Рекомендую його прочитати.
Остання частина - про тулінг. Для початку, вам потрібен distributed log. Наразі є багато технологій, що це підтримують, такі як Pulsar, Kafka, Pandu чи Nuts. Далі вам знадобиться in-memory движок. Залежно від екосистеми можна вибрати Pulsar, Ignite, Pandu чи DuckDB для SQL OLAP. Наскільки стосується тулінгу, рекомендую розглянути StereoDB та PaperProFaster для Key-Value-Store від Microsoft, який підтримує In-Place-Updates та Read-Write-Modify-Consistency per Record. Вони можуть бути корисні для різних завдань.
Штука, яку я роблю, на Read-Write-Mixed Workload'ах показує RPS приблизно 3 мільйони для Read і 100 тисяч для Write. Це все обробляється протягом 900 мс. Якщо говорити тільки про Write, то це близько 2 мільйони RPS. Вона підтримує Secondary Index'и, Hash-Based Value Index'и, Arrange Scans. Скоро буде додана підтримка Persistence, яка буде повністю відображена, де всі дані персистуються. Також буде реалізована Persistence, коли можна буде вказати, наприклад, 500 МБ для зберігання в пам'яті, а все інше автоматично перекидатиметься на диск.
Щодо нашого робочого навантаження в продакшені, у нас є статистика та деякі аспекти технічного стеку. Ми тримаємо, скажімо, великий штат, наприклад, в пікове навантаження ми використовуємо 5-10 нод. Для такого штату, як Нью-Йорк, одна нода зазвичай має 0.5-2 CPU, 6 ГБ RAM. Заключно, важливо відзначити, що наш Stateful використовується лише на 10% від усього розроблення, більшість в нас stateless. Stateful архітектура є не тільки застосовною для нашого домену, але також може бути ефективною для багатьох інших, як приклад сайти для покупки квитків або booking.com. Ці рішення можна успішно застосовувати, просто потрібно уважно розглядати особливості вашого домену, робити POC і аналізувати результати. Дякую за увагу.