Zwiększ wydajność za pomocą pamięci podręcznej w usłudze Azure Database for PostgreSQL — serwer elastyczny

Podczas tworzenia aplikacji na serwerze elastycznym Azure Database for PostgreSQL dodawanie warstwy buforowania jest jednym z najbardziej skutecznych sposobów poprawy czasów odpowiedzi, zmniejszenia obciążenia bazy danych i zwiększenia odporności. Dzięki obsłudze często odczytywanych danych z pamięci podręcznej w pamięci aplikacja wysyła mniej zapytań do bazy danych PostgreSQL. Oznacza to mniejsze obciążenie CPU i mniejsze zużycie IOPS, dzięki czemu można korzystać z mniejszej warstwy mocy obliczeniowej, skalować odczyty bez zwiększania mocy serwera i obsługiwać skoki ruchu. Pamięć podręczna może również zwiększyć odporność. Jeśli baza danych PostgreSQL ma krótką przerwę, żądania, które trafią na dane już w pamięci podręcznej, mogą nadal kończyć się powodzeniem, więc ścieżki odczytu pozostają dostępne podczas odzyskiwania bazy danych.

Ten artykuł pomaga zdecydować, kiedy buforowanie pomaga i który wzorzec pasuje do aplikacji. Następnie implementuje cztery wzorce buforowania w języku Python (cache-aside, wstępne pobieranie danych referencyjnych, write-through i sterowany zdarzeniami) przy użyciu usługi Azure Managed Redis, bibliotek redis i psycopg oraz uwierzytelniania Microsoft Entra ID.

Kiedy dodać pamięć podręczną

Baza danych PostgreSQL buforuje już często używane strony danych w pamięci podręcznej buforu, a także korzysta z pamięci podręcznej plików systemu operacyjnego. Te pamięci podręczne zapewniają szybszy dostęp, ale współużytkują pamięć przydzieloną do serwera bazy danych z wykonywaniem zapytań i innymi procesami. Ich zawartość również musi ponownie się rozgrzać po niektórych operacjach konserwacji i pracy w trybie failover.

Możesz uzyskać większą pojemność pamięci podręcznej bazy danych, przechodząc na opcję obliczeniową z większą pamięcią. Możesz również dostroić ustawienia pamięci w PostgreSQL, ale przydzielenie większej ilości pamięci na pamięć buforową oznacza mniej pamięci dla wykonywania zapytań i systemu operacyjnego. Dokładnie testuj zmiany związane z pamięcią, aby uniknąć błędów spowodowanych brakiem pamięci.

Usługa Azure Managed Redis uzupełnia te wbudowane pamięci podręczne. Przechowuje wybrane dane aplikacji i wyniki zapytań poza serwerem bazy danych, zapewnia dostęp o mniejszych opóźnieniach dla operacji odczytu wrażliwych na opóźnienia oraz może utrzymywać dostępność odczytów z pamięci podręcznej podczas odzyskiwania PostgreSQL lub nagrzewania jego pamięci podręcznej. Stosuj to, gdy te korzyści uzasadniają dodatkową logikę aplikacji oraz konieczność obsługi dodatkowej usługi. Nie zastępuje bazy danych PostgreSQL jako źródła prawdy.

Dodanie zewnętrznej pamięci podręcznej, takiej jak Azure Managed Redis, pomaga w większości przypadków, gdy obciążenie ma następujące cechy:

  • Wzorce dostępu z przewagą odczytu. Te same wiersze są odczytywane znacznie częściej niż zmieniają się, takie jak katalogi produktów, profile użytkowników, dane konfiguracji lub tabele referencyjne.
  • Kosztowne lub powtarzające się zapytania. Agregacje, sprzężenia lub obliczone wyniki, które są kosztowne do produkcji, ale stabilne w krótkim czasie.
  • Punkty końcowe wrażliwe na opóźnienia. Operacje widoczne dla użytkownika, w których odczyt z pamięci operacyjnej (poniżej milisekundy) jest lepszy niż pełny cykl zapytania do bazy danych.
  • Przewidywalne skoki. Ruch sezonowy lub sterowany zdarzeniami, w którym buforowanie pochłania obciążenie, które w przeciwnym razie wymusiłoby skalowanie zasobów obliczeniowych.
  • Czułość obsługi i przełączenia awaryjnego. Ścieżki odczytu, które wymagają stabilnych czasów odpowiedzi, gdy instancja PostgreSQL odzyskuje sprawność lub nagrzewa pamięć podręczną po pracach konserwacyjnych lub operacji przełączenia awaryjnego.

