Wstęp

Cache'owanie wygląda jak cheat w grze. Pobierasz coś kosztownego — odpowiedź z sieci, wynik zapytania do bazy, zdekodowany obrazek — i odkładasz to w szybkim miejscu, żeby nie płacić tej ceny drugi raz. Brzmi świetnie, prawda? I jest świetnie. Do czasu. Do czasu, aż cache zacznie serwować nieaktualne dane, a użytkownik zobaczy wczorajsze zdjęcie profilowe. Aż cache zje tyle pamięci, że system zabije twoją aplikację. Aż spędzisz tydzień na debugowaniu problemu, który okaże się wartością w cache'u, którą należało unieważnić trzy wywołania API temu. W informatyce krąży słynne powiedzenie: są tylko dwie trudne rzeczy — unieważnianie cache'u (cache invalidation) i nazywanie rzeczy. Dziś zrozumiemy, dlaczego cache'owanie jest jednocześnie niesamowicie przydatne i niesamowicie niebezpieczne. Zobaczymy, jak naprawdę działa cache, jak zarządzają nim biblioteki do ładowania obrazów w Androidzie i jak wybrać właściwą strategię cache'owania, nie strzelając sobie w stopę.

Czym właściwie jest cache

W swojej istocie cache to wymiana: płacisz pamięcią, żeby zyskać czas. Na tym polega podstawowa umowa. Zamiast za każdym razem wykonywać kosztowną operację — sięgać do sieci, czytać z dysku, dekodować bitmapę — robisz to raz, zapisujesz wynik w szybszym miejscu i przy kolejnych żądaniach serwujesz go stamtąd. Kluczowe jest to, że cache wykorzystuje zjawisko zwane lokalnością czasową (temporal locality): jeśli coś było używane niedawno, prawdopodobnie wkrótce znów będzie potrzebne. Użytkownik przewinął obok obrazka — pewnie zaraz do niego wróci. Aplikacja pobrała profil użytkownika — za trzydzieści sekund znów będzie go potrzebować. Właśnie pobrane dane pogodowe prawdopodobnie będą aktualne jeszcze przez dziesięć minut. Cache stawia zakład na ten wzorzec. Gdy zakład się opłaca — mamy cache hit — zyskujesz ogromne przyspieszenie. Gdy przegrywa — mamy cache miss — płacisz pierwotny koszt plus niewielki narzut na sprawdzenie cache'u. Sedno dobrego cache'owania polega na tym, żeby zakład wygrywał znacznie częściej, niż przegrywa.

LRU cache: koń pociągowy

Najczęstsza strategia cache'owania, z jaką się spotkasz, to LRU — Least Recently Used, czyli „najdawniej używany”. Idea jest prosta. Ustalasz maksymalny rozmiar cache'u. Gdy cache jest pełny, a trzeba dodać nowy wpis, usuwasz (evict) ten, po który sięgano najdawniej. Założenie jest takie, że to, co było używane ostatnio, najprawdopodobniej znów będzie potrzebne, a to, czego najdłużej nikt nie dotykał, najbezpieczniej wyrzucić. Android udostępnia LruCache bezpośrednio we frameworku. Pod spodem to LinkedHashMap, w którym kolejność odzwierciedla czas dostępu. Gdy wywołujesz get(), wpis przesuwa się na początek listy. Gdy cache przekroczy maksymalny rozmiar, wpisy są usuwane z jej końca. To eleganckie rozwiązanie i w większości przypadków działa zaskakująco dobrze. Ale LRU to nie magia. Fatalnie radzi sobie ze wzorcem skanowania — jeśli raz przejdziesz przez ogromną listę elementów, wyrzucisz z cache'u najcenniejsze wpisy i zastąpisz je rzeczami, po które już nigdy nie sięgniesz. Miej to z tyłu głowy.

Cache w pamięci a cache na dysku

