CodeGym /Kursy /Spring Data JPA /Normalizacja bez akademickiego bólu

Normalizacja bez akademickiego bólu

Spring Data JPA
Poziom 2, Lekcja 2
Dostępny

1. Jedna tabela: pokusa i jej cena

Pomysł jednej dużej tabeli zazwyczaj przychodzi bardzo uczciwie i po ludzku: „Po co mi pięć tabel, skoro mogę zrobić jedną? Przecież nie jestem DBA, jestem programistą". W tym momencie w każdym początkującym programiście budzi się mały entuzjasta Excela: chce się jeden arkusz, tysiąc kolumn i poczucie kontroli nad wszechświatem.

W domenie mini-shop ta „uniwersalna tabela" wygląda mniej więcej tak: i zamówienie, i kupujący, i produkty, i kategorie — wszystko w jednym miejscu. Na początku wydaje się nawet, że tak prościej się czyta: otwierasz tabelę i „widzisz wszystko". Ale gdy tylko w jednym zamówieniu pojawi się drugi produkt, tabela zaczyna zachowywać się jak walizka bez uchwytu: nosić niewygodnie, wyrzucić żal.

Oto uproszczony przykład „płaskiej" tabeli zamówienia — zły, ale bardzo kuszący projekt:

-- Zły przykład: w jednej tabeli wymieszano zamówienie, kupującego, produkt i kategorię
-- To prawie na pewno doprowadzi do duplikatów i anomalii przy zmianach
CREATE TABLE order_flat (
    order_number VARCHAR(30),
    customer_email VARCHAR(255),
    product_sku VARCHAR(50),
    product_name VARCHAR(200),
    category_code VARCHAR(50),
    category_name VARCHAR(100),
    quantity INT
);

A teraz wyobraź sobie jedno zamówienie z dwiema pozycjami. W modelu relacyjnym to nie jeden wiersz, a co najmniej dwa, bo powtarza się zestaw „produkt + ilość". Tabela zaczyna duplikować „nagłówek zamówienia" w każdym wierszu.

Przykład danych — umowny, ale życiowy:

order_number customer_email product_sku product_name category_code category_name quantity
ORD-1001 alice@example.com BOOK-001 Clean Code BOOKS Books 1
ORD-1001 alice@example.com GAME-777 Chess Set GAMES Games 1

Na razie tylko dwa wiersze — niby znośnie. Ale w prawdziwym sklepie zamówień jest wiele, pozycji w zamówieniu wiele, produkt pojawia się w różnych zamówieniach, kategoria pojawia się prawie u każdego produktu, i nagle „jedna tabela" staje się fabryką powtórzeń.

2. Nadmiarowość i sprzeczności

Normalizacja nie zaczyna się od terminów, lecz od zapachu. Dane mają „zapach nadmiarowości": widzisz tę samą wartość kopiowaną w dziesiątkach wierszy i rozumiesz, że pewnego dnia zacznie żyć w różnych wersjach. I wtedy systemem już nie zarządzasz ty — on zaczyna zarządzać tobą (a przy okazji twoimi terminami).

W order_flat powtarza się wszystko, co dotyczy nie pozycji, lecz czegoś bardziej „nadrzędnego" lub bardziej „słownikowego": e-mail kupującego powtarza się w każdej pozycji zamówienia, nazwa produktu powtarza się w każdym zamówieniu z tym produktem, nazwa kategorii powtarza się u każdego produktu do niej należącego. W efekcie jedna logiczna wartość istnieje w wielu fizycznych miejscach.

Problem nie tylko w tym, że „zajmuje miejsce". Pamięć jest teraz tania, ale błędy i sprzeczności — drogie. Wyobraź sobie, że postanowiłeś przemianować kategorię BOOKS z Books na Books and Media. W płaskiej tabeli zamienia się to w masową edycję wielu wierszy:

-- Ryzyko: zmiana rozłożona na wiele wierszy, można nie zaktualizować wszystkiego i uzyskać sprzeczności
UPDATE order_flat
SET category_name = 'Books and Media'
WHERE category_code = 'BOOKS';

Wydaje się, że jedno polecenie — i wszystko dobrze. Ale prawdziwe życie lubi komplikować: czasem takie aktualizacje robi się nie jednym skryptem SQL, lecz przez aplikację, partiami, porcjami, ręcznie „szybciutko poprawili w panelu admina". I oto masz już wiersze, gdzie category_code = BOOKS, ale category_name gdzieniegdzie zostało stare. W efekcie baza przechowuje wzajemnie wykluczające się wersje rzeczywistości, a ty potem piszesz bug-report o poziomie „dlaczego mamy w raporcie dwie kategorie BOOKS?".