Buforowanie jest mniej przydatne w przypadku obciążeń z przewagą operacji zapisu, danych, które muszą zawsze zachowywać spójność transakcyjną, lub zapytań, które i tak są szybkie i rzadko się powtarzają.

Wzorce buforowania

W tym artykule użyto witryny sklepu detalicznego jako działającego przykładu. Różne części aplikacji korzystają z różnych wzorców buforowania. W poniższych sekcjach zaimplementowane są pierwsze cztery wzorce w Python. Artykuł opisuje wzorce odciążania sesji i stanu oraz buforowania wieloregionalnego, ale nie przedstawia ich implementacji w kodzie.

Pattern Jak to działa W sklepie detalicznym
Cache-aside (leniwe ładowanie) Aplikacja najpierw sprawdza pamięć podręczną. W przypadku chybienia odczytuje dane z bazy PostgreSQL, a następnie uzupełnia pamięć podręczną. Katalog produktów i strony szczegółów, w których kilka popularnych elementów jest najczęściej odczytywanych.
Wstępne pobieranie danych referencyjnych Stabilne dane są ładowane z góry do pamięci podręcznej i odświeżane, gdy źródło ulegnie zmianie, zamiast pominięcia. Kategorie, marki i konfiguracja wysyłki.
Zapis Aplikacja zapisuje dane w pamięci podręcznej i bazy danych PostgreSQL w tej samej operacji, zachowując ich spójność. Aktualizacje cen i zapasów, które muszą być widoczne natychmiast.
Unieważnienie sterowane zdarzeniami Wpisy pamięci podręcznej są aktualizowane lub unieważniane w odpowiedzi na zdarzenia zmian danych, a nie na podstawie timera. Stan zamówienia w miarę jak zamówienie przechodzi przez realizację.
Odciążanie sesji i stanu Stan przejściowy znajduje się w pamięci podręcznej zamiast bazy danych. Koszyki i sesje użytkowników.
Buforowanie wieloregionowe Pamięć podręczna w każdym regionie obsługuje odczyty lokalne, przechowywane w synchronizacji z aktywną replikacją geograficzną. Globalna witryna sklepów obsługująca kupujących w wielu regionach.

Wymagania wstępne

Wskazówka

Aby uzyskać pełną, wdrażalną wersję tego przykładu, w tym infrastrukturę jako kod i wszystkie cztery wzorce, zobacz repozytorium amr-caching-pattern-samples w GitHub.

Krok 1. Instalowanie bibliotek klienckich

Zainstaluj biblioteki klienckie Redis i PostgreSQL wraz z biblioteką Azure Identity na potrzeby uwierzytelniania Microsoft Entra.

pip install "redis>=5.0,<6.0" "psycopg[binary]>=3.1,<4.0" "azure-identity>=1.17,<2.0"

Krok 2. Nawiązywanie połączenia z Microsoft Entra ID

Użyj Microsoft Entra ID uwierzytelniania zamiast kluczy dostępu. Microsoft Entra ID usuwa konieczność przechowywania wpisów tajnych w aplikacji i umożliwia centralne zarządzanie dostępem.

Poniższy kod tworzy klienta Redis i połączenie z PostgreSQL, oba uwierzytelniane przy użyciu tożsamości zarządzanej lub poświadczeń dewelopera za pośrednictwem DefaultAzureCredential. W przykładzie użyto klienta świadomego klastra RedisCluster, który jest zgodny z zasadami klastrowania OSS używanymi w tym przykładzie. Jeśli pamięć podręczna korzysta z zasad klastrowania Enterprise, użyj zamiast tego standardowego klienta redis.Redis.

import os
import json
import redis
from redis.cluster import RedisCluster
import psycopg
from azure.identity import DefaultAzureCredential

REDIS_HOST = os.environ["REDIS_HOST"]  # for example, mycache.eastus.redis.azure.net
REDIS_PORT = 10000
PG_HOST = os.environ["PG_HOST"]        # for example, myserver.postgres.database.azure.com
PG_DATABASE = os.environ["PG_DATABASE"]

