Jak Braze wykorzystuje Ruby na dużą skalę?

Opublikowany: 2022-08-18

Jeśli jesteś inżynierem, który czyta Hacker News, Developer Twitter lub inne podobne źródła informacji, prawie na pewno natknąłeś się na tysiąc artykułów o tytułach takich jak „Speed ​​of Rust vs C”, „What Makes Node”. js Faster Than Java?” lub „Dlaczego powinieneś używać Golanga i jak zacząć”. Artykuły te ogólnie wskazują, że istnieje jeden konkretny język, który jest oczywistym wyborem ze względu na skalowalność lub szybkość — i że jedyne, co musisz zrobić, to go zaakceptować.

Kiedy byłem w college'u i przez pierwszy rok lub dwa jako inżynier, czytałem te artykuły i natychmiast uruchamiałem projekt dla zwierząt, aby nauczyć się nowego języka lub frameworku du jour. W końcu gwarantowane było działanie „na skalę globalną” i „szybciej niż wszystko, co kiedykolwiek widziałeś”, a kto może się temu oprzeć? W końcu zorientowałem się, że w większości moich projektów nie potrzebuję żadnej z tych bardzo specyficznych rzeczy. W miarę rozwoju mojej kariery zdałem sobie sprawę, że żaden wybór języka lub struktury nie da mi tych rzeczy za darmo.

Zamiast tego odkryłem, że to architektura jest w rzeczywistości największą dźwignią, gdy chcesz skalować systemy, a nie języki czy frameworki.

Tutaj w Braze działamy na ogromną skalę globalną. I tak, używamy Ruby i Rails jako dwóch naszych podstawowych narzędzi do tego. Jednak nie istnieje wartość konfiguracyjna „global_scale = true”, która umożliwiałaby to wszystko — jest to wynik dobrze przemyślanej architektury obejmującej głęboko w aplikacjach aż do topologii wdrożeniowych. Inżynierowie z Braze nieustannie badają skalowanie wąskich gardeł i zastanawiają się, jak przyspieszyć nasz system, a odpowiedzią zwykle nie jest „odejście od Rubiego”: prawie na pewno będzie to zmiana w architekturze.

Przyjrzyjmy się więc, w jaki sposób Braze wykorzystuje przemyślaną architekturę, aby faktycznie rozwiązać problem szybkości i masowej globalnej skali — i gdzie pasują do nich Ruby i Rails (a nie)!

Moc najlepszej w swojej klasie architektury

Proste żądanie internetowe

Ze względu na skalę, w jakiej działamy, wiemy, że urządzenia powiązane z bazami użytkowników naszych klientów będą każdego dnia wysyłać miliardy żądań internetowych, które będą musiały być obsługiwane przez jakiś serwer sieciowy Braze. Nawet w najprostszych witrynach będziesz mieć stosunkowo złożony przepływ związany z żądaniem od klienta do serwera iz powrotem:

  1. Rozpoczyna się od rozwiązania DNS klienta (zwykle jego dostawcy usług internetowych), który ustala, do którego adresu IP należy się udać, na podstawie domeny w adresie URL Twojej witryny.

  2. Gdy klient ma adres IP, wyśle ​​żądanie do routera będącego bramą, który prześle je do routera „następnego przeskoku” (co może się zdarzyć kilka razy), dopóki żądanie nie trafi do docelowego adresu IP.

  3. Stamtąd system operacyjny na serwerze odbierającym żądanie obsłuży szczegóły dotyczące sieci i powiadomi proces oczekiwania serwera WWW, że przychodzące żądanie zostało odebrane przez gniazdo/port, na którym nasłuchiwał.

  4. Serwer sieciowy zapisze odpowiedź (żądany zasób, być może index.html) do tego gniazda, które będzie podróżować wstecz przez routery z powrotem do klienta.

Dość skomplikowane rzeczy na prostą stronę internetową, nie? Na szczęście wiele z tych rzeczy jest już za nas załatwionych (o tym za chwilę). Ale nasz system nadal ma magazyny danych, zadania w tle, problemy ze współbieżnością i wiele więcej, z którymi musi sobie poradzić! Przyjrzyjmy się, jak to wygląda.

Pierwsze systemy obsługujące skalę