Płaska tabela skłania do tego, że „prawda" jest rozmazana. Normalizacja, przeciwnie, stara się sprawić, żeby każdy fakt miał jedno miejsce przechowywania: jedna kategoria — jeden wiersz w tabeli kategorii, jeden produkt — jeden wiersz w tabeli produktów, jedno zamówienie — jeden wiersz w tabeli zamówień.

3. Anomalie danych w szerokiej tabeli

Gdy programiści słyszą słowo „anomalia", wydaje się, że za chwilę zacznie się coś z podręcznika z formułami i smutkiem. Ale w praktycznym sensie anomalie — to po prostu sytuacje, gdzie schemat zmusza do dziwnych, nienaturalnych działań lub powoduje utratę danych. To nie „akademia", a zwykła praca programisty, która nagle stała się nieprzyjemna.

Anomalia aktualizacji w płaskiej tabeli — to gdy jedną logiczną zmianę trzeba rozkładać na wiele wierszy. Na przykład u kupującego zmienił się e-mail (tak, to się zdarza: ludzie zmieniają domenę firmy albo po prostu mają dość starego adresu). Jeśli e-mail jest przechowywany w każdym wierszu zamówienia, to aktualizować trzeba nie „zamówienie", a „wszystkie wiersze zamówienia". I im więcej wierszy, tym większa szansa na błąd, nie zaktualizowanie wszystkiego lub zaktualizowanie zbędnych rzeczy.

Anomalia usuwania — jeszcze bardziej podstępna. Wyobraź sobie, że przechowujesz zamówienie tylko w order_flat. Zamówienie ma dwa wiersze — dwie pozycje. Usuwasz jedną pozycję — nadal pozostaje drugi wiersz i zamówienie „żyje". Ale jeśli usuniesz ostatnią pozycję, razem z nią znika cały „nagłówek zamówienia", bo nagłówek nigdzie osobno nie jest przechowywany. To niemal filozoficzny problem: „Jeśli w lesie upadło drzewo, ale nikt nie słyszał — czy upadło?" W płaskiej tabeli zamienia się to w pytanie: „Jeśli usunięto ostatnią pozycję — czy w ogóle istniało zamówienie?"

Wygląda to mniej więcej tak:

-- Uwaga: jeśli to ostatnia pozycja zamówienia, razem z nią znika też samo "zamówienie" jako fakt
DELETE FROM order_flat
WHERE order_number = 'ORD-1001' AND product_sku = 'GAME-777';

Jeśli to był ostatni wiersz zamówienia, po usunięciu w bazie nie zostanie żaden ślad, że zamówienie w ogóle kiedyś istniało. Czasem to dopuszczalne (jeśli biznes tak zdecydował), ale częściej to nieoczekiwana utrata danych.

Anomalia wstawiania — gdy chcesz dodać „słownikową" encję, ale schemat zmusza do udawania, że zamówienie już istnieje. Na przykład chcesz dodać nową kategorię SPORTS. W normalnym świecie po prostu wstawiłbyś kategorię do słownika. W płaskiej tabeli nie możesz „po prostu dodać kategorii" — nie ma miejsca, gdzie kategoria żyje sama w sobie. Musisz albo czekać na pierwsze zamówienie z tą kategorią, albo wstawiać wiersz z masą NULL lub „wypełniaczy". A to już pachnie tym, że tabela przechowuje jednocześnie kilka różnych encji.

Jest jeszcze jeden praktyczny sygnał problemu: pojawiają się zbędne kolumny NULL. Gdy próbujesz wcisnąć do jednej tabeli różne typy danych (słowniki, zamówienia, stany magazynowe), część kolumn będzie regularnie pusta. Zwykle oznacza to nie „mamy elastyczny model", lecz „w jednej tabeli mieszkają różne encje, którym nie jest razem po drodze".

4. Normalizacja: jeden fakt — jedno miejsce

Normalizacja jest często postrzegana jako zestaw reguł w stylu „trzecia postać normalna" i „zależności funkcjonalne". To wszystko istnieje, ale na poziomie junior przydatniejszy jest inny model mentalny: normalizacja — to zdrowy rozsądek, który redukuje powtórzenia i sprzeczności. Jeśli czujesz, że wartości się kopiują, a każda zmiana zamienia się w masową edycję, to dane proszą się o osobną tabelę.

