Kamil Owczarek
Opublikowano

Zastąpiliśmy Redisa PostgreSQL-em w cache'owaniu — oto co z tego wyszło

Autorzy

Setup, którego nikt nie kwestionuje

Redis to domyślna odpowiedź na pytanie "gdzie mamy to cache'ować?". Jest szybki, sprawdzony w boju i każdy framework ma do niego sterownik. Używaliśmy go dokładnie tak, jak sugeruje dokumentacja — zarządzana instancja na DigitalOcean (15 dolarów miesięcznie) siedząca między naszymi czterema aplikacjami a bazą danych, przechowująca cache'owane odpowiedzi API, rozproszone locki i sygnały do inwalidacji cache.

Nasza platforma e-commerce składa się z czterech osobnych aplikacji: sklepu dla klientów, API B2B serwującego katalogi produktów, kolektora danych synchronizującego stany magazynowe z systemów nadrzędnych oraz strony marketingowej naszego studia projektowego. Wszystkie cztery łączyły się z tą samą instancją Redisa. Każda odpowiedź API lądowała w cache jako skompresowany JSON z trzydniowym TTL-em. Rozproszone locki zapobiegały równoległym przebudowom cache. Sygnały inwalidacji koordynowały świeżość cache we wszystkich czterech aplikacjach, gdy dane produktowe zmieniały się w systemie źródłowym.

Przez wiele miesięcy działało dokładnie tak, jak powinno. Czasy odpowiedzi były stabilne, cache hit rate wysoki, a 15 dolarów miesięcznie wydawało się okazją za tę infrastrukturalną prostotę.

A potem zaczęły się urywać połączenia TLS.

Kiedy Twój cache sam staje się problemem

Pierwsze objawy były subtelne — sporadyczne cache missy tam, gdzie powinny być hity. Kilka dodatkowych zapytań do bazy w szczycie ruchu. Nic alarmującego, dopóki nie zajrzeliśmy do logów błędów.

Połączenia do Redisa okresowo padały z błędami TLS handshake. Nie regularnie, nie przewidywalnie — po prostu na tyle często, żeby warstwa cache'owania przestała być wiarygodna. W złym momencie wszystkie cztery aplikacje traciły cache jednocześnie, waląc w bazę zapytaniami, które powinien obsłużyć Redis.

Wzorzec awarii był wyjątkowo podstępny. Kiedy Redis padał, każde kolejne żądanie stawało się cache missem. Każdy miss uruchamiał pełne zapytanie do bazy i zapis do cache. Wiele równoległych zapisów walczyło o locki, które też trzymaliśmy w Redisie. System, który miał chronić bazę przed skokami ruchu, sam te skoki generował.

Spędziliśmy dwa dni na próbach utwardzenia konfiguracji Redisa:

PróbaCo zmieniliśmyCo się stało
Wyłączenie offline queueSzybkie padanie zamiast kolejkowania żądańSzybsze awarie, ale nadal awarie
Zero retry na żądanieZapobieganie burzom retry podczas awariiTe same dropy, tylko mniej retry na drop
Jawna konfiguracja TLSWymuszenie inicjalizacji TLS (bug w parsowaniu URL-a w ioredis)Dropy szły dalej, bez zmian
Connection keepAlive (10 s)Zapobieganie wygasaniu bezczynnych połączeńCofnięte tego samego dnia — zero poprawy

Każda zmiana adresowała jakąś prawdopodobną przyczynę. Żadna nie naprawiła faktycznego problemu. Dropy TLS działy się na poziomie zarządzanej infrastruktury, gdzieś między usługą Redisa w DigitalOcean a naszym serverlessowym hostingiem. Mogliśmy konfigurować klienta, jak nam się żywnie podobało — sama rura była nieszczelna.

Po dwóch dniach dłubania w configu przyjęliśmy do wiadomości niewygodną prawdę: nie naprawimy Redisa. Musimy go wymienić.

Oczywista alternatywa (która już u nas chodziła)