Serwery DNS i nazw zazwyczaj nie wymagają w większości przypadków dużej uwagi. Twój serwer nazw domen najwyższego poziomu prawdopodobnie będzie miał kilka wpisów do mapowania „twojastrona.com” na serwery nazw dla Twojej domeny, a jeśli korzystasz z usługi takiej jak Amazon Route 53 lub Azure DNS, będą one obsługiwać nazwę serwery dla Twojej domeny (np. zarządzanie rekordami A, CNAME lub innego typu). Zwykle nie musisz myśleć o skalowaniu tej części, ponieważ będzie to obsługiwane automatycznie przez systemy, których używasz.

Jednak część trasowania przepływu może stać się interesująca. Istnieje kilka różnych algorytmów routingu, takich jak Open Shortest Path First lub Routing Information Protocol, wszystkie zaprojektowane w celu znalezienia najszybszej/najkrótszej trasy od klienta do serwera. Ponieważ Internet jest w rzeczywistości gigantycznym połączonym wykresem (lub alternatywnie siecią przepływów), może istnieć wiele ścieżek, które można wykorzystać, z których każda ma odpowiednio wyższy lub niższy koszt. Wykonywanie pracy w celu znalezienia absolutnie najszybszej trasy byłoby niemożliwe, więc większość algorytmów używa rozsądnej heurystyki, aby uzyskać akceptowalną trasę. Komputery i sieci nie zawsze są niezawodne, dlatego polegamy na Fastly, aby zwiększyć zdolność naszych klientów do szybszego trasowania do naszych serwerów.

Szybko działa, zapewniając punkty obecności (POP) na całym świecie z bardzo szybkimi i niezawodnymi połączeniami między nimi. Pomyśl o nich jako o międzystanowej autostradzie Internetu. Rekordy A i CNAME naszych domen wskazują na Fastly, co powoduje, że żądania naszych klientów są kierowane bezpośrednio na autostradę. Stamtąd Fastly może skierować je we właściwe miejsce.

Drzwi wejściowe do lutowania

Ok, więc prośba naszego klienta poszła autostradą Fastly i znajduje się tuż przy drzwiach wejściowych platformy Braze — co dalej?

W prostym przypadku te drzwi wejściowe byłyby pojedynczym serwerem akceptującym żądania. Jak możesz sobie wyobrazić, nie skalowałoby się to zbyt dobrze, więc tak naprawdę wskazujemy Fastly na zestaw równoważników obciążenia. Istnieją różne rodzaje strategii, z których mogą korzystać systemy równoważenia obciążenia, ale wyobraź sobie, że w tym scenariuszu Fastly round robin żądania równomiernie do puli systemów równoważenia obciążenia. Te systemy równoważenia obciążenia ustawiają żądania w kolejce, a następnie dystrybuują te żądania do serwerów internetowych, które, jak możemy sobie wyobrazić, są traktowane jako żądania klientów w sposób okrężny. (W praktyce niektóre rodzaje powinowactwa mogą mieć zalety, ale to temat na inny czas).

Pozwala nam to skalować liczbę systemów równoważenia obciążenia i liczbę serwerów internetowych w zależności od przepustowości otrzymywanych żądań i przepustowości żądań, które możemy obsłużyć. Do tej pory zbudowaliśmy architekturę, która może obsłużyć gigantyczny nawał żądań bez wysiłku! Dzięki elastyczności kolejek żądań systemów równoważenia obciążenia może nawet obsłużyć schematy ruchu o dużym natężeniu — co jest niesamowite!

Serwery WWW

Na koniec dochodzimy do ekscytującej części (Ruby): Serwer WWW. Używamy Ruby on Rails, ale to tylko framework webowy — rzeczywistym serwerem webowym jest Unicorn. Unicorn działa, uruchamiając wiele procesów roboczych na maszynie, gdzie każdy proces roboczy nasłuchuje pracy w gnieździe systemu operacyjnego. Obsługuje za nas zarządzanie procesami i przenosi równoważenie obciążenia żądań do samego systemu operacyjnego. Potrzebujemy tylko naszego kodu Rubiego, aby przetworzyć żądania tak szybko, jak to możliwe; wszystko inne jest dla nas efektywnie zoptymalizowane poza Ruby.

Ponieważ większość żądań wysyłanych przez nasz SDK w aplikacjach naszych klientów lub za pośrednictwem naszego interfejsu API REST jest asynchroniczna (tj. nie musimy czekać na zakończenie operacji, aby zwrócić określoną odpowiedź do klientów), większość naszych Serwery API są niezwykle proste — sprawdzają strukturę żądania, wszelkie ograniczenia klucza API, a następnie wrzucają żądanie do kolejki Redis i zwracają odpowiedź 200 do klienta, jeśli wszystko się sprawdzi.

Ten cykl żądanie/odpowiedź zajmuje około 10 milisekund na przetworzenie kodu Rubiego — a część tego czasu spędza się na czekaniu na Memcached i Redis. Nawet gdybyśmy mieli przepisać to wszystko w innym języku, nie da się z tego wycisnąć znacznie większej wydajności. Ostatecznie to architektura wszystkiego, co do tej pory przeczytałeś, umożliwia nam skalowanie tego procesu pozyskiwania danych w celu zaspokojenia stale rosnących potrzeb naszych klientów.

Kolejki pracy

Jest to temat, który zgłębialiśmy w przeszłości, więc nie będę się zagłębiać w ten aspekt — aby dowiedzieć się więcej o naszym systemie kolejek pracy, zapoznaj się z moim postem na temat Osiąganie odporności dzięki kolejkom. Na wysokim poziomie wykorzystujemy liczne instancje Redis, które działają jako kolejki zadań, dodatkowo buforując pracę, którą należy wykonać. Podobnie jak nasze serwery internetowe, te instancje są podzielone na strefy dostępności — aby zapewnić wyższą dostępność w przypadku problemu w określonej strefie dostępności — i występują w parach podstawowa/dodatkowa przy użyciu Redis Sentinel w celu zapewnienia nadmiarowości. Możemy również skalować je zarówno w poziomie, jak i w pionie, aby zoptymalizować zarówno pojemność, jak i przepustowość.

Pracownicy

To z pewnością najciekawsza część — jak sprawić, by pracownicy skalowali się?

Przede wszystkim nasi pracownicy i kolejki są podzielone według wielu wymiarów: Klienci, rodzaje pracy, potrzebne magazyny danych itp. Dzięki temu mamy wysoką dostępność; na przykład, jeśli dany magazyn danych ma trudności, inne funkcje będą nadal działać bez zarzutu. Pozwala nam również na automatyczne skalowanie typów procesów roboczych niezależnie, w zależności od dowolnego z tych wymiarów. W końcu jesteśmy w stanie zarządzać wydajnością pracowników w sposób skalowalny poziomo — to znaczy, jeśli mamy więcej określonego rodzaju pracy, możemy zwiększyć liczbę pracowników.

Oto miejsce, w którym możesz zacząć dostrzegać znaczenie wyboru języka lub struktury. Ostatecznie bardziej wydajny pracownik będzie w stanie wykonać więcej pracy szybciej. Języki kompilowane, takie jak C lub Rust, są zwykle znacznie szybsze w zadaniach obliczeniowych niż języki interpretowane, takie jak Ruby, co może prowadzić do bardziej wydajnych pracowników w przypadku niektórych obciążeń. Jednak spędzam dużo czasu, patrząc na ślady, a surowe przetwarzanie procesora to zaskakująco mała część w dużym obrazie Braze. Większość naszego czasu przetwarzania spędzamy na oczekiwaniu na odpowiedzi z magazynów danych lub na żądania zewnętrzne, a nie na przetwarzanie liczb; nie potrzebujemy do tego mocno zoptymalizowanego kodu C.

Magazyny danych

Jak dotąd wszystko, co omówiliśmy, jest dość skalowalne. Poświęćmy więc chwilę i porozmawiajmy o tym, gdzie nasi pracownicy spędzają większość czasu — magazyny danych.

Każdy, kto kiedykolwiek skalował serwery WWW lub asynchroniczne procesy robocze korzystające z bazy danych SQL, prawdopodobnie napotkał konkretny problem związany ze skalą: transakcje. Możesz mieć punkt końcowy, który zajmuje się realizacją zamówienia, który tworzy dwa FulfillmentRequests i PaymentReceipt. Jeśli to nie wszystko dzieje się w transakcji, możesz skończyć z niespójnymi danymi. Wykonywanie wielu transakcji jednocześnie na jednej bazie danych może skutkować dużą ilością czasu spędzonego na blokadach, a nawet zakleszczeniu. W Braze zajmujemy się skalowaniem problemu z samymi modelami danych, dzięki niezależności obiektów i ostatecznej spójności. Dzięki tym zasadom możemy wycisnąć dużo wydajności z naszych magazynów danych.

Niezależne obiekty danych

W Braze intensywnie wykorzystujemy MongoDB z bardzo dobrych powodów: Mianowicie, umożliwia nam to znaczne skalowanie fragmentów MongoDB w poziomie i uzyskanie niemal liniowego wzrostu pojemności i wydajności. Działa to bardzo dobrze w przypadku naszych profili użytkowników ze względu na ich niezależność od siebie — między profilami użytkowników nie ma instrukcji JOIN ani relacji ograniczeń. W miarę rozwoju każdego z naszych klientów lub dodawania nowych klientów (lub obu), możemy po prostu dodawać nowe bazy danych i nowe fragmenty do istniejących baz danych, aby zwiększyć naszą pojemność. Wyraźnie unikamy funkcji, takich jak transakcje wielodokumentowe, aby utrzymać ten poziom skalowalności.

Oprócz MongoDB często wykorzystujemy Redis jako tymczasowy magazyn danych do takich rzeczy, jak buforowanie informacji analitycznych. Ponieważ źródło prawdy dla wielu z tych analiz istnieje w MongoDB jako niezależne dokumenty przez pewien czas, utrzymujemy poziomo skalowalną pulę instancji Redis, które działają jako bufory; w ramach tego podejścia zaszyfrowany identyfikator dokumentu jest używany w schemacie shardingu opartym na kluczu, równomiernie rozkładając obciążenie ze względu na niezależność. Okresowe zadania opróżniają te bufory z jednego magazynu danych o skali poziomej do innego magazynu danych o skali poziomej. Skala osiągnięta!

Co więcej, używamy Redis Sentinel w tych przypadkach, tak jak robimy to w przypadku kolejek zadań wymienionych powyżej. Wdrażamy również wiele „typów” tych klastrów Redis do różnych celów, zapewniając nam kontrolowany przepływ awarii (tj. jeśli jeden konkretny typ klastra Redis ma problemy, nie widzimy, że niepowiązane funkcje zaczynają jednocześnie zawodzić).

Ostateczna spójność

Braze wykorzystuje również ostateczną spójność jako zasadę w przypadku większości operacji odczytu. Pozwala nam to w większości przypadków wykorzystać odczytywanie zarówno podstawowych, jak i drugorzędnych elementów zestawów replik MongoDB, dzięki czemu nasza architektura jest bardziej wydajna. Ta zasada w naszym modelu danych pozwala nam intensywnie wykorzystywać buforowanie w całym naszym stosie.

Używamy podejścia wielowarstwowego przy użyciu Memcached — zasadniczo, gdy żądamy dokumentu z bazy danych, najpierw sprawdzamy lokalny proces Memcached na maszynie z bardzo krótkim czasem życia (TTL), a następnie sprawdzamy zdalną instancję Memcached (za pomocą wyższe TTL), zanim kiedykolwiek zwrócisz się bezpośrednio do bazy danych. Pomaga nam to znacznie ograniczyć odczyty bazy danych dla typowych dokumentów, takich jak ustawienia klienta lub szczegóły kampanii. „Ewentualne” może brzmieć przerażająco, ale w rzeczywistości trwa to tylko kilka sekund, a takie podejście zmniejsza ogromny ruch ze źródła prawdy. Jeśli kiedykolwiek brałeś udział w zajęciach z architektury komputerowej, możesz rozpoznać, jak podobne jest to podejście do tego, jak działa system pamięci podręcznej L1, L2 i L3 procesorów!

Dzięki tym sztuczkom możemy wycisnąć dużo wydajności z prawdopodobnie najwolniejszej części naszej architektury, a następnie odpowiednio skalować ją poziomo, gdy wzrośnie nasze zapotrzebowanie na przepustowość lub pojemność.

Gdzie wpasowuje się Ruby i Rails

