Panel administracyjny zawsze zaczyna się tak samo: tabela, cztery testowe wiersze i select bez where. Działa. A skoro działa, nikt na to więcej nie patrzy. W tym czasie tabela użytkowników rośnie z czterech wierszy do czterdziestu tysięcy, a wewnętrzna strona, której nikt nie mierzy, staje się najwolniejszą w produkcie.
Musiałem to naprawiać w czterech różnych projektach i zawsze z tych samych trzech powodów.
Admina pisze się niestarannie, bo nikt go nie widzi
Strona publiczna ma paginację od pierwszego dnia. Ktoś ją przegląda, ktoś ją mierzy, jest metryka Core Web Vitals, która krzyczy, jeśli się ślimaczy. Admin nie ma nic z tych rzeczy. Używają go dwie osoby i te dwie osoby zakładają, że jest wolny, bo "to przecież admin".
W utrzymywanym przeze mnie API do transkrypcji akcje serwerowe getAdminUsers i getAdminFeedback dostawały limit jako parametr i przekazywały go wprost do zapytania. Bez sufitu. Nikt tego nie ruszał od miesięcy, bo z UI zawsze przychodziła dziesiątka. Problemem nie jest UI: problemem jest to, że server action to endpoint. Każdy z sesją admina, albo dowolny skrypt, mógł poprosić o limit=1000000 i zmaterializować całą tabelę w pamięci workera.
Poprawka miała trzy linie i weszła w paczce zmian bezpieczeństwa:
export const ADMIN_LIST_MAX_LIMIT = 1000;
const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);
Math.max(limit, 1) to nie darmowa paranoja: ujemny albo zerowy limit też robi dziwne rzeczy, zależnie od sterownika. Ważne jest to, gdzie mieszka sufit. Nie w komponencie, nie w hooku: w warstwie, która przyjmuje żądanie. Wszystko powyżej to sugestia.
count, który wygląda na darmowy, a nie jest
Klasyczna paginacja musi wiedzieć, ile jest stron, a do tego trzeba SELECT COUNT(*). Piszesz to bez zastanowienia, bo w głowie count jest tani. Nie jest: w Postgresie dokładny count przechodzi całą tabelę albo cały indeks i nie da się go od tego odwieść.
W wewnętrznym systemie do zarządzania dokumentami admin użytkowników robił trzy rzeczy równolegle przy każdym ładowaniu strony: pobierał stronę profili, pobierał wszystkie firmy do selektora i liczył łączną liczbę profili. Przy pageSize równym 5. Czyli przechodził całą tabelę, żeby wypisać "strona 3 z 47".
Wyrzuciłem count i zmieniłem kontrakt paginacji:
setHasMore(profilesData.length === pageSize);
Tracisz "z 47". Zyskujesz to, że zapytanie jest zawsze proporcjonalne do rozmiaru strony. Przycisk "następna" zależy teraz od hasMore zamiast od currentPage < totalPages, a wyświetlany zakres liczy się z tego, co już masz w ręku, bez pytania o cokolwiek więcej:
const endIndex = startIndex + companies.length - 1;
Nikt nie narzekał na brak sumy. To dana, która w panelu wewnętrznym nie decyduje absolutnie o niczym. Jeśli naprawdę jej potrzebujesz, reltuples z pg_class daje oszacowanie za zero kosztu; na "jest jakieś 40k użytkowników" w zupełności wystarczy.
Pobieranie metadanych zamiast całego obiektu
Trzeci wzorzec jest subtelniejszy. Lista plików ładuje się dobrze, dopóki pliki nie mają treści. W narzędziu do współdzielonych notatek pasek boczny wołał getProjectTextFiles, które robiło select bez projekcji: czyli ciągnęło też kolumnę content każdego pliku w projekcie. Po to, żeby wypisać listę nazw.
Rozwiązanie jest nudne i działa: druga funkcja, wybierająca jawnie kolumny, których lista potrzebuje.
return await db
.select({
id: textFiles.id,
projectId: textFiles.projectId,
name: textFiles.name,
isTemp: textFiles.isTemp,
deletedAt: textFiles.deletedAt,
createdAt: textFiles.createdAt,
updatedAt: textFiles.updatedAt,
})
.from(textFiles)
.where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));
O treść prosisz przy wyborze pliku, nie wcześniej. I tu pojawia się szczegół, który widać dopiero, kiedy się to zrobiło: jeśli użytkownik szybko kliknie w dwa pliki, wolna odpowiedź pierwszego może nadpisać drugi. Trzeba sprawdzić, przed zapisem do store'a, czy plik nadal jest tym wybranym:
if (fullFile && useAppStore.getState().selectedFileId === fileId) {
useAppStore.getState().setSelectedFile(fullFile);
}
Bez tego sprawdzenia zamieniasz problem wydajnościowy na wyścig, co jest gorsze, bo nie odtwarza się na życzenie.
Bug, który pojawia się tylko w panelu admina
Jeden z najlepszych przykładów na to, dlaczego admin zasługuje na uwagę, dał mi dashboard analityczny, który zacache'owałem przez unstable_cache. Działał lokalnie i przy pierwszej wizycie na produkcji. Potem wywalał się z Invalid time value.
unstable_cache serializuje swój payload do JSON-a. Date zapisany podczas zimnego przebiegu wraca jako string we wszystkich odczytach z cache'u. A Intl.DateTimeFormat.format nie przyjmuje stringa. Typ kłamał: lastIngestedAt: Date | null było w rzeczywistości Date | string | null. TypeScript nie mógł tego zobaczyć, bo granica serializacji nie jest otypowana.
Naprawiłem to, normalizując obie formy i traktując to, czego nie da się wypisać, jako brak, a nie jako wyjątek:
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);
Data, której nie da się wyświetlić, powinna pokazać się jako "brak danych", a nie kłaść całej strony. Na dashboardzie jedna zepsuta metryka nie może zabrać ze sobą pozostałych dwunastu.
Co dziś stosuję do każdej wewnętrznej listy
- Sufit w warstwie serwerowej.
limitprzychodzący z zewnątrz jest ograniczany tam, gdzie wchodzi, nazwaną stałą. UI to nie walidacja. - Bez
countdomyślnie. Paginacja przezhasMore. Jeśli ktoś poprosi o sumę, dostaje oszacowanie, i tylko jeśli poprosi. - Jawna projekcja. Lista wybiera kolumny, nigdy
*. O bloba prosisz przy otwarciu szczegółów. - Indeks na kolumnie, po której filtrujesz. W aplikacji do rejestracji czasu pracy były indeksy złożone
(user_id, date), ale zapytania filtrujące tylko pouser_idnie korzystały z nich dobrze; trzeba było dorzucić prosty indeks.
Panel admina to miejsce, w które zespół patrzy, kiedy coś idzie źle na produkcji. To, że akurat ta część systemu nie wytrzymuje produkcyjnego wolumenu, jest drogą ironią.