Najprostsza zasada, która naprawdę działa: każda tabela powinna opisywać jeden typ „rzeczy" lub jeden typ „faktu". Kategoria — to osobna rzecz, produkt — osobna rzecz, zamówienie — osobna rzecz, pozycja zamówienia — osobny fakt wewnątrz zamówienia, stany magazynowe — osobny fakt o produkcie. Jeśli trzymać to wszystko w jednej tabeli, każesz jednej strukturze danych wykonywać pracę kilku.

Druga zasada: powtarzający się zestaw danych niemal zawsze oznacza osobną tabelę. Zamówienie ma pozycje, i pozycji jest wiele — więc „pozycja zamówienia" to osobna encja lub osobna tabela. To nie „złożoność dla złożoności", lecz uczciwe przyznanie: w jednym wierszu nie można naturalnie przechowywać wielu jednorodnych elementów.

Trzecia zasada, która bardzo pomaga przy projektowaniu: różny „cykl życia" — powód do rozdzielenia. Kategoria tworzona jest rzadko i zmienia się rzadko. Produkt zmienia się czasem (cena, status, nazwa). Zamówienie tworzone jest często i po stworzeniu zazwyczaj nie powinno być przepisywane jak leci. Stany magazynowe zmieniają się ciągle. Gdy mieszasz dane z różnym rytmem życia, albo aktualizujesz zbyt wiele wierszy, albo przypadkowo dotykasz czegoś, czego dotykać nie powinieneś.

Żeby widzieć to jako obrazek, wygodnie trzymać w głowie taki schemat domeny mini-shop — nie jako model JPA, lecz jako mapę SQL znaczeń:

erDiagram
    CATEGORY ||--o{ PRODUCT : contains
    PRODUCT  ||--|| STOCK_ITEM : has
    CUSTOMER_ORDER ||--o{ ORDER_ITEM : includes
    ORDER_ITEM }o--|| PRODUCT : refers

Nie ma tu nic „magicznego": to po prostu uczciwy podział „co jest czym" i jak jedno odnosi się do drugiego. Dalej klucze i ograniczenia z poprzedniego wykładu stają się tym samym klejem, który trzyma te tabele w jednym świecie danych.

5. Mini-shop: schemat zamiast płaskiego zamówienia

Teraz zastosujmy normalizację do naszej domeny tak, żeby wyglądało to nie jak teoria, lecz jak praca inżynierska. Zaczniemy od najbardziej bolesnego miejsca płaskiego modelu — zamówienia z kilkoma produktami. Potem oddzielimy encje słownikowe (kategorie i produkty) i osobno wydzielimy stany magazynowe. Weźmiemy powiększony fragment schematu: tylko te tabele i powiązania, na których widać, jak zamówienie, pozycja, produkt, kategoria i stany rozkładają się po różnych miejscach przechowywania. Naszym celem dziś nie jest „idealny schemat na wieki", a zrozumiały i logiczny model bez gigantycznej tabeli.

Zamówienie i pozycje zamówienia: dwie tabele

Zamówienie (customer_order) — to „nagłówek": numer, e-mail, status, kwota itd. Pozycja (order_item) — to „wiersz składu zamówienia": jaki produkt, ile sztuk, po jakiej cenie. Pozycje się powtarzają, a nagłówek powinien być jeden. Dlatego normalny schemat niemal nieuchronnie wygląda tak:

-- "Nagłówek" zamówienia: jeden wiersz na jedno zamówienie
CREATE TABLE customer_order (
    id BIGINT PRIMARY KEY,
    -- Biznesowy identyfikator zamówienia (to, co widzi użytkownik/integracje)
    order_number VARCHAR(30) NOT NULL UNIQUE,
    -- W uproszczonym przykładzie kupującego identyfikujemy e-mailem
    customer_email VARCHAR(255) NOT NULL
);
-- Pozycja zamówienia: wiele wierszy na jedno zamówienie
CREATE TABLE order_item (
    id BIGINT PRIMARY KEY,
    -- FK na "nagłówek": pozycja nie powinna istnieć bez zamówienia
    order_id BIGINT NOT NULL,
    -- Odwołanie do produktu w katalogu
    product_id BIGINT NOT NULL,
    -- Ilość produktu w konkretnej pozycji
    quantity INT NOT NULL,
    FOREIGN KEY (order_id) REFERENCES customer_order(id)
);

Celowo trzymam przykład krótki: bez statusów, bez pieniędzy i bez adresów. Sens teraz tkwi w rozdzieleniu „jedno zamówienie" i „wiele wierszy składu". Klucz order_id — to ten sam FK, który nie pozwoli pozycji „wisieć w powietrzu" bez zamówienia.

Kategoria i produkt: słowniki osobno

Kategoria — to słownik. Ma kod i nazwę, i powinna istnieć sama w sobie. Produkt — to encja katalogu, należy do kategorii. Wszystko to normalnie opisuje się osobnymi tabelami:

-- Słownik kategorii: żyje niezależnie od zamówień
CREATE TABLE category (
    id BIGINT PRIMARY KEY,
    -- Krótki stabilny kod (wygodny dla integracji/czytelności)
    code VARCHAR(50) NOT NULL UNIQUE,
    -- Czytelna dla człowieka nazwa kategorii
    name VARCHAR(100) NOT NULL
);
-- Katalog produktów: każdy wiersz — jeden produkt
CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    -- Biznesowy identyfikator produktu (SKU)
    sku VARCHAR(50) NOT NULL UNIQUE,
    -- Nazwa produktu dla witryny/raportów
    name VARCHAR(200) NOT NULL,
    -- FK na kategorię: produkt musi należeć do kategorii
    category_id BIGINT NOT NULL,
    FOREIGN KEY (category_id) REFERENCES category(id)
);