credential = DefaultAzureCredential()

# Acquire a token for Azure Managed Redis and use it as the password.
redis_token = credential.get_token("https://redis.azure.com/.default")

# The username is the object ID of the Microsoft Entra identity.
redis_client = RedisCluster(
    host=REDIS_HOST,
    port=REDIS_PORT,
    ssl=True,
    ssl_check_hostname=False,  # cluster nodes are reached by IP; the certificate chain is still validated
    username=os.environ["REDIS_USER_OBJECT_ID"],
    password=redis_token.token,
    decode_responses=True,
)

# Acquire a token for Azure Database for PostgreSQL and use it as the password.
pg_token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default")

pg_conn = psycopg.connect(
    host=PG_HOST,
    dbname=PG_DATABASE,
    user=os.environ["PG_USER"],
    password=pg_token.token,
    sslmode="require",
)

Note

Tokeny dostępu Microsoft Entra wygasają zazwyczaj po około godzinie. W przypadku aplikacji działających przez długi czas należy odświeżyć token przed jego wygaśnięciem i ponownie nawiązać połączenie zarówno z usługą Azure Managed Redis, jak i z usługą Azure Database for PostgreSQL, lub użyć pomocnika, który automatycznie ponownie pobiera tokeny. Aby uzyskać szczegółowe informacje, zobacz Use Microsoft Entra ID for authentication with Azure Managed Redis (Używanie Microsoft Entra ID do uwierzytelniania za pomocą usługi Azure Managed Redis).

Krok 3: Cache-aside

Cache-aside to najczęściej stosowany wzorzec i w tym przykładzie służy do odczytu danych produktu. Aplikacja najpierw sprawdza Redis, a w przypadku braku trafienia wysyła zapytanie do PostgreSQL i zapisuje wynik w pamięci podręcznej, ustawiając czas życia (TTL). Kilka popularnych produktów napędza większość odczytów, więc wskaźnik trafień jest wysoki.

Ponieważ większość operacji odczytu jest obsługiwana z pamięci, wzorzec cache-aside zdejmuje z PostgreSQL trwałe obciążenie związane z odczytami. Ta redukcja oznacza mniej połączeń, mniejsze obciążenie pamięci podręcznej bufora oraz niższe wykorzystanie CPU i mniejszą liczbę operacji we/wy na sekundę (IOPS). Można obsłużyć nagłe wzrosty liczby odczytów bez zwiększania mocy serwera lub dodawania replik do odczytu. Wykonujesz zapytanie dotyczące bazy danych PostgreSQL tylko w przypadku błędu (pierwszy dostęp lub po wygaśnięciu czasu wygaśnięcia). Zobacz Co przechowywać w pamięci podręcznej, aby określić, które zapytania warto przechowywać w pamięci podręcznej.

CACHE_TTL_SECONDS = 300  # 5 minutes

def get_product(product_id: int) -> dict | None:
    cache_key = f"product:{product_id}"

    # 1. Try the cache first.
    cached = redis_client.get(cache_key)
    if cached is not None:
        return json.loads(cached)

    # 2. On a miss, read from PostgreSQL.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is None:
        return None

    product = {"id": row[0], "name": row[1], "price": float(row[2])}

    # 3. Populate the cache with a TTL, then return.
    redis_client.set(cache_key, json.dumps(product), ex=CACHE_TTL_SECONDS)
    return product

Krok 4: Wstępne pobieranie danych referencyjnych

Stabilne dane, które są stale odczytywane, ale rzadko zmieniają się (na przykład kategorie, marki lub konfiguracja wysyłki) nie muszą czekać na chybienie pamięci podręcznej. Załaduj je z góry do pamięci podręcznej i odśwież je po zmianie źródła. W schemacie bazy danych PostgreSQL są to zazwyczaj małe tabele słownikowe i tabele wymiarów, które są dołączane w wielu zapytaniach. Serwowanie ich z pamięci eliminuje znaczną liczbę powtarzających się złączeń i wyszukiwań po stronie bazy danych. W przeciwieństwie do wzorca cache-aside, nie występuje brak trafienia przy każdym żądaniu ani warunek wyścigu TTL. Odświeżasz zmiany, więc odczyty są zawsze ciepłe.