Cache działa na różnych poziomach, a każdy poziom ma inne kompromisy. Cache w pamięci — taki jak LruCache — jest piekielnie szybki. Czas dostępu liczy się w nanosekundach. Ale jest ulotny. Gdy proces umiera, cache znika. Do tego konkuruje o pamięć ze stertą twojej aplikacji. Cache na dysku — taki jak DiskLruCache — jest trwały. Przetrwa śmierć procesu, restart aplikacji, a nawet restart urządzenia. Ale jest o rzędy wielkości wolniejszy od pamięci — milisekundy zamiast nanosekund. Najlepsze systemy cache'owania korzystają z obu warstw. Najpierw sprawdzasz pamięć — jeśli dane tam są, masz natychmiastowe trafienie. Jeśli nie, sprawdzasz dysk — wolniej, ale wciąż dużo szybciej niż sieć. Jeśli i tam ich nie ma, idziesz do sieci i zapisujesz wynik w obu warstwach. Dokładnie tak robią biblioteki do ładowania obrazów, takie jak Coil i Glide. Utrzymują dwupoziomowy cache: cache w pamięci dla obrazów, które są teraz na ekranie albo były na nim niedawno, i cache na dysku dla obrazów, które zostały pobrane, ale mogą już nie być w pamięci. Cache w pamięci daje idealnie płynne przewijanie. Cache na dysku sprawia, że nie pobierasz obrazów od nowa przy każdym otwarciu aplikacji.

Unieważnianie cache'u: najtrudniejszy problem

Docieramy do naprawdę trudnej części. Skąd wiadomo, że dane w cache'u przestały być aktualne? To właśnie unieważnianie cache'u i to naprawdę jeden z najtrudniejszych problemów w inżynierii oprogramowania. Pomyśl o tym. Profil użytkownika trafił do cache'u. Użytkownik właśnie zmienił imię na innym urządzeniu. Twój cache wciąż podaje stare imię. Jak długo to jest akceptowalne? A co, jeśli w cache'u jest lista produktów? Jeden z nich właśnie oznaczono jako niedostępny. Twój cache wciąż pokazuje go jako dostępny. Użytkownik próbuje go kupić. Strategie unieważniania cache'u występują w kilku odmianach. Oparte na czasie: wpis w cache'u wygasa po ustalonym czasie — powiedzmy, po pięciu minutach. Proste, ale nieprecyzyjne. Możesz serwować nieaktualne dane przez cztery minuty i pięćdziesiąt dziewięć sekund albo unieważnić dane, które były idealnie świeże. Oparte na zdarzeniach: unieważniasz wpis, gdy wiesz, że coś się zmieniło — użytkownik właśnie edytował profil, więc czyścisz cache profilu. Bardziej precyzyjne, ale wymaga znajomości wszystkich zdarzeń, które wpływają na twoje dane. Oparte na wersjach: każdy wpis ma wersję albo ETag. Przed zaserwowaniem sprawdzasz, czy wersja jest wciąż aktualna. Precyzyjne, ale wymaga sprawdzenia przy każdym dostępie. Nie ma idealnej strategii. Każde podejście to kompromis między nieaktualnością danych, złożonością i wydajnością.

Cache obrazów w Androidzie: jak działają Coil i Glide

Przyjrzyjmy się, jak radzą sobie z tym prawdziwe biblioteki do ładowania obrazów, bo to mistrzowskie przykłady dobrze zrobionego cache'owania. Coil — biblioteka Kotlin-first oparta na korutynach — używa dwupoziomowego cache'u. Cache w pamięci to cache LRU, w którym kluczem jest URL obrazu plus wszelkie transformacje — na przykład rozmiar i tryb przycinania. Ten klucz jest bardzo ważny: ten sam URL w różnych rozmiarach daje różne wpisy w cache'u. Cache na dysku przechowuje surowe pobrane bajty. Gdy ładujesz obraz, Coil najpierw sprawdza pamięć. Trafienie? Zwraca obraz natychmiast — bitmapa jest już zdekodowana. Pudło? Sprawdza dysk. Trafienie? Dekoduje bajty do bitmapy, zapisuje ją w pamięci i zwraca. Pudło? Pobiera obraz z sieci, zapisuje surowe bajty na dysku, dekoduje je, zapisuje bitmapę w pamięci i zwraca. Glide działa podobnie, ale dodaje sprytny element: cache'uje dane na kilku poziomach szczegółowości. Osobno przechowuje oryginalne źródło, zdekodowaną bitmapę i bitmapę po transformacji. Jeśli więc zastosujesz tę samą transformację do tego samego obrazu w dwóch różnych miejscach, w cache'u ląduje wynik transformacji, a nie tylko surowe pobranie. Obie biblioteki obsługują unieważnianie głównie przez klucze oparte na URL-u. Zmień URL, a będzie cache miss — i świeże pobranie. Przy unieważnianiu opartym na czasie polegają na nagłówkach cache HTTP wysyłanych przez serwer.

