Zmniejsz obciążenie Prismy i PostgreSQL przed zakupem większej bazy danych
Piętnaście praktycznych technik, od EXPLAIN ANALYZE i indeksów złożonych po rozwiązania typu N+1, liczniki, zarządzanie zasobami i procesy oczyszczania, służących do zmniejszenia liczby operacji w bazie danych Prisma.
Źródła: I Reduced Prisma Database Operations by 60% Without Upgrading My Database
W aplikacjach opartych na Prisma i PostgreSQL baza danych często staje się pierwszym prawdziwym wąskim gardłem. Zapytanie, które wykonywa się w 5 ms przy kilkuset wierszach, może trwać sekundy, gdy tabela zawiera miliony rekordów, i naturalną reakcją jest zakup większej instancji lub droższego planu. Weźmy przykład aplikacji typu marketplace, w której ruch bazy danych osiąga od 5000 do 6000 operacji dziennie – to wystarcza, by wyczerpać darmowy pakiet oparty na ilości wykorzystania jeszcze przed końcem miesiąca. Tydzień pracy nad profilowaniem i optymalizacją zapytań w takim przypadku może przynieść redukcję liczby operacji o ponad 60 procent, przyspieszyć odpowiedzi API i obniżyć rachunek, bez konieczności zmiany sprzętu.
Poniższe techniki, uporządkowane od diagnozy, przez projektowanie schematu, aż po konserwację, to te, które zazwyczaj sprawiają tę różnicę.
Pomierz przed wprowadzaniem jakichkolwiek zmian
Nie zakładaj, że zapytanie jest wolne – zmierz je. Dwa narzędzia pokrywają większość potrzeb:
EXPLAIN ANALYZEpokazuje plan wykonywania w PostgreSQL wraz z liczbą przeskanowanych wierszy, informacją o użyciu indeksów oraz rzeczywistym czasem wykonywania.- Logowanie zapytań w Prismie ujawnia dokładny kod SQL generowany przez klienta.
Wykonanie EXPLAIN ANALYZE na filtrze według sprzedawcy wygląda w ten sposób. Należy pamiętać, że ANALYZE faktycznie wykonuje zapytanie, dlatego zapytania modyfikujące dane należy umieszczać w transakcji, którą można cofnąć.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE seller_id = 123;
Nawet wyglądająca schludnie instrukcja w Prismie może skutkować sekwencyjnym przeszukiwaniem całej tabeli; tylko plan wykonywania to pokazuje. Funkcja pg_stat_statements (która sortuje zapytania według całkowitego czasu), funkcje analityczne i panele kontrolne Prismy, takie jak te dostępne w Vercel, pomagają określić, co należy naprawić najpierw. Aby zastosować uporządkowany podejście, zapoznaj się z sposobem znajdowania prawdziwego wąskiego gardła w wolnym punkcie końcowym Node.js.
Indeksuj zapytania, które faktycznie wykonywane są
Indeksy to zazwyczaj najskuteczniejsza zmiana, jaką można wprowadzić. Bez nich takie zapytanie może zmusić PostgreSQL do sprawdzenia każdego wiersza w tabeli orders:
SELECT *
FROM orders
WHERE seller_id = 123;
Zwykły indeks B-tree na kolumnie filtrowanej pozwala planerowi przejść bezpośrednio do odpowiadających wierszy:
CREATE INDEX idx_orders_seller
ON orders(seller_id);
Gdy aplikacja regularnie filtrowała dane na podstawie kilku kolumn jednocześnie, indeks złożony służy bezpośrednio do obsługi tej kombinacji:
CREATE INDEX idx_orders_seller_status
ON orders(seller_id, status);
Dzięki niemu warunek dotyczący zarówno sprzedawcy, jak i statusu staje się możliwy do szybkiego znalezienia za pomocą indeksu:
WHERE seller_id = 123
AND status = 'PAID'
Kolejność kolumn ma znaczenie w indeksie złożonym. (seller_id, status) pomaga również w zapytaniach filtrowanych wyłącznie według seller_id, ale nie w tych filtrowanych tylko według status. Zasadą jest indeksowanie kolumn, których rzeczywiście używa się do filtrowania, sortowania lub łączenia, a nie wszystkich; każdy indeks zajmuje miejsce na dysku i spowalnia każdą operację zapisu do tabeli.
Usunięcie zapytań typu N+1
Wzorzec N+1 to klasyczny problem ORM. Ładuje się listę 100 zamówień:
const orders = await prisma.order.findMany();
a następnie w pętli pobiera się użytkownika każdego zamówienia pojedynczo:
for (const order of orders) {
await prisma.user.findUnique({
where: {
id: order.userId
}
});
}
To oznacza 101 zapytań na jedną stronę danych. Poproszenie Prismy o załadowanie relacji wraz z zamówieniami eliminuje potrzebę pętli:
const orders = await prisma.order.findMany({
include: {
user: true
}
});
Jedno wywołanie zastępuje pętlę, przy znacznie mniejszej opóźnieniu i obciążeniu. W zależności od wersji i konfiguracji Prisma wykonywa funkcję include jako połączenie lub jako dodatkowe zapytanie w formie partii; w obu przypadkach znika konieczność przesyłania danych dla każdego wiersza.
Wybieraj tylko te kolumny, które są potrzebne
Zmniejszenie ilości przesyłanych danych to jeden z najtańszych sposobów na oszczędności. Proste wywołanie findMany zwraca wszystkie kolumny z każdego wiersza:
await prisma.user.findMany();
Wyrażenie select ogranicza wyniki do tego, co faktycznie jest potrzebne przez użytkownika:
await prisma.user.findMany({
select: {
id: true,
name: true
}
});
Gdy w tabeli znajdują się bazy danych JSON, pliki Markdown, obrazy lub długie teksty, samo to może znacząco skrócić czas odpowiedzi, a także chroni wrażliwe kolumny przed dostępem w odpowiedziach API.
Zastąp powtarzające się agregacje zapisanymi licznikami
Paneliki analityczne często przeliczają agregaty przy każdym załadowaniu strony:
COUNT(*)
SUM(amount)
AVG(...)
To jest w porządku przy 100 wierszach, ale kłopotliwe przy milionach. Na przykład obliczanie łącznych sprzedaży przy każdej prośbie oznacza sumowanie całej tabeli:
SELECT SUM(amount)
FROM orders;
Alternatywą jest aktualizowanie wartości agregowanych w momencie zapisu, przechowywanie takich wartości jak łączne kwoty bezpośrednio w rekordzie użytkownika:
User
-------------
totalSales
totalOrders
totalRevenue
Uaktualniaj te pola za każdym razem, gdy dokonuje się zakupu, dzięki czemu operacje odczytu stają się zapytaniami typu O(1) zamiast przeszukiwania milionów wierszy; w opisanym powyżej scenariuszu była to jedna z największych redukcji. Uaktualniaj liczniki atomowo (na przykład za pomocą funkcji increment w Prismie w tej samej transakcji co dodawanie zamówienia) i sprawdzaj je od czasu do czasu, ponieważ denormalizowane wartości łączne mogą ulegać zmianom.
Paginuj każdą listę
Tabele nie pozostają małe, więc nigdy nie zwracaj nieskończonej listy w ten sposób:
await prisma.product.findMany();
Użyj funkcji take i skip, aby pobrać ograniczoną liczbę wierszy z strony; ten przykład zwraca drugą ze 20 stron:
await prisma.product.findMany({
take: 20,
skip: 20
});
W przypadku dużych zbiorów danych lub obsługi scrollowania nieskończonego, lepiej zastosować paginację za pomocą wskaźnika: wartość offsetu sprawia, że PostgreSQL przechodzi nad każdym pominiętym wierszem, natomiast wskaźnik na kolumnie zindeksowanej kontynuuje przeglądanie dokładnie tam, gdzie skończyła się poprzednia strona.
Caching rzadko zmieniających się danych
Naj szybszą zapytaniem jest takie, które w ogóle nie jest wykonywane. Dane zmieniające się rzadko są doskonałym kandydatem do cacheowania: strony produktów, publiczne witryny sklepowe, ustawienia aplikacji oraz listy kategorii. Powszechne rozwiązania to Upstash Redis, cache w Next.js oraz LRU cache działający wewnątrz procesu. Przeniesienie często używanych zapytań poza PostgreSQL znacznie zmniejsza obciążenie; kosztem jest konieczność unieważniania danych, więc należy określić, jak starymi mogą być dane, i ponownie je zweryfikować po zmianach wierszy.
Zapisy zbiorcze zamiast pętli
Każde oczekiwane wywołanie to ruch w sieci w obie strony, a ich liczba szybko rośnie. Klasycznym przykładem jest tworzenie rekordów pojedynczo w pętli:
for (const item of items) {
await prisma.item.create({
data: item
});
}
createMany wstawia cały tablicę w jednej instrukcji:
await prisma.item.createMany({
data: items
});
Jeden ruch w obie strony jest lepszy niż setki. Należy pamiętać, że createMany nie tworzy zagnieżdżonych relacji i zwraca liczbę wierszy, a nie same wiersze; sprawdź dokumentację swojej wersji Prisma, jeśli potrzebujesz tych rekordów.
Grupuj powiązane operacje zapisu w transakcji
Tworzenie zamówienia często wiąże się z innymi operacjami zapisu, takimi jak korekta stanów magazynowych, aktualizacja sald i generowanie paragonu. Te zmiany należą do siebie, a $transaction zapewnia, że albo wszystkie się udają, albo wszystkie zostaną cofnięte:
await prisma.$transaction([
prisma.order.create(...),
prisma.user.update(...),
prisma.balance.update(...)
]);
Prawdziwą korzyścią jest spójność, a nie szybkość: brak możliwości realizacji operacji bez aktualizacji stanu równowagi, jeśli coś pójdzie nie tak w trakcie. Trzymaj transakcje krótkie, ponieważ blokują one zasoby i połączenia.
Ponowne użycie jednego Prisma Client
Tworzenie nowego obiektu PrismaClient przy każdej prośbie jest kosztowne i może wyczerpać dostępne połączenia, a podczas rozwoju szybkie ładowanie może generować nowy obiekt przy każdej zmianie pliku. Użycie singletona pozwala uniknąć obu tych problemów. Wczytaj klienta:
import { PrismaClient } from "@prisma/client";
Następnie zapisz instancję w zmiennej globalThis poza środowiskiem produkcyjnym, aby ponownie ładowane moduły korzystały z istniejącego klienta, zamiast tworzyć nowego:
const globalForPrisma =
globalThis as unknown as {
prisma: PrismaClient | undefined;
};export const prisma =
globalForPrisma.prisma ??
new PrismaClient();if (process.env.NODE_ENV !== "production")
globalForPrisma.prisma = prisma;
Przekształcenie typu as unknown as nadaje zmiennej globalThis właściwość typu prisma; w środowisku produkcyjnym moduł jest ładowany tylko raz, więc nie ma potrzeby globalnego cache’u.
Używaj puli połączeń, szczególnie w środowiskach serverless
Platformy serverless mogą uruchamiać tysiące jednoczesnych instancji funkcji, z których każda potrzebuje własnego połączenia z bazą danych. Bez mechanizmu poolingu PostgreSQL może wyczerpać dostępne połączenia na długo przed tym, zanim stanie się problemem CPU. Zarówno Prisma Accelerate, jak i PgBouncer znajdują się pomiędzy aplikacją a bazą danych, umożliwiając wielu żądaniom dzielenie się znacznie mniejszą liczbą rzeczywistych połączeń z PostgreSQL.
Indeksuj pola JSONB, których używasz do filtrowania
Typ JSONB w PostgreSQL jest bardzo potężny, ale filtrowanie pól zagnieżdżonych bez indeksu zazwyczaj oznacza pełne przeszukiwanie tabeli. Jeśli regularnie wykonujesz zapytania w dokumentach JSON, dodaj indeks GIN do tej kolumny (lub indeks wyrażeniowy do konkretnej klucza, który często filtrowasz), co przekształci powolne wyszukiwanie dokumentów w szybkie operacje indeksowane.
Wolij funkcję findUnique do wyszukiwania unikalnych rekordów
Gdy wykonujesz zapytanie według klucza głównego lub innego unikalnego pola, użyj tej metody:
findUnique()
Mówi to Prismie, że wyszukiwanie dotyczy jednej wiersza, co umożliwia Prismie łączenie kilku wywołań findUnique z tego samego etapu w jedno zapytanie, co jest przydatne w rozwiązaniach GraphQL. Na przykład:
await prisma.user.findUnique({
where: {
id
}
});
Używaj tego zamiast poniższego rozwiązania, gdy pole jest unikalne:
findFirst()
findFirst pozostaje właściwym wyborem dla filtrów nieunikalnych, gdy faktycznie chcesz „pierwszy pasujący wiersz”.
Archiwizuj stare wiersze zamiast trwałego delikatnego usuwania
Delikatne usuwanie jest wygodne – wystarczy oznaczyć wiersz:
isDeleted = true
Jednak z biegiem lat każde zapytanie musi filtrować usunięte wiersze, a także radzić sobie z rozdęciem tabel i indeksów. W systemach o dużym obrocie należy okresowo przenosić usunięte rekordy do tabel archiwalnych: mniejsze tabele oznaczają mniejsze indeksy i szybsze zapytania. Częściowy indeks dla niesprzątniętych wierszy stanowi kompromis.
Zachowaj autovacuum w dobrym stanie
PostgreSQL wykorzystuje MVCC (kontrolę współbieżności wielowersji), więc aktualizowane i usunięte wiersze nie są natychmiast usuwane; pozostają jako martwe tuple. Autovacuum to proces w tle, który je usuwa. Dobrze dostrojony autovacuum:
- zwalnia przestrzeń zajmowaną przez martwe tuple;
- odświeża statystyki planera;
- zapewnia efektywność indeksów;
- utrzymuje dokładność planów zapytań.
Zaniedbywanie tego procesu prowadzi do przepełnienia bazy i słabych planów zapytań, szczególnie w tabelach intensywnie aktualizowanych, takich jak powyższe liczniki.
Główne wnioski
Większość problemów z wydajnością bazy danych nie rozwiązuje się poprzez większe serwery; rozwiązują je lepsze wzorce dostępu do danych. W opisanym powyżej scenariuszu dostawca hostingu i rozmiar bazy danych pozostały bez zmian. Korzyści pochodziły z:
- profilowania kosztownych zapytań zamiast domyślania się;
Razem zapewniły one ponad 60 procent mniej operacji, szybsze API oraz możliwość obsługi znacznie większego ruchu. Zanim zaczniesz skalowanie, dowiedz się, w jaki sposób faktycznie wykorzystywana jest twoja baza danych; istniejące zapytania często ukrywają większość parametrów wpływających na wydajność.