Cache'owanie puli połączeń MySQL poszło nie tak: kulisy błędu 0.001%, który położył produkcję
W skrócie
Poznaj kulisy błędu 0.001% w cache'owaniu puli połączeń MySQL, który po cichu psuł produkcję, i dowiedz się, jak go wykrywać i unikać w backendzie.
Twój backend zwraca puste zestawy wyników dla zapytań, które zawsze zwracają dane. Współczynnik błędów wynosi 0,001% — mniej więcej jedno na 100 000 żądań. Telemetria nic nie pokazuje. Trace'e nic nie pokazują. Jedyne wskazówka, jaką masz, jest taka, że to nigdy nie występuje, gdy cache jest wyłączony.
To dokładnie sytuacja, w jakiej znalazł się zespół Cloudflare Hyperdrive, wprowadzając wsparcie MySQL w swoim poolerze połączeń do ogólnej dostępności. Znaleziony przez nich błąd to mistrzowska lekcja tego, jak cache'owanie puli połączeń MySQL może po cichu się zepsuć, gdy dwie warstwy nie zgadzają się co do formatu bajtów przesyłanych po kablu. Jeśli prowadzisz warstwę bazodanową z poolingiem i cache'em dla swojego backendu gry — a powinieneś — ten tryb awarii dotyczy również Ciebie.
Ten wpis rozkłada na czynniki ten błąd, szczegóły protokołu, które go umożliwiły, jak go wykrywać i naprawiać oraz co oznacza dla architektury Twojego backendu gry.
Dlaczego connection pooling w ogóle istnieje
Nawiązanie TCP + TLS + MySQL handshake z bazą danych wymaga co najmniej pięciu sieciowych round tripów. Jeśli Twój serwer gry otwiera nowe połączenie dla każdego żądania, płacisz ten koszt przy każdym odczycie leaderboardu, pobraniu zapisu gry i sprawdzeniu ekwipunku.
Pooling rozwiązuje ten problem, utrzymując otwarte ciepłe połączenia i wypożyczając je. MySQL jest tu bardziej przyjazny niż Postgres — Postgres tworzy cały proces systemowy na połączenie, podczas gdy połączenia MySQL są stosunkowo tanie — ale popularna gra z tysiącami jednoczesnych graczy może szybko wyczerpać limity połączeń.
Pooling wprowadza jednak własny problem: wyciek stanu sesji. Jeśli zapytanie gracza A ustawi zmienną sesyjną, a gracz B dostanie to samo połączenie, masz błąd poprawności. Poolery rozwiązują to przez czyszczenie połączenia między wypożyczeniami. MySQL udostępnia do tego lekką komendę: COM_RESET_CONNECTION.
I tu właśnie mieszka pierwszy subtelny błąd w tej historii.
Błąd #1: Dryf kodowania po resetach połączenia
Zespół Hyperdrive wynegocjował utf8 w handshake'u przy starcie. To działało — dopóki gracze nie zaczęli używać emoji w czacie i imionach postaci, a dane nie zaczęły wracać zniekształcone.
Problem: utf8 w MySQL to przestarzały alias dla utf8mb3, który obsługuje tylko 1–3 bajty na punkt kodowy Unicode. Pełny UTF-8 (w tym emoji, rozszerzenia CJK i większość niełacińskich skryptów, których użyje Twoja globalna baza graczy) wymaga do 4 bajtów na punkt kodowy. Rozwiązaniem jest wynegocjowanie utf8mb4.
Ale tutaj jest pułapka, na którą wpada prawie każdy:
-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.
COM_RESET_CONNECTION nie przywraca połączenia do zestawu znaków wynegocjowanego przy starcie. Resetuje do domyślnego zestawu znaków serwera — którym na wielu instancjach MySQL wciąż jest latin1. Po pierwszym resecie każde pooled połączenie po cichu degraduje się do kodowania z lat 90.
Remediacja: stosuj ponownie swoją konfigurację sesji po każdym resecie, nie tylko przy starcie:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
Reguła kciuka: traktuj połączenie jako całkowicie nieskonfigurowane po każdym resecie i ponownie ustaw wszystko, na czym Ci zależy. Wszystko, co ustawisz raz przy połączeniu, w końcu wycieknie.
Błąd #2: Błąd pustych odpowiedzi 0.001%
To ten, którego znalezienie zajęło dni. Klient o dużej skali zgłosił niewielki, ale stały odsetek pustych odpowiedzi na zapytania, dla których gwarantowane były niepuste odpowiedzi. Błąd występował w około 0,001% przypadków — zbyt rzadko, by wiarygodnie uchwycić end-to-end trace, zbyt często, by go zignorować.
Zespół przeczesał telemetrię, zwiększył częstość trace'ów, szukał anomalii sieciowych. Nic. Jedyna wskazówka, która rozwiązała zagadkę: błąd występował tylko na instancjach z włączonym cache'owaniem.
Architektura, która to umożliwiła
Hyperdrive używa podzielonej topologii:
- Client node — działa blisko Workera, obsługuje lokalne cache'owanie, przekazuje zapytania dalej.
- Endpoint node — działa w centrum danych najbliższym bazie źródłowej, utrzymuje właściwą pulę połączeń.
Oba węzły używają cache'u CDN lokalnego dla swojego centrum danych. Gdy działają w tym samym centrum danych, współdzielą jeden cache. To współdzielenie to zysk wydajnościowy — ale niesie ukryty warunek wstępny: oba węzły muszą cache'ować dokładnie te same bajty dla tej samej odpowiedzi na zapytanie.
Niedopasowanie protokołu
Protokół klient-serwer MySQL ma fazę połączenia, w której klient i serwer negocjują capabilities — opcjonalne funkcje, takie jak CLIENT_DEPRECATE_EOF, która zmienia sposób kończenia zestawów wyników:
- Bez
CLIENT_DEPRECATE_EOF: pakiet EOF po definicjach kolumn, potem kolejny EOF na końcu zestawu wyników. - Z nim: EOF metadanych jest pomijany, a końcowy EOF staje się pakietem OK.
Hyperdrive wspiera tę capability i negocjuje ją z serwerem MySQL u źródła. Ale biblioteka kliencka po drugiej stronie — na przykład mysql2 dla Node.js, która obecnie nie wspiera CLIENT_DEPRECATE_EOF — może jej nie wspierać. Dlatego Hyperdrive musi tłumaczyć: przepisywać zestawy wyników, aby wstawić pakiety EOF, konwertować pakiety OK z powrotem na EOF, tak aby bajty odpowiadały temu, czego oczekuje sterownik Workera.
Konsekwencja: bajty, które Hyperdrive zapisuje z powrotem do klienta, są w innym formacie niż bajty otrzymywane z serwera źródłowego.
Błędne założenie
Gdy wdrażano cache'owanie po stronie Client dla Postgresa, zespół wbudował założenie: możesz cache'ować dokładnie te bajty, które zapisałeś z powrotem do klienta. Dla protokołu wire Postgresa to była prawda. Dla MySQL — nie.
Teraz złóżmy całą awarię:
- Endpoint obsługuje zapytanie Q, cache'uje bajty odpowiedzi w formacie capability Hyperdrive do współdzielonego cache'u.
- Client (to samo centrum danych) dostaje żądanie dla Q, znajduje wpis w cache i serwuje go Workerowi bez tłumaczenia capabilities — ponieważ bajty wyglądają, jakby były już zapisane dla Workera.
- Sterownik Workera, który nigdy nie negocjował
CLIENT_DEPRECATE_EOF, błędnie parsuje zestaw wyników zakończony OK — co daje puste wyniki, zawieszenia lub desynchronizację protokołu.
Błąd odpala się tylko wtedy, gdy Client i Endpoint są w tej samej lokalizacji oraz jeden czyta wpis w cache drugiego. Dlatego wynosił 0,001% i był niewidoczny dla zwykłego trace'owania. To odpowiednik błędu desync na poziomie protokołu wire — dwa komponenty trzymające różne założenia o współdzielonym stanie. Jeśli kiedykolwiek debugowałeś rozjazd stanu między predykcją kliencką a autorytetem serwera, kształt tej awarii będzie Ci znajomy; to ta sama klasa problemu opisana w naprawianiu problemu z replikacją RPC w Unreal Engine, który psuje Twoje stany, tylko na warstwie protokołu bazy danych.
Naprawa, koncepcyjnie: cache'uj w formacie kanonicznym (tym, co wysłało źródło) i tłumacz do capabilities klienta w momencie odczytu. Nigdy nie cache'uj bajtów po translacji, jeśli translacja zależy od negocjacji per klient.
Runbook: Wykrywanie uszkodzenia cache we własnym stacku
Jeśli prowadzisz pooler lub warstwę cache przed MySQL (albo czymkolwiek ze stanowym protokołem wire), oto jak zastosować ten postmortem.
Wykrywanie
Koreluj błędy z topologią. Błąd pojawiał się tylko na instancjach, gdzie Client i Endpoint współdzieliły centrum danych i cache. Loguj, który węzeł obsłużył każdą odpowiedź i czy pochodziła z cache. Jeśli współczynnik błędów koreluje z trafieniami w cache, podejrzewaj niedopasowanie formatu między zapisującym a czytającym.
Poluj na rzadkie, ale stałe błędy, nie na skoki. Współczynnik błędów 0,001% nigdy nie wyzwala alertów. Skonfiguruj zapytanie w swoim śledzeniu błędów dla powtarzających się wystąpień tej samej sygnatury niezależnie od wolumenu. Stałość przy niskim współczynniku to silniejszy sygnał błędu logicznego niż skok skorelowany z ruchem.
Celowo testuj z asymetrią capabilities. Podłącz jednego klienta testowego z
CLIENT_DEPRECATE_EOFi jednego bez, włącz cache i bombarduj identyczne zapytania. Większość błędów translacji protokołu wychodzi na jaw w ciągu kilku minut przy tej asymetrii.
Remediacja
Kanonicznie zapisuj wpisy w cache. Przechowuj surowy format odpowiedzi źródła; tłumacz przy odczycie. To poprawna naprawa dla każdego poolera, którego klienci mają heterogeniczne capabilities.
Ponownie ustawiaj stan sesji po resetach. Jak pokazano wyżej,
COM_RESET_CONNECTIONprzywraca domyślne ustawienia serwera, a nie Twoje wynegocjowane ustawienia.Wersjonuj klucze cache. Jeśli zmieniasz jakiekolwiek zachowanie protokołu (capabilities, kodowanie, ramowanie odpowiedzi), dodaj wersję formatu do klucza cache, aby stare wpisy nie mogły być serwowane przez nowy kod w trakcie wdrożenia.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
# format_version bumps whenever wire-format handling changes,
# preventing stale-format entries from poisoning new deploys
return sha256(f"{schema_version}:{format_version}:{query_normalized}")
Zapobieganie
Pisz testy zgodności protokołu wire przeciwko dwóm bibliotekom klienckim, nie jednej. Luka
CLIENT_DEPRECATE_EOFbyła widoczna tylko dlatego, że popularny sterownik Node.js jej nie wspierał.Nie zakładaj niczego o „tych samych bajtach" między warstwami. Każdy komponent, który przepisuje wiadomości, nie może zakładać, że jego wyjście jest bajtowo identyczne z wejściem.
Testuj wydajność z włączonym cache od pierwszego dnia. Zespół Hyperdrive walidował niezawodność przez miesiące, zanim wyszedł błąd specyficzny dla cache — bo przecięcie funkcji (pooling × cache'owanie × translacja capabilities) to miejsce, gdzie mieszkają rzadkie błędy.
Pięć najlepszych praktyk dla poolingu i cache'owania w backendach gier
Zawsze negocjuj
utf8mb4, nigdyutf8. Imiona graczy, czat i dane zapisów będą zawierać 4-bajtowe znaki. Następnie ponownie ustawiajSET NAMES utf8mb4po każdymCOM_RESET_CONNECTION, ponieważ reset przywraca domyślne ustawienia serwera, a nie Twój handshake.Cache'uj dane kanoniczne, nie dane sformatowane pod klienta. Jeśli jakakolwiek warstwa przekształca odpowiedź przed wysłaniem, przekształcone bajty nigdy nie mogą trafić do współdzielonego cache. Przechowuj format źródła; tłumacz per czytelnik.
Dodawaj wersje formatu do kluczy cache. Zmiana w jednej linii, chroni Cię przed wpisami z nieaktualnym protokołem podczas wdrożeń kroczących.
Alertuj na stałość błędów, nie tylko na współczynnik błędów. Powtarzająca się sygnatura 0,001% bez korelacji z ruchem to błąd logiczny. Skok skorelowany z ruchem to problem wydajności. Kieruj je różnymi ścieżkami.
Testuj klientów z niedopasowanymi capabilities. Poprawność Twojego poolera zależy od obsługi każdej biblioteki klienckiej, z którą może połączyć się Twój zespół (lub użytkownicy Twojego SDK).
Lekcja z architektury backendu
Najgłębsza lekcja wcale nie dotyczy MySQL. Chodzi o systemy warstwowe z negocjacją per peer. W momencie, gdy wstawiasz middlebox — pooler, proxy, cache — który mówi protokołem po obu stronach, dziedziczysz obowiązek wiernego tłumaczenia oraz cache'owania tylko formatów uniwersalnie ważnych. Postgres działał; MySQL się zepsuł; nic w logice cache'owania nie było nie tak poza jednym założeniem odziedziczonym z innego protokołu.
Dla studia gier to mocny argument przeciwko pisaniu własnej warstwy pooler-plus-cache od zera. Nakład pracy inżynieryjnej na uruchomienie szczęśliwej ścieżki to może tydzień; nakład pracy na dopięcie rzadkich ścieżek — negocjacji capabilities, dryfu kodowania, współlokacji cache — to coś, co zajęło dedykowanemu zespołowi infrastrukturalnemu dni dochodzenia, by wyśledzić pojedynczy błąd 0,001%. Warto powtórzyć własne przyznanie Cloudflare: różnica między betą a GA to „tylko 20 poprawek błędów w płaszczu".
Jeśli Twój backend gry potrzebuje niezawodnej trwałości bez utrzymywania poolera, schematu unieważniania cache i warstwy translacji protokołu, to właśnie tam Backend-as-a-Service pokazuje swoją wartość. horizOn obsługuje przypisane do konta Cloud Save z odczytami i zapisami świadomymi rewizji — Twój klient rozwiązuje konflikty, a Ty omijasz całe pole minowe poprawności pooled-connection-plus-cache dla danych graczy. W połączeniu z leaderboardami i konfiguracją zdalną problemy z latencją zapytań omówione w tym wpisie w większości przestają być Twoim problemem.
Następny krok: zrewiduj dziś konfigurację swojego poolera. Sprawdź trzy rzeczy — wynegocjowany zestaw znaków, czy stan sesji jest ponownie ustawiany po resetach połączenia oraz czy Twój cache przechowuje bajty kanoniczne czy przetłumaczone. Jeśli nie potrafisz odpowiedzieć na wszystkie trzy w dziesięć minut, masz pracę do wykonania — a jeśli wolisz jej nie robić wcale, rzuć okiem na horizOn i sprawdź, czy jego Cloud Save i leaderboardy pokrywają to, co Twoja warstwa bazodanowa robi obecnie ręcznie.
Źródło: Inside Hyperdrive: jak rozwiązaliśmy rzadki błąd cache'owania MySQL w Hyperdrive