Kiedy cache SZKODZI

Porozmawiajmy teraz o tym, kiedy cache'owanie zawodzi. Po pierwsze: presja pamięciowa (memory pressure). Każdy bajt w cache'u to bajt, którego brakuje do właściwej pracy aplikacji. Na Androidzie sterta jest ograniczona — często do 256 megabajtów albo mniej. Hojny cache obrazów potrafi zjeść od 50 do 100 megabajtów. Zostaje mniej miejsca na hierarchię widoków, modele danych, stosy korutyn. Jeśli system potrzebuje pamięci, zabije twoją aplikację. Cache pogorszył sprawę zamiast ją poprawić. Po drugie: nieaktualny stan. W aplikacjach mocno opartych na stanie — pomyśl o aplikacjach finansowych, komunikatorach, wspólnej edycji dokumentów — serwowanie nieaktualnych danych nie jest tylko irytujące, jest niebezpieczne. Jeśli cache mówi, że saldo konta to tysiąc dolarów, a w rzeczywistości jest to pięćdziesiąt, masz poważny problem. Po trzecie: złożoność. Każdy cache, który dodajesz, to komponent, który może zawieść, który wymaga konfiguracji i który wchodzi w interakcje z innymi systemami. Błędy związane z cache'em są wyjątkowo trudne do odtworzenia, bo zależą od czasu, kolejności zdarzeń i konkretnej zawartości cache'u w danym momencie. Widziałem zespoły, które spędzały dni na debugowaniu problemów sprowadzających się do jednego: cache serwował starą wartość.

Rozmiar cache'u: jak go dobrać

Dobór właściwego rozmiaru cache'u to bardziej sztuka niż nauka, ale istnieją pewne zasady. Dla cache'u obrazów w pamięci częstym punktem wyjścia jest jedna ósma dostępnej sterty. Jeśli maksymalna sterta ma 256 megabajtów, cache obrazów może mieć 32 megabajty. Coil i Glide domyślnie stosują podobne heurystyki. Na dysku masz więcej miejsca — od 50 do 250 megabajtów to typowy rozmiar dyskowego cache'u obrazów. DiskLruCache zajmuje się usuwaniem wpisów za ciebie. W przypadku cache'u danych — odpowiedzi API, wyników zapytań do bazy — rozmiar zależy wyłącznie od twoich danych. Cache profili użytkowników może potrzebować zaledwie kilkuset wpisów. Cache wyników wyszukiwania może potrzebować tysięcy. Najważniejsza zasada brzmi: mierz. Nie zgaduj. Użyj memory profilera w Android Studio, żeby zobaczyć, ile pamięci faktycznie zużywa twój cache. Monitoruj współczynnik trafień (hit rate) — jeśli jest poniżej 70 procent, cache może być za mały albo twoje wzorce dostępu w ogóle nie zyskują na cache'owaniu. Jeśli współczynnik trafień przekracza 95 procent, a cache jest wypełniony ledwie w połowie, być może przydzielasz mu za dużo pamięci.

DiskLruCache i cache OkHttp

Są dwa ważne systemy cache'owania na dysku, które warto rozumieć. DiskLruCache to standardowa implementacja używana przez większość androidowych bibliotek cache'ujących. Przechowuje wpisy jako pliki na dysku, a kolejność LRU śledzi w pliku dziennika (journal). Dziennik to najsprytniejszy element — to log operacji na cache'u, do którego dane są tylko dopisywane, więc śledzenie kolejności dostępu jest tanie. Przy starcie DiskLruCache odtwarza dziennik, żeby zrekonstruować stan LRU. Cache OkHttp jest zbudowany na DiskLruCache i automatycznie implementuje semantykę cache'owania HTTP. Gdy serwer zwraca nagłówki cache — Cache-Control max-age, ETag, Last-Modified — OkHttp zapisuje odpowiedź na dysku. Kolejne żądania sprawdzają cache. Jeśli odpowiedź w cache'u jest wciąż świeża według nagłówków, jest serwowana bezpośrednio — bez żadnego wywołania sieciowego. Jeśli jest nieaktualna, OkHttp wysyła żądanie warunkowe z nagłówkiem If-None-Match albo If-Modified-Since. Jeśli serwer odpowie 304 Not Modified, wersja z cache'u jest nadal dobra. To piękne rozwiązanie, bo politykę unieważniania kontroluje serwer. Nie musisz zgadywać, kiedy dane wygasają — serwer ci to mówi.