I w tym momencie zamyka się drugie odwołanie z pozycji zamówienia. order_item.product_id — to nie po prostu liczba „o jakimś produkcie", a tak samo obowiązkowe odwołanie do product.id, jak order_id do samego zamówienia. Jeśli pokazać to w języku SQL, powiązanie dodaje się tak:

ALTER TABLE order_item
ADD FOREIGN KEY (product_id) REFERENCES product(id);

Teraz pozycja zamówienia nie może wskazywać na nieistniejący produkt — inaczej normalizacja szybko zamieniłaby się w zestaw ładnych, ale słabo ze sobą powiązanych tabel.

I tu widać główną zaletę: jeśli zmieniasz nazwę kategorii, robisz to w jednym miejscu. Bez masowej aktualizacji wszystkich zamówień i wszystkich produktów, gdzie ta kategoria kiedykolwiek figurowała jako tekst.

UPDATE category
SET name = 'Books and Media'
WHERE code = 'BOOKS';

I gotowe: jeden wiersz. Jedna prawda.

Stany magazynowe (stock_item): osobno od produktu

Stany magazynowe — to nie „część produktu", a osobny kawałek danych z innym cyklem życia. Nazwa produktu zmienia się rzadko, stany — ciągle. Jeśli przechowujesz je w jednej tabeli product, zamieniasz katalog w tabelę aktualizowaną co minutę. W projekcie edukacyjnym może się to wydawać drobiazgiem, ale jako nawyk architektoniczny — jest szkodliwy.

Dlatego zazwyczaj stany magazynowe wydziela się osobno:

-- Stany magazynowe: osobna tabela z osobnym cyklem życia
CREATE TABLE stock_item (
    id BIGINT PRIMARY KEY,
    -- "Jeden produkt — jeden rekord stanów" (w uproszczeniu: jeden magazyn)
    product_id BIGINT NOT NULL UNIQUE,
    -- Dostępne do sprzedaży teraz
    available_quantity INT NOT NULL,
    -- Zarezerwowane pod zamówienia (jeszcze nie odjęte)
    reserved_quantity INT NOT NULL,
    FOREIGN KEY (product_id) REFERENCES product(id)
);

Zwróć uwagę na UNIQUE na product_id: tym utrwalamy regułę „jeden rekord stanów na jeden produkt" (w naszej uproszczonej domenie jeden magazyn). To dobry przykład tego, jak ograniczenia z poprzedniego wykładu wspierają sens modelu.

Ważna myśl: czasem „duplikowanie" jest normalne

Normalizacja nie oznacza „nigdy nie powtarzaj danych". W zamówieniach bardzo często jest pole unit_price w order_item, choć cena jest też w product. Początkującemu to wygląda jak „błąd normalizacji", ale w rzeczywistości to różne fakty. Cena produktu w katalogu — to „bieżąca cena teraz", a unit_price w pozycji zamówienia — to „cena w momencie zakupu". Jeśli jutro cena w katalogu się zmieni, twoje zamówienie nie powinno przepisywać historii.

Właśnie to jest przykład zdrowego rozsądku: rozdzielamy dane po sensie i czasie życia. Normalizacja pomaga zobaczyć, gdzie powtórzenie — to rzeczywiście powtórzenie, a gdzie — nowa samodzielna „prawda" z innym czasem i kontekstem.