def prefetch_categories() -> None:
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name FROM categories ORDER BY name")
        categories = [{"id": r[0], "name": r[1]} for r in cursor.fetchall()]
    redis_client.set("ref:categories", json.dumps(categories))  # no TTL; refreshed on change

def get_categories() -> list[dict]:
    cached = redis_client.get("ref:categories")
    return json.loads(cached) if cached else []

Krok 5. Zapis

Gdy zmiana musi być widoczna natychmiast, zapisz dane w PostgreSQL i w pamięci podręcznej w ramach tej samej operacji, zamiast czekać na wygaśnięcie TTL lub unieważniać klucz. Na przykład ten wzorzec służy do aktualizowania informacji o cenach w przykładzie. Baza danych PostgreSQL pozostaje źródłem prawdy. Najpierw następuje zatwierdzenie zmiany, a następnie odświeżenie pamięci podręcznej, więc odczyt po zapisie zwraca nową wartość.

Podczas zapisywania w dwóch systemach, które nie współużytkują transakcji, zatwierdzenie bazy danych może zakończyć się powodzeniem, gdy odświeżanie pamięci podręcznej kończy się niepowodzeniem i nie ma prostej poprawki. Poniższy fragment kodu przedstawia scenariusz optymistyczny i pomija obsługę błędów. W środowisku produkcyjnym to Ty decydujesz, jak obsłużyć nieudane odświeżenie — na przykład ponowić próbę, gdy problem wygląda na przejściowy, albo unieważnić klucz, aby przy następnym odczycie dane zostały ponownie wczytane z PostgreSQL. Tak czy inaczej, baza danych PostgreSQL przechowuje poprawną wartość, więc zawsze można odzyskać nieaktualny lub brakujący wpis pamięci podręcznej. Gdy aktualizacja pamięci podręcznej musi niezawodnie wylądować, należy ją przeprowadź ze strumienia zmian bazy danych (zobacz unieważnienie sterowane zdarzeniami).

def update_price(product_id: int, new_price: float) -> None:
    # 1. Write to PostgreSQL, the source of truth.
    with pg_conn.cursor() as cursor:
        cursor.execute("UPDATE products SET price = %s WHERE id = %s", (new_price, product_id))
    pg_conn.commit()

    # 2. Refresh the cached entry so reads see the new price right away.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is not None:
        product = {"id": row[0], "name": row[1], "price": float(row[2])}
        redis_client.set(f"product:{product_id}", json.dumps(product), ex=CACHE_TTL_SECONDS)

Krok 6. Unieważnienie sterowane zdarzeniami

Unieważnienie sterowane zdarzeniami zapewnia spójność pamięci podręcznej z bazą danych przez reagowanie na zdarzenia zmiany danych. Aktualizuje lub unieważnia wpisy, gdy się zmieniają, zamiast pozwalać im wygasnąć po upływie ustawionego czasu. Autorzy dołączają zdarzenia do trwałego strumienia Redis, dziennika tylko do dołączania i jednego lub większej liczby odbiorców odczytują te zdarzenia i aktualizują pamięć podręczną. Ponieważ strumień będzie się powtarzać, zdarzenia przetrwają ponowne uruchomienie użytkownika. Grupa konsumentów dostarcza każde zdarzenie do pojedynczego procesu roboczego, śledzi potwierdzenia, dzięki czemu żadne zdarzenie nie zostaje utracone ani przetworzone dwukrotnie, oraz umożliwia skalowanie przetwarzania między procesami roboczymi.

Użyj tego wzorca, gdy buforowana wartość jest wyprowadzana z danych, które zmieniają się gdzie indziej (stan, projekcja lub agregacja), a TTL powodowałby albo zwracanie nieaktualnych danych, albo wymuszał ciągłe przeliczanie. W sklepie internetowym ten wzorzec steruje statusem zamówienia w procesie realizacji. Złożenie zamówienia zapisuje je do bazy danych PostgreSQL, umieszcza jego początkowy stan w pamięci podręcznej i dołącza zdarzenie placed do strumienia:

ORDER_STREAM = "orders:events"