Praktyczne wzorce cache'owania

Oto kilka wzorców, które sprawdzają się w produkcyjnych aplikacjach na Androida. Pierwszy: wzorzec repozytorium z cache'em. Repozytorium sprawdza cache, zanim sięgnie do sieci. Jeśli cache ma świeże dane, zwraca je od razu. Jeśli cache jest nieaktualny albo pusty, pobiera dane z sieci, aktualizuje cache i je zwraca. Opcjonalnie może od razu zwrócić nieaktualną wartość z cache'u, żeby UI natychmiast coś wyświetlił, a potem odświeżyć dane w tle i zaktualizować UI, gdy przyjdą świeże. Ten wzorzec nazywa się czasem stale-while-revalidate i daje zarówno szybkość, jak i świeżość. Drugi: rozgrzewanie cache'u (cache warming). Gdy użytkownik się loguje, pobierz z wyprzedzeniem i zapisz w cache'u dane, o których wiesz, że będą potrzebne — jego profil, ostatnie elementy, liczbę powiadomień. Pierwszy ekran ładuje się wtedy natychmiast. Trzeci: zakres cache'u (cache scoping). Nie wszystko powinno trafiać do tego samego cache'u. Tokeny uwierzytelniające trafiają do szyfrowanego magazynu, a nie do LruCache. Dane obrazów trafiają do cache'u w pamięci i na dysku. Odpowiedzi API mogą trafić do krótko żyjącego cache'u w pamięci. Dopasuj zakres cache'u do czasu życia i wrażliwości danych.

Kiedy NIE cache'ować

Na koniec powiem wprost, kiedy w ogóle nie należy cache'ować. Nie cache'uj danych, które zmieniają się przy każdym żądaniu — cen w czasie rzeczywistym, wyników na żywo, danych strumieniowych. Współczynnik trafień będzie bliski zeru, a ty tylko zmarnujesz pamięć. Nie trzymaj danych wrażliwych z punktu widzenia bezpieczeństwa w łatwo dostępnych cache'ach — tokeny, hasła i dane osobowe powinny trafiać do EncryptedSharedPreferences albo Keystore, a nie do cache'u LRU, który można wyciągnąć podczas analizy zrzutu sterty. Nie dodawaj cache'u, żeby naprawić problem z wydajnością, którego nikt jeszcze nie zmierzył. Widziałem programistów, którzy spekulacyjnie dodawali warstwy cache'u i tworzyli skomplikowane problemy z unieważnianiem, podczas gdy prawdziwe wąskie gardło było zupełnie gdzie indziej — na przykład brakujący indeks w bazie danych albo niezoptymalizowane zapytanie. Najpierw profiluj. Potem cache'uj — wtedy i tylko wtedy, gdy dane pokazują, że wąskim gardłem są powtarzane kosztowne odczyty.

Podsumowanie i co dalej

Oto, co omówiliśmy. Cache wymienia pamięć na szybkość. LRU to najpopularniejsza strategia usuwania wpisów. Dwupoziomowy cache — pamięć plus dysk — łączy zalety obu światów. Unieważnianie cache'u jest naprawdę trudne, a strategie oparte na czasie, zdarzeniach i wersjach mają swoje kompromisy. Coil i Glide implementują zaawansowane, wielopoziomowe cache'owanie obrazów. Cache może szkodzić przez presję pamięciową, nieaktualne dane i dodatkową złożoność. Rozmiar cache'u dobieraj na podstawie pomiarów, a nie zgadywania. Cache OkHttp automatycznie implementuje semantykę HTTP. A czasem najlepszy cache to brak cache'u. W następnym filmie przyjrzymy się czemuś, co każda aplikacja robi bez przerwy, a o czym niewielu programistów myśli — ukrytemu kosztowi stringów, buforów i kopii. Do zobaczenia.