6. Wystarczająca normalizacja bez fanatyzmu

Po pierwszych sukcesach z normalizacją czasem pojawia się inna skrajność: chce się rozkładać wszystko na maksymalną liczbę tabel, bo „tak jest prawidłowo". W tym momencie baza danych zaczyna przypominać szafę, gdzie skarpetki leżą osobno od lewej skarpetki, a lewa skarpetka — osobno od swojego nastroju. Formalnie może się da. Praktycznie — sam sobie utrudniasz życie.

W projektach backendowych na poziomie junior/junior+ zazwyczaj wystarczy dojść do stanu, gdzie główne anomalie są usunięte: słowniki żyją osobno, powtarzające się zestawy wydzielone, powiązania wyrażone przez FK, unikalność pól biznesowych ustalona, pola obowiązkowe — NOT NULL. To już czyni model stabilnym: można go zmieniać, można sprawdzać, można czytać i nie bać się, że dane sobie przeczą.

Przydatne jest trzymanie w głowie małego porównania — nie jako dogmat, a jako „zdrowy rozsądek":

Sytuacja Płaska tabela Normalny schemat
Przemianować kategorię Trzeba zaktualizować wiele wierszy Aktualizowany jest jeden wiersz w category
Usunąć pozycję zamówienia Ryzyko utraty „nagłówka zamówienia" Usuwany jest wiersz z order_item, zamówienie zostaje
Dodać kategorię z wyprzedzeniem Niejasne gdzie wstawiać, pojawiają się NULL Proste wstawienie do category
Przechowywać cenę w momencie zakupu Trudno wyjaśnić, gdzie jest „prawda" product.price — bieżąca, order_item.unit_price — historyczna

Zauważ: nadal mówimy o logice danych, a nie o „sprytnej optymalizacji". Normalizacja tu nie dotyczy szybkości, lecz tego, żeby dane były niesprzeczne i zarządzalne.

7. Typowe błędy przy normalizacji

Błąd nr 1: robienie „uniwersalnej tabeli", żeby unikać powiązań.
Wydaje się, że jedna tabela jest prostsza: nie trzeba pisać JOIN, nie trzeba FK, wszystko w jednym miejscu. Ale działa to tylko na starcie. Przy pierwszej zmianie struktury dane zaczynają kolidować, pojawiają się duplikaty i niespójność. Powiązania — to nie komplikowanie, a sposób na utrwalenie sensu w schemacie.

Błąd nr 2: tworzenie „powiązań przez umowę" bez FOREIGN KEY.
Gdy zamiast category_id używa się stringa category_code, baza traci kontrolę nad integralnością. Każda literówka zamienia dane w niepowiązane kawałki. Jeśli powiązanie jest ważne, powinno być wyrażone przez FK, a nie opierać się na umowie między programistami.

Błąd nr 3: normalizowanie „do atomów" bez uwzględnienia odczytu.
Można rozłożyć wszystko po tabelach tak, że prosty odczyt zamienia się w skomplikowany łańcuch JOIN-ów. Formalnie schemat jest „prawidłowy", ale praktycznie — niewygodny. Normalizacja powinna upraszczać model i pracę z nim, a nie zamieniać czytania w inżynierski quest.

Błąd nr 4: przechowywanie list wartości w jednej kolumnie.
sku_list = 'BOOK-001,GAME-777' wygląda jak szybkie rozwiązanie, ale łamie całą ideę modelu relacyjnego. Takich danych nie można normalnie filtrować, walidować i łączyć przez FK. Jeśli są powtarzające się elementy, potrzebują osobnej tabeli, a nie stringa z separatorami.

Błąd nr 5: uważanie każdego duplikowania za błąd.
Nie każde powtórzenie to zło. Pola takie jak unit_price w order_item utrwalają stan w momencie zdarzenia, a nie duplikują „bieżącą cenę". To samo dotyczy agregatów jak total_amount. Ważne jest rozumienie sensu danych: czasem duplikowanie — to nie naruszenie normalizacji, a świadoma decyzja.

1
Zadanie
Spring Data JPA, poziom 2, lekcja 2
Niedostępne
Podziel płaskie zamówienie na nagłówek i pozycje
Podziel płaskie zamówienie na nagłówek i pozycje
1
Zadanie
Spring Data JPA, poziom 2, lekcja 2
Niedostępne
Wyodrębnij kategorię z tabeli produktu
Wyodrębnij kategorię z tabeli produktu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION