Na początek niezwykle ważne było określenie wymagań dla projektu Prima Aprilis, ponieważ musiał on zostać uruchomiony bez „podkręcania”, aby wszyscy użytkownicy Reddita mogli od razu uzyskać do niego dostęp. Gdyby od samego początku nie działało idealnie, raczej nie przyciągnęłoby uwagi wielu osób.
- Należy zapewnić elastyczną konfigurację na wypadek wystąpienia nieoczekiwanych wąskich gardeł lub awarii. Oznacza to, że musisz mieć możliwość dostosowania rozmiaru planszy i dozwolonej częstotliwości rysowania na bieżąco, jeśli ilość danych jest zbyt duża lub częstotliwość aktualizacji jest zbyt wysoka.
„Tablica” powinna mieć wymiary 1000x1000 płytek, aby wyglądała na bardzo dużą.
Wszyscy klienci powinni być zsynchronizowani i wyświetlać ten sam stan karty. W końcu, jeśli mają to różni użytkownicy różne wersje, będzie im trudno nawiązać kontakt.
Musisz obsługiwać co najmniej 100 000 jednoczesnych użytkowników.
Użytkownicy mogą umieszczać jedną płytkę co pięć minut. Dlatego konieczne jest utrzymanie średniej szybkości aktualizacji na poziomie 100 000 płytek na pięć minut (333 aktualizacji na sekundę).
Projekt nie powinien negatywnie wpływać na funkcjonowanie pozostałych części i funkcji obiektu (nawet w przypadku dużego ruchu na r/Place).
Zaplecze
Rozwiązania wdrożeniowe
Główną trudnością przy tworzeniu backendu była synchronizacja wyświetlania statusu tablicy dla wszystkich klientów. Zdecydowano, aby klienci nasłuchiwali w czasie rzeczywistym zdarzeń związanych z rozmieszczeniem płytek i natychmiast sprawdzali stan całej płytki. Posiadanie nieco przestarzałego pełnego stanu jest dopuszczalne, jeśli subskrybujesz aktualizacje przed wygenerowaniem pełnego stanu. Gdy klient otrzyma pełny stan, wyświetla wszystkie otrzymane kafelki w oczekiwaniu; wszystkie kolejne płytki muszą pojawić się na planszy natychmiast po ich otrzymaniu.
Aby ten schemat zadziałał, żądanie pełnego stanu płytki musi zostać zrealizowane tak szybko, jak to możliwe. Na początku chcieliśmy przechowywać całą tablicę w jednym wierszu w Cassandrze i aby każde żądanie po prostu czytało ten wiersz. Format każdej kolumny w tym wierszu był następujący:
(x, y): („znacznik czasu”: epoki, „autor”: nazwa_użytkownika, „kolor”: kolor)
Ale ponieważ plansza zawiera milion płytek, musieliśmy przeczytać milion kolumn. W naszym klastrze produkcyjnym trwało to do 30 sekund, co było niedopuszczalne i mogło skutkować nadmiernym obciążeniem Cassandry.
Następnie postanowiliśmy przechowywać całą płytkę w Redisie. Wzięliśmy pole bitowe miliona czterobitowych liczb, z których każda mogła zakodować czterobitowy kolor, a współrzędne x i y zostały określone przez przesunięcie (offset = x + 1000y) w polu bitowym. Aby uzyskać pełny stan płytki, należało odczytać całe pole bitowe.
Możliwa była aktualizacja płytek poprzez aktualizację wartości w określonych przesunięciach (nie trzeba blokować ani przeprowadzać całej procedury odczytu/aktualizacji/zapisu). Jednak wszystkie szczegóły nadal muszą być przechowywane w Cassandrze, aby użytkownicy mogli dowiedzieć się, kto i kiedy umieścił poszczególne kafelki. Planowaliśmy również użyć Cassandry do przywrócenia planszy po awarii Redis. Odczytanie z niego całej płytki zajęło niecałe 100 ms, czyli było dość szybko.
Oto jak przechowywaliśmy kolory w Redis na przykładzie tablicy 2x2:
Martwiliśmy się, że możemy napotkać przepustowość odczytu w Redis. Jeśli wielu klientów podłączyło się lub zaktualizowało w tym samym czasie, wszyscy jednocześnie wysyłaliby żądania pełnego stanu tablicy. Ponieważ tablica reprezentowała współdzielony stan globalny, oczywistym rozwiązaniem było użycie buforowania. Zdecydowaliśmy się na buforowanie na poziomie CDN (Fastly), ponieważ było to łatwiejsze w implementacji, a pamięć podręczną uzyskiwano najbliżej klientów, co skracało czas otrzymania odpowiedzi.
Żądania dotyczące stanu pełnego wyżywienia były buforowane przez Fastly z limitem czasu wynoszącym sekundę. Aby zapobiec duża liczbażądań po upływie limitu czasu, użyliśmy nagłówka nieaktualne podczas ponownego sprawdzania poprawności. Fastly obsługuje około 33 punktów POP, które niezależnie buforują się nawzajem, dlatego spodziewaliśmy się otrzymać do 33 żądań stanu pełnego wyżywienie na sekundę.
Aby opublikować aktualizacje dla wszystkich klientów, skorzystaliśmy z naszej usługi websocket. Wcześniej z powodzeniem używaliśmy go do obsługi Reddit.Live z ponad 100 000 jednoczesnymi użytkownikami w celu otrzymywania powiadomień o prywatnych wiadomościach na żywo i innych funkcji. Obsługa również była kamień węgielny nasze poprzednie primaaprilisowe projekty - The Button i Robin. W przypadku r/Place klienci obsługiwali połączenia za pośrednictwem protokołu internetowego, aby otrzymywać w czasie rzeczywistym aktualizacje dotyczące rozmieszczenia kafelków.
API
Uzyskanie stanu pełnego wyżywienia
Początkowo prośby kierowano do Fastly. Gdyby miał ważny egzemplarz płytki, natychmiast by ją zwrócił, bez konieczności kontaktowania się z serwerami aplikacji Reddita. Jeśli nie lub kopia była za stara, aplikacja Reddit odczytała pełne wyżywienie z Redis i zwrócił go do Fastly, aby mógł go buforować i zwrócić klientowi.
Należy pamiętać, że liczba żądań nigdy nie osiągnęła 33 na sekundę, co oznacza, że buforowanie w Fastly było bardzo duże Skuteczne środki Ochrona aplikacji Reddit przed większością żądań.
A kiedy żądania dotarły do aplikacji, Redis zareagował bardzo szybko.
Rysowanie płytki
Etapy rysowania płytki:
- Znacznik czasu ostatniego umieszczenia kafelka przez użytkownika jest odczytywany z Cassandry. Jeżeli było to mniej niż pięć minut temu, to nic nie robimy i do użytkownika zwracany jest błąd.
- Szczegóły kafelka są zapisywane Redisowi i Cassandrze.
- Aktualny czas jest rejestrowany w Cassandrze jako ostatni moment ułożenia płytki przez użytkownika.
- Usługa websocket wysyła wiadomość do wszystkich podłączonych klientów o nowym kafelku.
Aby zachować ścisłą spójność, wszystkie zapisy i odczyty w Cassandrze zostały wykonane przy użyciu warstwy spójności QUORUM.
W rzeczywistości mieliśmy tutaj wyścig, w którym użytkownicy mogli umieszczać wiele płytek na raz. W etapach 1–3 nie było blokowania, więc jednoczesne próby losowania płytek mogły przejść kontrolę w pierwszym etapie i zostać wylosowane w drugim. Wygląda na to, że niektórzy użytkownicy odkryli ten błąd (lub użyli botów, które zignorowały ograniczenie częstotliwości żądań) – w rezultacie przy jego użyciu ułożono około 15 000 płytek (~0,09% całości).
Wskaźniki żądań i czasy odpowiedzi mierzone przez aplikację Reddit:
Szczytowa szybkość umieszczania płytek wynosiła prawie 200 na sekundę. To mniej niż nasz szacunkowy limit 333 płytek na sekundę (średnia przy założeniu, że 100 000 użytkowników umieszcza płytki co pięć minut).
Uzyskiwanie szczegółów dotyczących konkretnego kafelka
Przy żądaniu konkretnych płytek dane były odczytywane bezpośrednio z Cassandry.
Wskaźniki żądań i czasy odpowiedzi mierzone przez aplikację Reddit:
Ta prośba okazała się bardzo popularna. Oprócz zwykłych próśb klientów, ludzie napisali skrypty pobierające całą tablicę, po jednym kafelku na raz. Ponieważ to żądanie nie zostało zapisane w pamięci podręcznej w CDN, wszystkie żądania zostały obsłużone przez aplikację Reddit.
Czas reakcji na te prośby był dość krótki i utrzymywał się na tym samym poziomie przez cały okres trwania projektu.
Gniazda internetowe
Nie mamy indywidualnych wskaźników pokazujących, jak r/Place wpłynął na usługę websocket. Ale możemy oszacować wartości, porównując dane przed rozpoczęciem projektu i po jego zakończeniu.
Całkowita liczba połączeń z usługą websocket:
Podstawowe obciążenie przed uruchomieniem r/Place wynosiło około 20 000 połączeń, szczytowe 100 000 połączeń. Zatem w szczytowym okresie prawdopodobnie do r/Place podłączonych było jednocześnie około 80 000 użytkowników.
Przepustowość usługi Websocket:
W szczytowym momencie obciążenia r/Place usługa websocket przesyłała ponad 4 Gb/s (150 Mb/s na instancję, łącznie 24 instancje).
Frontend: klienci webowi i mobilni
W procesie tworzenia frontendu dla Place musieliśmy rozwiązać wiele skomplikowanych problemów związanych z rozwojem międzyplatformowym. Chcieliśmy, aby projekt działał tak samo na wszystkich głównych platformach, w tym na komputerach stacjonarnych i urządzeniach mobilnych z systemami iOS i Android.
Interfejs użytkownika musiał spełniać trzy ważne funkcje:
- Wyświetlaj status tablicy w czasie rzeczywistym.
- Pozwól użytkownikom na interakcję z tablicą.
- Pracuj na wszystkich platformach, w tym w aplikacjach mobilnych.
Głównym obiektem interfejsu był canvas, a API Canvas nadawało się do tego idealnie. Wykorzystaliśmy element
Rysowanie płótna
Płótno musiało odzwierciedlać stan tablicy w czasie rzeczywistym. Konieczne było narysowanie całej planszy po załadowaniu strony i dokończenie rysowania aktualizacji przychodzących przez websockety. Element canvas korzystający z interfejsu CanvasRenderingContext2D można zaktualizować na trzy sposoby:
- Narysuj istniejący obraz na płótnie za pomocą funkcji DrawImage() .
- Rysuj kształty za pomocą różne metody formy rysunkowe. Na przykład fillRect() wypełnia prostokąt kolorem.
- Skonstruuj obiekt ImageData i narysuj go na płótnie za pomocą putImageData() .
Pierwsza opcja nam nie odpowiadała, gdyż nie mieliśmy planszy w postaci gotowego obrazu. Pozostały opcje 2 i 3. Najprostszym sposobem była aktualizacja poszczególnych kafelków za pomocą fillRect(): kiedy aktualizacja nadchodzi przez websocket, po prostu rysujemy prostokąt 1x1 w pozycji (x, y). Ogólnie metoda działała, ale nie była zbyt wygodna do rysowania stanu początkowego płytki. Metoda putImageData() była znacznie lepsza: mogliśmy określić kolor każdego piksela w pojedynczym obiekcie ImageData i narysować od razu całe płótno.
Rysowanie stanu początkowego płytki
Użycie metody putImageData() wymaga zdefiniowania stanu płytki jako Uint8ClampedArray , gdzie każda wartość jest ośmiobitową liczbą bez znaku z zakresu od 0 do 255. Każda wartość reprezentuje kanał koloru (czerwony, zielony, niebieski, alfa), a każda piksel wymaga czterech elementów w tablicy. Płótno 2x2 wymaga 16-bajtowej tablicy, w której pierwsze cztery bajty reprezentują lewy górny piksel płótna, a ostatnie cztery reprezentują prawy dolny piksel.
Oto jak piksele canvas są powiązane z ich reprezentacjami Uint8ClampedArray:
Na kanwę naszego projektu potrzebowaliśmy tablicy czterech milionów bajtów - 4 MB.
W backendzie stan płytki jest przechowywany jako czterobitowe pole bitowe. Każdy kolor jest reprezentowany przez liczbę od 0 do 15, co pozwoliło nam upakować dwa piksele w każdym bajcie. Aby użyć tego na urządzeniu klienckim, musisz wykonać trzy rzeczy:
- Prześlij dane binarne z naszego API do klienta.
- Rozpakuj dane.
- Konwertuj kolory czterobitowe na 32-bitowe.
Do przesyłania danych binarnych wykorzystaliśmy Fetch API w przeglądarkach, które je obsługują. A w tych, które nie obsługują, używaliśmy Żądanie XMLHttp z typem odpowiedzi ustawionym na „arraybuffer”.
Dane binarne otrzymane z interfejsu API zawierają dwa piksele w każdym bajcie. Najmniejszy konstruktor TypedArray, jaki mieliśmy, pozwala na pracę z danymi binarnymi w postaci jednostek jednobajtowych. Trudno jest jednak z nich korzystać na urządzeniach klienckich, dlatego rozpakowaliśmy dane, aby ułatwić pracę z nimi. Proces jest prosty: iterowaliśmy po spakowanych danych, wyciągaliśmy bity wyższego i niższego rzędu, a następnie kopiowaliśmy je do pojedynczych bajtów do innej tablicy.
Wreszcie kolory czterobitowe musiały zostać przekonwertowane na kolory 32-bitowe.
Wymaga tego struktura ImageData, której potrzebowaliśmy do użycia metody putImageData(). ostateczny wynik miał postać tablicy Uint8ClampedArray z bajtami kodującymi kanały kolorów w kolejności RGBA. Oznacza to, że musieliśmy przeprowadzić kolejną dekompresję, dzieląc każdy kolor na bajty kanału składowego i umieszczając je we właściwym indeksie. Wykonywanie czterech zapisów na piksel nie jest zbyt wygodne. Ale na szczęście była inna opcja.
Obiekty TypedArray są zasadniczo reprezentacjami tablicowymi ArrayBuffer. Jest tu jedno zastrzeżenie: wiele instancji TypedArray może czytać i zapisywać w tej samej instancji ArrayBuffer. Zamiast nagrywać cztery wartości w tablicy ośmiobitowej możemy zapisać jedną wartość w 32-bitowej! Używając Uint32Array do zapisu, mogliśmy łatwo aktualizować kolory kafelków, po prostu aktualizując jeden indeks tablicy. Musieliśmy jednak przechowywać naszą paletę kolorów w kolejności dużych bajtów (ABGR), aby bajty automatycznie trafiały we właściwe miejsca podczas odczytu za pomocą Uint8ClampedArray .
Przetwarzanie aktualizacji otrzymanych przez websocket
Metoda remisRect() była dobra do rysowania aktualizacji poszczególnych pikseli w miarę ich otrzymywania, ale miała jedną wadę: duże partie aktualizacji przychodzących jednocześnie mogły prowadzić do spowolnienia przeglądarek. Rozumieliśmy też, że aktualizacje statusu tablicy mogą pojawiać się bardzo często, więc problem musiał zostać w jakiś sposób rozwiązany.
Zamiast natychmiastowo przerysowywać płótno za każdym razem, gdy aktualizacja jest odbierana przez websocket, zdecydowaliśmy się zrobić to tak, aby aktualizacje websocket, które docierają w tym samym czasie, mogły być grupowane i renderowane masowo. Aby to osiągnąć, wprowadzono dwie zmiany:
- Przestań używać funkcji DrawRect() - znaleźliśmy wygodnym sposobem aktualizuj wiele pikseli na raz za pomocą putImageData() .
- Przesyłanie renderowania płótna do pętli requestAnimationFrame.
Przenosząc renderowanie do pętli animacji, mogliśmy natychmiast zapisać aktualizacje protokołu internetowego w buforze ArrayBuffer, odraczając jednocześnie faktyczne renderowanie. Wszystkie aktualizacje protokołu websocket docierające pomiędzy ramkami (około 16 ms) zostały wsadowe i renderowane jednocześnie. Dzięki zastosowaniu requestAnimationFrame, jeśli renderowanie trwałoby zbyt długo (ponad 16 ms), wpłynęłoby to jedynie na częstotliwość odświeżania canvasu (a nie pogorszyłoby wydajność całej przeglądarki).
Interakcja z płótnem
Warto zaznaczyć, że canvas był potrzebny, aby ułatwić użytkownikom interakcję z systemem. Głównym scenariuszem interakcji jest rozmieszczenie płytek na płótnie.
Jednak dokładne renderowanie każdego piksela w skali 1:1 byłoby niezwykle trudne i nie uniknęlibyśmy błędów. Potrzebowaliśmy więc (dużego!) zoomu. Dodatkowo użytkownicy musieli mieć możliwość łatwego poruszania się po obszarze roboczym, ponieważ był on zbyt duży dla większości ekranów (szczególnie przy korzystaniu z zoomu).
Powiększenie
Ponieważ użytkownicy mogli umieszczać płytki raz na pięć minut, błędy w ich umieszczaniu byłyby dla nich szczególnie frustrujące. Należało zastosować powiększenie o takim współczynniku, aby płytka była odpowiednio duża i można było ją łatwo włożyć Właściwe miejsce. Było to szczególnie ważne w przypadku urządzeń z ekranem dotykowym.
Zaimplementowaliśmy zoom 40x, czyli każda płytka miała rozmiar 40x40. Okleiliśmy element