Oto rzecz: Okazuje się, że kiedy poświęcasz dużo wysiłku na budowanie holistycznej architektury, w której każda warstwa dobrze skaluje się w poziomie, szybkość języka lub środowiska wykonawczego jest o wiele mniej ważna, niż mogłoby się wydawać. Oznacza to, że wybór języków, frameworków i środowisk wykonawczych jest dokonywany z zupełnie innym zestawem wymagań i ograniczeń.

Ruby i Rails mają udokumentowane doświadczenie w pomaganiu zespołom w szybkiej iteracji, gdy Braze został uruchomiony w 2011 roku — i nadal są używane przez GitHub, Shopify i inne wiodące marki, ponieważ nadal to umożliwia. Nadal są aktywnie rozwijane przez społeczności Ruby i Rails, i obie nadal mają świetny zestaw bibliotek open-source dostępnych dla różnych potrzeb. Para jest doskonałym wyborem do szybkiej iteracji, ponieważ mają ogromną elastyczność i utrzymują znaczną prostotę w typowych przypadkach użycia. Przekonujemy się, że jest to przytłaczająca prawda każdego dnia, w którym go używamy.

Nie oznacza to, że Ruby on Rails jest idealnym rozwiązaniem, które będzie działać dobrze dla każdego. Jednak w Braze odkryliśmy, że bardzo dobrze sprawdza się w obsłudze dużej części naszego potoku pozyskiwania danych, potoku wysyłania wiadomości i naszego pulpitu nawigacyjnego skierowanego do klienta, z których wszystkie wymagają szybkiej iteracji i mają kluczowe znaczenie dla sukcesu Braze platforma jako całość.

Kiedy nie używamy Ruby

Ale poczekaj! Nie wszystko, co robimy w Braze, jest w Ruby. Na przestrzeni lat w kilku miejscach wezwaliśmy z różnych powodów do kierowania rzeczy na inne języki i technologie. Przyjrzyjmy się trzem z nich, aby zapewnić dodatkowy wgląd w to, kiedy opieramy się na Ruby, a kiedy nie.

1. Usługi nadawcy

Jak się okazuje, Ruby nie radzi sobie zbyt dobrze z obsługą bardzo wysokiego stopnia jednoczesnych żądań sieciowych w jednym procesie. Jest to problem, ponieważ gdy Braze wysyła wiadomości w imieniu naszych klientów, niektórzy dostawcy usług końcowych mogą wymagać jednego żądania na użytkownika. Kiedy mamy stos 100 wiadomości gotowych do wysłania, nie chcemy czekać na zakończenie każdej z nich przed przejściem do następnej. Zdecydowanie wolelibyśmy wykonywać całą tę pracę równolegle.

Wpisz nasze „Usługi nadawcy” — czyli bezstanowe mikroserwisy napisane w języku Golang. Nasz kod Rubiego w powyższym przykładzie może wysłać wszystkie 100 wiadomości do jednej z tych usług, która wykona wszystkie żądania równolegle, poczeka na ich zakończenie, a następnie zwróci odpowiedź masową do Rubiego. Te usługi są znacznie bardziej wydajne niż to, co moglibyśmy zrobić z Ruby, jeśli chodzi o współbieżną sieć.

2. Złącza prądowe

Nasza funkcja eksportu dużych ilości danych Braze Currents pozwala klientom Braze na ciągłe przesyłanie strumieniowe danych do jednego lub większej liczby naszych wielu partnerów danych. Platforma jest obsługiwana przez Apache Kafka, a przesyłanie strumieniowe odbywa się za pośrednictwem Kafka Connectors. Technicznie można je napisać w Ruby, ale oficjalnie obsługiwany sposób jest w Javie. A ze względu na wysoki poziom obsługi Javy, napisanie tych konektorów jest dużo łatwiejsze w Javie niż w Ruby.

3. Uczenie maszynowe

Jeśli kiedykolwiek zajmowałeś się uczeniem maszynowym, wiesz, że wybranym językiem jest Python. Liczne pakiety i narzędzia dla obciążeń uczenia maszynowego w Pythonie przyćmiewają równoważną obsługę Rubiego — rzeczy takie jak notatniki TensorFlow i Jupyter są kluczowe dla naszego zespołu, a tego typu narzędzia po prostu nie istnieją lub nie są dobrze ugruntowane w świecie Rubiego. W związku z tym zajęliśmy się Pythonem, jeśli chodzi o tworzenie elementów naszego produktu, które wykorzystują uczenie maszynowe.