def place_order(product_id: int, quantity: int) -> int:
    with pg_conn.cursor() as cursor:
        cursor.execute(
            "INSERT INTO orders (product_id, quantity, status) VALUES (%s, %s, 'placed') RETURNING id",
            (product_id, quantity),
        )
        order_id = cursor.fetchone()[0]
    pg_conn.commit()

    redis_client.set(f"order:{order_id}:status", "placed", ex=86400)
    redis_client.xadd(ORDER_STREAM, {"order_id": order_id, "status": "placed"}, maxlen=10000, approximate=True)
    return order_id

Proces roboczy realizacji uruchamia grupę odbiorców: odczytuje nowe zdarzenia, wykonuje kolejne zamówienia w usłudze PostgreSQL, odświeża buforowane order:{id}:status projekcje i potwierdza zdarzenie. Strona zamówienia odczytuje tę projekcję, więc sprawdzanie statusu jest szybkie i nigdy nie odwołuje się do bazy danych. Wartość pozostaje poprawna, ponieważ zdarzenia zachowują aktualność.

GROUP = "fulfillment"

def process_orders() -> None:
    try:
        redis_client.xgroup_create(ORDER_STREAM, GROUP, id="0", mkstream=True)
    except redis.exceptions.ResponseError:
        pass  # group already exists

    while True:
        events = redis_client.xreadgroup(GROUP, "worker-1", {ORDER_STREAM: ">"}, count=10, block=5000)
        for _stream, entries in events or []:
            for event_id, fields in entries:
                order_id = int(fields["order_id"])
                with pg_conn.cursor() as cursor:
                    cursor.execute("UPDATE orders SET status = 'shipped' WHERE id = %s", (order_id,))
                pg_conn.commit()
                redis_client.set(f"order:{order_id}:status", "shipped", ex=86400)
                redis_client.xack(ORDER_STREAM, GROUP, event_id)

Źródłem zdarzeń w tym przykładzie jest aplikacja, która zapisuje dane w PostgreSQL i dopisuje zdarzenie w ramach tej samej ścieżki. Baza danych PostgreSQL może również emitować zmiany: LISTEN/NOTIFY w przypadku uproszczonych powiadomień lub dekodowania logicznego (przechwytywania zmian danych) dla trwałego strumienia zmiany na poziomie wiersza. Zasilanie pamięci podręcznej z własnego strumienia zmian PostgreSQL oznacza, że reaguje ona na każdą zatwierdzoną zmianę, nawet na operacje zapisu, które omijają aplikację.

Najlepsze rozwiązania