Nasza baza PostgreSQL działała na Neonie — serverlessowym dostawcy Postgresa, za którego i tak płaciliśmy. Obsługiwała wszystkie dane aplikacji: produkty, użytkowników, zamówienia, tłumaczenia, wszystko. Przez miesiące produkcyjnego użycia nie urwała ani jednego połączenia.

Pytanie było proste: czy PostgreSQL sprawdzi się jako backend cache? Nie do wszystkiego, co robi Redis — nie potrzebowaliśmy pub/sub ani streamów. Tylko trzech rzeczy:

  1. Przechowywania klucz-wartość z wygasaniem po TTL (cache'owane odpowiedzi API)
  2. Rozproszonych locków (blokada równoległych przebudów cache)
  3. Sygnałów inwalidacji (koordynacja świeżości cache między aplikacjami)

Wszystkie trzy to operacje na tabelach. Wpis cache to wiersz z kluczem, wartością i znacznikiem wygaśnięcia. Lock to atomowy insert. Sygnał inwalidacji to upsert do tabeli koordynacyjnej.

Prawdziwe pytanie nie brzmiało "czy PostgreSQL to potrafi?" — tylko "czy potrafi wystarczająco szybko?".

Benchmark, który omal nas nie zmylił

Zanim cokolwiek ruszyliśmy na produkcji, zbudowaliśmy endpoint benchmarkowy. Korzystał z prawdziwych payloadów katalogu produktów — dokładnie tych obiektów JSON, które faktycznie trafiałyby do cache. Puściliśmy go i na Redisie, i na naszej instancji PostgreSQL w Neonie, porównując latencje odczytu i zapisu.

Wyniki wyglądały obiecująco:

OperacjaRedisPostgreSQL (Neon)Różnica
Odczyt z cache (ciepły)~3 ms~5 ms1,6x wolniej
Zapis do cache (ciepły)~4 ms~6 ms1,5x wolniej

Spowolnienie odczytów 1,6x? W systemie, który okresowo padał w całości? To brzmiało jak świetny deal. Wdrożyliśmy cache na PostgreSQL w aplikacji studia projektowego — najmniej ruchliwej z całej czwórki, naszym kanarku.

Wyniki produkcyjne w ciągu pierwszej godziny:

OperacjaRedisPostgreSQL (Neon)Różnica
Odczyt z cache (produkcja)~60 ms~400 ms6,7x wolniej

Cofnęliśmy to w ciągu kilku godzin.

Benchmark działał na ciepłych połączeniach do bazy — Prisma ORM utrzymywał pulę połączeń, która była już nawiązana. Na produkcji, na platformie serverless, każde żądanie mogło trafić na zimne połączenie. Narzut nawiązania połączenia w ORM-ie, parsowanie zapytania i round trip po sieci do serverlessowego endpointu Neona złożyły się na coś dramatycznie wolniejszego, niż sugerował nasz kontrolowany test.

Wniosek: benchmarki na ciepłych połączeniach w serverless to fikcja. Twój benchmark musi odtwarzać warunki produkcyjne — zimne połączenia, zmienną latencję sieci, realne wzorce współbieżności. Różnica 3 ms kontra 5 ms w kontrolowanym teście nie powiedziała nam nic o rzeczywistości 60 ms kontra 400 ms.

Prawie zupełnie odpuściliśmy podejście z PostgreSQL. Ale przyjrzeliśmy się dokładniej temu, co właściwie cache'ujemy, i zrozumieliśmy, że nie wszystkie operacje na cache są sobie równe.

Podział problemu na dwie części

Nasze użycie Redisa dzieliło się na dwie wyraźne kategorie o bardzo różnych wzorcach dostępu:

Kategoria 1: sygnały inwalidacji. Małe zapisy (pojedynczy timestamp), rzadkie (wyzwalane przez synchronizacje danych źródłowych), odczytywane przez wszystkie aplikacje przy decyzji, czy cache'owane dane są nieświeże. To robota w sam raz dla bazy — małe wiersze, indeksowane lookupy, a gwarancje transakcyjne liczą się bardziej niż surowa szybkość.

Kategoria 2: cache'owane odpowiedzi API. Duże bloby JSON (od 50 KB do 2 MB), zapisywane przy cache missie, odczytywane przy każdym żądaniu. Tu Redis tradycyjnie błyszczy — szybkie lookupy klucz-wartość przy minimalnym narzucie.

Produkcyjna latencja 400 ms brała się głównie z kategorii 2 — odczytów dużych wartości przez Prisma ORM na zimnych połączeniach. Ale kategoria 1 to były znikome ilości danych, z którymi nasza baza radziła sobie bez najmniejszego wysiłku.

Podzieliliśmy więc migrację na dwie fazy.

Faza 1: przeniesienie sygnałów inwalidacji do PostgreSQL. Mieliśmy już w bazie tabelę Synchronization do śledzenia znaczników synchronizacji danych. Sygnały inwalidacji — "produkty się zmieniły, wywal cache" — wpasowały się w nią naturalnie. Jeden upsert do bazy zastąpił zapis do Redisa, który wcześniej potrzebował pięciu prób, żeby zagwarantować dostarczenie.

Poprawa była natychmiastowa i mierzalna. Nasz kolektor danych chodził w pętlach retry, bo zapisy do Redisa podczas dropów TLS po cichu padały, zostawiając aplikacje z nieświeżymi danymi w nieskończoność. Przy PostgreSQL zapis albo się udawał (a udawał się zawsze), albo rzucał błędem, który mogliśmy obsłużyć. Jeden zapis. Jeden round trip. 100% gwarancji dostarczenia.

Faza 2: przeniesienie cache'owanych odpowiedzi do PostgreSQL. To była ta część, która poległa na 400 ms. Ale po fazie 1 rozumieliśmy problem lepiej. Rozwiązaniem okazała się dedykowana tabela Cache zoptymalizowana pod lookupy po kluczu głównym:

  • Klucz: klucz cache (string, klucz główny)
  • Wartość: cache'owany JSON (text)
  • Wygaśnięcie: opcjonalny timestamp (zaindeksowany pod zapytania czyszczące)

Lookupy po kluczu głównym w PostgreSQL są szybkie — indeksowane pobrania pojedynczego wiersza. Te 400 ms z pierwszego podejścia to był narzut połączenia Prismy przy zimnych startach, a nie samo zapytanie. Dla kolejnych żądań w obrębie tej samej instancji serverless odczyty konsekwentnie schodziły poniżej 10 ms.

Dzień, w którym pociągnęliśmy za spust

27 marca 2026. Wszystkie cztery aplikacje przeszły na cache'owanie w PostgreSQL jednego dnia, wdrażane po kolei, od najmniejszego do największego ryzyka:

1. Collector app (data sync)     ─ lowest risk, no user traffic
2. B2B API (external clients)    ─ isolated, own caching layer
3. Design studio (marketing)     ─ low traffic, already tested
4. Main storefront (everything)  ─ highest risk, deployed last

Studio projektowe dostarczyło nam najbardziej dramatycznego momentu. Wdrożyliśmy, monitorowaliśmy, zobaczyliśmy w logach coś nieoczekiwanego, cofnęliśmy w ciągu kilku minut, zdiagnozowaliśmy problem, naprawiliśmy i wdrożyliśmy ponownie z sukcesem — wszystko w pięć godzin. To możliwość natychmiastowego cofnięcia zmiany pozwoliła na tak agresywne tempo. Każda aplikacja była niezależnym deployem z własną konfiguracją cache. Gdyby jedna padła, pozostałe działały dalej.

Przy okazji migracji wprowadziliśmy trzy uproszczenia, których pierwotnie nie planowaliśmy:

Wyrzuciliśmy kompresję gzip w całości. Wcześniej włożyliśmy sporo wysiłku w zbudowanie warstwy kompresji dla Redisa — gzipowanie każdej cache'owanej odpowiedzi, żeby zmieścić więcej wpisów w limicie 1 GB pamięci. Przy PostgreSQL storage nie jest ograniczony pamięcią. Surowy JSON zapisany jako text. Protokół sieciowy PostgreSQL sam zajmuje się kompresją w transporcie. Usunęliśmy kod kompresji, kod dekompresji, kodowanie base64 i całą obsługę błędów wokół tego.

Skonsolidowaliśmy cztery pliki narzędziowe w jedną wspólną paczkę. Każda aplikacja miała własną kopię funkcji pomocniczych do Redisa — bezpieczne wrappery na get, set, lock i unlock z obsługą błędów i logiką retry. Zastąpiliśmy wszystkie cztery jednym zestawem helperów w naszej wspólnej paczce bazodanowej. Te same funkcje, jedno miejsce, spójne zachowanie.

Usunęliśmy całą konfigurację Redisa. URL-e połączeń, ustawienia TLS, timeouty, polityki retry, ustawienia offline queue, interwały keepAlive — każda linia konfiguracji Redisa zniknęła z plików konfiguracyjnych każdej aplikacji.

Po udanym wdrożeniu głównego sklepu dorzuciliśmy jeszcze jedną optymalizację: endpointy serwujące dane względnie statyczne (filtry produktów, dostępne wykończenia, strefy montażowe) całkowicie pomijają teraz sprawdzanie sygnału inwalidacji i polegają wyłącznie na wygasaniu po TTL. Oszczędza to jedno zapytanie do bazy na żądanie dla tych endpointów — odświeżają się raz dziennie, niezależnie od zmian w systemie źródłowym.

Liczby, które naprawdę mają znaczenie

Po dziesięciu dniach na produkcji ze wszystkimi czterema aplikacjami na cache'owaniu w PostgreSQL:

MetrykaRedis (przed)PostgreSQL (po)Efekt
Czas odpowiedzi endpointu API130-160 ms130-160 msBez zmian
Czas renderowania strony SSRStabilnyStabilnyBez zmian
Latencja odczytu z cache (ciepły)~3 ms~5 ms+2 ms (bez znaczenia na poziomie strony)
Dropy połączeń TLSOkresowe (co tydzień)ZeroWyeliminowane
Dostarczanie sygnałów inwalidacji~95% (potrzebne retry)100%Gwarantowane
Miesięczny koszt infrastruktury15 dolarów0 dolarów (istniejąca baza)-15 dolarów miesięcznie
Pliki narzędziowe cache4 (po jednym na aplikację)1 (wspólna paczka)-75% kodu
Narzut CPU na kompresję3-5 ms na odczyt/zapis0 msWyeliminowany
Łączny czas migracji3 dni (1 dzień aktywnej pracy)

Nagłówkiem nie jest oszczędność 15 dolarów miesięcznie — to błąd zaokrąglenia. Nagłówkiem jest zero dropów TLS i 100% dostarczonych inwalidacji.

Przed migracją żyliśmy z warstwą cache'owania, która okresowo znikała, zostawiając wszystkie cztery aplikacje z zapytaniami prosto do bazy. Zbudowaliśmy logikę retry, łagodną degradację, utwardzanie połączeń — warstwy złożoności, żeby obejść niestabilną zależność. Cała ta złożoność jest teraz usunięta.

Wzrost o 2 ms przy ciepłych odczytach z cache jest technicznie mierzalny, ale praktycznie niewidoczny. Strona, która renderuje się w 130 ms, nie sprawia innego wrażenia przy 132 ms. A te 2 ms mają znaczenie tylko dla ciepłych połączeń — przy zimnych startach w serverless różnica w nawiązywaniu połączenia między Redisem a PostgreSQL się zeruje.

Co powinniśmy byli zrobić inaczej

Nie benchmarkuj na ciepłych połączeniach w serverless. Nasz pierwszy benchmark dał nam fałszywą pewność, która doprowadziła do awarii na produkcji, kilku godzin debugowania i cofnięcia zmiany. Gdybyśmy od początku testowali na zimnych połączeniach, natychmiast zidentyfikowalibyśmy narzut ORM-a i zaprojektowali rozwiązanie z tym na uwadze.

Migruj sygnały przed storage'em. Sygnały inwalidacji były czystą, niskoryzykowną migracją, która od razu dała poprawę niezawodności. Powinniśmy byli przenieść je miesiące wcześniej — nie miały nic wspólnego z pytaniem o storage cache i niezależnie rozwiązałyby nasz najgorszy problem z niezawodnością (ciche porażki inwalidacji).

Nie utwardzaj serwisu, który zaraz usuniesz. Spędziliśmy dwa dni na dłubaniu w parametrach konfiguracji Redisa, które później i tak skasowaliśmy w całości. Ten czas lepiej byłoby przeznaczyć na plan migracji. Koszt utopiony w debugowanie padającego serwisu potrafi opóźnić decyzję o jego wymianie.

Kwestionuj własne optymalizacje. Wcześniej włożyliśmy sporo wysiłku w kompresję cache — budowanie, testowanie i utrzymywanie gzipa, żeby zmieścić więcej wpisów w ograniczonej pamięci Redisa. Przy przejściu na PostgreSQL wyrzuciliśmy kompresję w całości. Ta optymalizacja była specyficzna dla modelu pamięciowego Redisa. Inny storage, inne ograniczenia, inne odpowiedzi.

Kiedy to podejście ma sens

PostgreSQL jako cache sprawdza się, gdy:

  • Bazę i tak już masz. Krańcowy koszt tabeli Cache w istniejącej bazie jest praktycznie zerowy. Żadnego nowego serwisu do utrzymania, monitorowania i opłacania.
  • Cache'owane dane to JSON. PostgreSQL wydajnie zapisuje i odczytuje tekst. Lookupy po kluczu głównym na kolumnie tekstowej są szybkie.
  • Niezawodność liczy się bardziej niż surowa szybkość. Jeśli alternatywą jest "czasem 3 ms, a czasem całkowita niedostępność", to "konsekwentnie 5 ms" jest poważnym awansem.
  • Jesteś na serverless. Różnice w pulowaniu połączeń między Redisem a PostgreSQL mają na serverless mniejsze znaczenie niż na trwałych serwerach, bo oba mierzą się z narzutem zimnego startu.

PostgreSQL jako cache słabo się sprawdza, gdy:

  • Latencja poniżej milisekundy to twarde wymaganie. Redis jest naprawdę szybszy w lookupach na gorącej ścieżce. Jeśli Twoja aplikacja nie zniesie 5 ms na odczyt z cache, PostgreSQL nie jest odpowiedzią.
  • Potrzebujesz pub/sub albo funkcji real-time. Redis Pub/Sub, Streams i Sorted Sets to struktury danych zaprojektowane pod konkretne zadania. PostgreSQL potrafi część z nich przybliżyć, ale nie zrobi tego wydajnie.
  • Wolumen zapisów jest bardzo duży. Tysiące zapisów do cache na sekundę obciążą instancję PostgreSQL zupełnie inaczej niż Redisa. Przy cache'owaniu zdominowanym przez zapisy model in-memory Redisa ma wyraźną przewagę.

Pytanie, którego nikt nie zadaje

Rozmowa o backendach do cache'owania zwykle zaczyna się od "który jest szybszy?". Dla większości zespołów to złe pytanie.

Właściwe brzmi: "Czy jest wystarczająco szybki i czy będzie dostępny, kiedy będę go potrzebować?"

Redis jest szybszy. Nikt tego nie kwestionuje. Ale przy naszym obciążeniu — odpowiedzi API cache'owane jako JSON, odczytywane kilka tysięcy razy dziennie, odświeżane przy zmianach danych źródłowych — różnica między 3 ms a 5 ms jest niewidoczna dla każdego użytkownika i bez znaczenia dla każdej metryki biznesowej.

Niewidoczne nie było natomiast znikanie całej warstwy cache'owania podczas dropów TLS. Bez znaczenia nie były ciche porażki inwalidacji zostawiające klientów ze starymi danymi produktowymi. Nie do zaakceptowania było budowanie kolejnych warstw retry i łagodnej degradacji, żeby kompensować niestabilną infrastrukturę.

Zastąpiliśmy to wszystko jedną tabelą w bazie, którą i tak mieliśmy. Ta sama wydajność tam, gdzie ma znaczenie. Idealna niezawodność tam, gdzie wcześniej jej nie było. O jeden serwis mniej na diagramie architektury.

Czasem najlepsze rozwiązanie do cache'owania to nie to najszybsze — tylko to, które faktycznie działa, kiedy go potrzebujesz.