Kiedy język ma znaczenie

Oczywiście powyżej mamy kilka świetnych przykładów, w których Ruby nie był idealnym wyborem. Istnieje wiele powodów, dla których możesz wybrać inny język — oto kilka, które naszym zdaniem są szczególnie przydatne do rozważenia.

Budowanie nowych rzeczy bez kosztów zmiany

Jeśli zamierzasz zbudować zupełnie nowy system, z nowym modelem domeny i bez ściśle powiązanej integracji z istniejącą funkcjonalnością, możesz mieć możliwość użycia innego języka, jeśli tak zdecydujesz. Zwłaszcza w przypadkach, gdy Twoja organizacja ocenia różne możliwości, mniejszy, odizolowany projekt od podstaw może być świetnym eksperymentem w świecie rzeczywistym polegającym na wypróbowaniu nowego języka lub frameworka.

Ekosystem językowy i ergonomia specyficzna dla zadania

Niektóre zadania są znacznie prostsze w określonym języku lub frameworku — szczególnie lubimy Railsy i Grape do tworzenia funkcjonalności pulpitu nawigacyjnego, ale kod uczenia maszynowego byłby absolutnym koszmarem do pisania w Ruby, ponieważ narzędzia typu open source po prostu nie istnieją. Możesz chcieć użyć określonego frameworka lub biblioteki, aby zaimplementować jakąś funkcjonalność lub integrację, a czasami będzie to miało wpływ na wybór języka, ponieważ prawie na pewno zaowocuje to łatwiejszym lub szybszym doświadczeniem programistycznym.

Szybkość wykonania

Czasami trzeba zoptymalizować surową szybkość wykonywania, a używany język ma na to duży wpływ. Istnieje dobry powód, dla którego wiele platform handlowych o wysokiej częstotliwości i systemów autonomicznej jazdy jest napisanych w C++; natywnie skompilowany kod może być szalenie szybki! Nasze usługi nadawców wykorzystują prymitywy równoległości/współbieżności Golanga, które po prostu nie są dostępne w Rubim właśnie z tego powodu.

Znajomość programistów

Z drugiej strony możesz budować coś odizolowanego lub masz na myśli bibliotekę, z której chcesz korzystać, ale Twój wybór języka jest całkowicie nieznany reszcie Twojego zespołu. Wprowadzenie nowego projektu w Scali z dużym naciskiem na programowanie funkcjonalne może wprowadzić barierę znajomości dla innych programistów w twoim zespole, co ostatecznie doprowadzi do izolacji wiedzy lub zmniejszenia prędkości netto. Uważamy, że jest to szczególnie ważne w Braze, ponieważ kładziemy duży nacisk na szybką iterację, więc zachęcamy do korzystania z narzędzi, bibliotek, frameworków i języków, które są już szeroko stosowane w organizacji.

Końcowe przemyślenia

Gdybym mógł cofnąć się w czasie i powiedzieć sobie jedną rzecz o inżynierii oprogramowania w gigantycznych systemach, byłoby to tak: w przypadku większości obciążeń ogólne wybory dotyczące architektury określą ograniczenia skalowania i szybkość bardziej niż kiedykolwiek dokonany wybór języka. Ta intuicja jest udowadniana każdego dnia tutaj, w Braze.

Ruby i Rails są niesamowitymi narzędziami, które będąc częścią poprawnie zaprojektowanego systemu, skalują się niesamowicie dobrze. Rails to również bardzo dojrzała platforma, która wspiera naszą kulturę w Braze polegającą na szybkim iterowaniu i wytwarzaniu prawdziwej wartości dla klienta. To sprawia, że ​​Ruby i Rails są dla nas idealnymi narzędziami, narzędziami, z których zamierzamy korzystać przez wiele lat.

Chcesz pracować w Braze? Zatrudniamy różne stanowiska w naszych zespołach inżynieryjnych, zarządzania produktami i User Experience. Sprawdź naszą stronę kariery, aby dowiedzieć się więcej o naszych otwartych rolach i naszej kulturze.