Postępuj zgodnie z tymi praktykami, aby pamięć podręczna była poprawna, wydajna i opłacalna.

  • Ustaw TTL wszędzie, gdzie istnieje ryzyko nieaktualności danych. TTL ogranicza stopień nieaktualności danych, jeśli inwalidacja się nie powiedzie. Dopasuj wartość TTL do maksymalnego dopuszczalnego czasu nieaktualności, jaki aplikacja może zaakceptować. Stosuj długi TTL lub brak TTL dla danych referencyjnych tylko wtedy, gdy istnieją niezawodne procesy unieważniania i odświeżania.
  • Użyj spójnego schematu nazewnictwa kluczy. Klucze przestrzeni nazw według usługi, jednostki i identyfikatora, takich jak product:42 lub user:1001:profile. Dodaj wersję, gdy można zmienić format klucza lub schemat wartości.
  • Buforuj odpowiedni stopień szczegółowości. Buforuj poszczególne jednostki lub małe zestawy wyników, gdy są często używane ponownie i są łatwe do unieważnienia. Nie buforuj danych, które mają niewielkie użycie. Nadmierne buforowanie marnuje pamięć i może zmniejszyć współczynnik trafień.
  • Obsługa błędów i awarii pamięci podręcznej w sposób bezproblemowy. Traktuj pamięć podręczną jako optymalizację, a nie źródło prawdy. Jeśli Redis jest niedostępny, użyj ograniczonego mechanizmu awaryjnego opartego na PostgreSQL. Dodaj limity czasu, wyłączniki, wycofywanie i limity żądań w celu ochrony bazy danych PostgreSQL. Jeśli dojdzie do krótkiej przerwy w działaniu PostgreSQL, możesz nadal obsługiwać operacje odczytu danych, które są już w pamięci podręcznej, podczas gdy operacje zapisu będą czekać, aż baza danych wróci do działania.
  • Zapobiegaj lawinowemu odpytywaniu pamięci podręcznej. Po wygaśnięciu popularnego klucza wiele żądań może trafić do bazy danych jednocześnie. Użyj losowego odchylenia TTL, scalania żądań, mechanizmu stale-while-revalidate lub krótkiej blokady rozproszonej, aby jedno żądanie mogło ponownie zapełnić ten wpis.
  • Ustaw odpowiedni rozmiar pamięci podręcznej. Monitoruj współczynnik trafień, użycie pamięci, współczynnik eksmisji, współczynnik wygasania, opóźnienie, gorące klucze i kardynalność kluczy. Niska szybkość trafień może wskazywać na zbyt małą pamięć podręczną, nadmierne eksmisje, słaby wybór klucza lub słaby wzorzec dostępu. Aby uzyskać wskazówki dotyczące doboru rozmiaru, zobacz wskazówki dotyczące wyboru warstwy Azure Managed Redis.
  • Wybierz politykę usuwania odpowiednią dla twoich kluczy. W przypadku bazy danych tylko w pamięci podręcznej zacznij od allkeys-lru lub allkeys-lfu. Użyj zasad volatile-* tylko wtedy, gdy ta sama baza danych zawiera wygasające klucze pamięci podręcznej i chronione klucze, które nie wygasają. Polityka volatile może przestać przeprowadzać eksmisję, gdy żaden klucz nie ma TTL. W miarę możliwości oddzielaj dane pamięci podręcznej od chronionych danych.
  • Serializuj wydajnie. Format JSON jest czytelny i przenośny. W przypadku ścieżek o wysokiej przepływności przetestuj kompaktowy format binarny, aby zmniejszyć obciążenie pamięci i sieci. Przed zmianą formatu zmierz wpływ na użycie pamięci, obciążenie CPU, opóźnienia, ewolucję schematu i debugowanie.

Znajdź, co zapisać w pamięci podręcznej

Najbardziej efektywne miejsca docelowe pamięci podręcznej to zapytania, które aplikacja uruchamia najczęściej względem danych, które najmniej się zmieniają. Zamiast odgadywać, które zapytania pasują do tego opisu, porównaj historyczne i bieżące widoki w telemetrii zapytania, które Azure Database for PostgreSQL mogą zbierać po włączeniu odpowiednich funkcji:

  • Query Store utrwala statystyki wykonywania zapytań na potrzeby analizy historycznej. Użyj liczników wywołań i łącznego i średniego czasu wykonywania, aby znaleźć zapytania, które stale zdominowały obciążenie bazy danych w dłuższych okresach. Zobacz Monitorowanie wydajności za pomocą Query Store.
  • Szczegółowe informacje o wydajności zapytań wizualizuje dane Query Store w portalu Azure, dzięki czemu można wykrywać częste i intensywnie korzystające z zasobów zapytania i porównywać ich zachowanie w czasie. Zobacz Szczegółowe informacje o wydajności zapytań.
  • pg_stat_statements udostępnia w bazie danych skumulowane statystyki dla poszczególnych instrukcji, zapewniając bezpośredni wgląd w bieżące okno obserwacji. Jego statystyki można zresetować, dlatego należy użyć Query Store, gdy potrzebna jest zachowana historia w oknach obserwacji.

Skorzystaj z obu widoków, zanim wybierzesz kandydata do buforowania. Krótkoterminowy wzrost może nie reprezentować normalnego zachowania obciążenia, podczas gdy średnia historyczna może ukryć bieżącą regresję. Określanie priorytetów zapytań, które są częste, kosztowne i stabilne, co oznacza wysoką liczbę wywołań, wysoki całkowity czas wykonywania i wyniki, które nie zmieniają się w każdym żądaniu. Zapytania te zapewniają najwyższą szybkość trafień pamięci podręcznej i największy spadek obciążenia bazy danych. Zapytanie, które jest uruchamiane stale, ale zwraca te same wiersze w ciągu kilku minut, takie jak lista produktów, drzewo kategorii lub tabela cenowa, jest idealnym kandydatem.