Wszystkie wpisy

Relacyjne bazy danych w C# - architektura RDBMS, schemat tabel, mapowanie typów SQL

Architektura silnika RDBMS (Query Optimizer, Storage Engine, Transaction Log), projektowanie tabel z PK/FK, mapowanie typów SQL Server na typy C# (INT, NVARCHAR, DECIMAL, DATETIME2, DATE). Dwudziesta druga część kursu C#.


Przez ostatnie tygodnie poznałeś C# od strony wewnętrznej - typów, pamięci, asynchroniczności, optymalizacji. Teraz przechodzimy do zewnętrznego świata: trwałe przechowywanie danych. Każda aplikacja biznesowa potrzebuje pamiętać coś między uruchomieniami - użytkowników, zamówienia, logi, konfigurację. Standardem branżowym od dekad jest Relacyjna Baza Danych (RDBMS) - silnik z procesorami zapytań, indeksami, transakcjami i gwarancjami spójności.

W tej części poznasz architekturę typowego silnika RDBMS (warstwa po warstwie), klasyczne projektowanie tabel z Primary Key i Foreign Key, oraz mapowanie typów - kluczową umiejętność wiążącą Twój kod C# z fizycznym zapisem na dysku. Wpis na poziomie fundamentów - nie zależnie od wyboru ORM-a (Entity Framework Core poznałeś w piątym wpisie kursu, ale fundamenty SQL są pod każdą biblioteką).

1. Architektura silnika RDBMS #

Baza danych to nie plik tekstowy. To samodzielny, działający w tle program (serwer), który Twoja aplikacja odpytuje przez sieć. Najpopularniejsze silniki:

SilnikProducentCharakterystyka
SQL ServerMicrosoftStandard w stacku .NET, najlepsza integracja z Visual Studio i Azure
PostgreSQLOpen SourceNajbogatsza funkcjonalność, JSON-native, świetna ekosystem
MySQL / MariaDBOracle / Open SourceNajprostszy, najpopularniejszy w stacku LAMP
SQLiteOpen SourceEmbedded - jeden plik, bez serwera; idealny do testów i małych aplikacji
OracleOracleEnterprise, drogi, dominuje w bankowości

Każdy silnik ma podobną architekturę - cztery główne warstwy:

Warstwa 1: Klient-Serwer (Połączenia) #

Twoja aplikacja C# (klient) łączy się z bazą (serwerem) przez TCP/IP. Serwer autoryzuje, alokuje sesję, przydziela buffer pool. Klasyczny C#:

using SqlConnection conn = new SqlConnection("Server=localhost;Database=Shop;...");
conn.Open();   // tu odbywa się autoryzacja

Warstwa 2: Procesor Zapytań (Query Optimizer) #

Twoje zapytanie SQL trafia do optymalizatora, który tworzy Execution Plan - decyzję, jak wykonać zapytanie najefektywniej.

SELECT * FROM Orders WHERE UserId = 42

Optymalizator analizuje:

  • Czy tabela Orders ma indeks na kolumnie UserId?
  • Ile rekordów ma Orders?
  • Jaka jest dystrybucja UserId (czy 42 to rzadka wartość czy częsta)?
  • Czy są dostępne statystyki (cache poprzednich planów)?

Na podstawie tego decyduje: pełen skan (Table Scan) kontra szukanie przez indeks (Index Seek). Dla tabeli z miliardem rekordów to różnica sekund kontra mikrosekund.

To esencja deklaratywności SQL: Ty mówisz co chcesz (SELECT WHERE), baza decyduje jak to zdobyć. Programowanie imperatywne (jak w C#) zostawia decyzję autorowi - SQL ją automatyzuje.

Warstwa 3: Silnik magazynowania (Storage Engine) #

Fizyczny zapis na dysku. Bazy relacyjne nie zapisują pojedynczych wierszy - operują na stronach o stałym rozmiarze (zwykle 8 KB). Każda strona zawiera ~50-100 wierszy (zależnie od rozmiaru).

Mechanika modyfikacji jednej wartości:

  1. Read: cała strona (8 KB) z Orders ląduje w Buffer Pool (RAM)
  2. Modify: zmiana w pamięci
  3. Write: strona wraca na dysk (z opóźnieniem - dopiero gdy nie ma już miejsca w cache albo przy CHECKPOINT)

Buffer Pool to cache stron w RAM. Dla często odpytywanych tabel cała tabela ląduje w RAM - dyski są tylko backup’em. Stąd reguła ogólna: dolicz tyle RAM do serwera DB, ile masz aktywnych danych.

Warstwa 4: Menedżer Transakcji i Transaction Log #

Gwarantuje ACID:

  • Atomicity - transakcja jest niepodzielna (wszystko albo nic)
  • Consistency - po transakcji baza w spójnym stanie
  • Isolation - równoległe transakcje nie widzą się nawzajem (do czasu commit)
  • Durability - po commit zmiany są trwałe

Sercem tego jest Transaction Log (Write-Ahead Log - WAL). Każda modyfikacja najpierw trafia do log file, potem do głównego pliku danych. Po awarii prądu baza w czasie startu:

  1. Czyta log od ostatniego checkpointa
  2. Powtarza (redo) wszystkie zatwierdzone transakcje
  3. Cofa (undo) niezakończone

Dzięki temu nigdy nie tracisz zatwierdzonych danych - nawet przy crashu w samym środku zapisu.

2. Projektowanie schematu tabel #

W bazach relacyjnych dane są ściśle ustrukturyzowane. Każda tabela ma określone kolumny, każda kolumna ma określony typ. Nie można “wrzucić czegokolwiek” jak w NoSQL (MongoDB).

Mapowanie OOP → SQL #

Najprostsza intuicja dla programisty C#:

C#SQLPrzykład
KlasaTabelaclass UserCREATE TABLE Users
WłaściwośćKolumnaint Id { get; set; }Id INT
Obiekt (instancja)Wiersz (rekord)new User { Id = 1, ... }INSERT INTO Users
Lista obiektówCała tabelaList<User> ↔ wynik SELECT * FROM Users

Konwencja nazewnicza: klasa w liczbie pojedynczej (User), tabela w mnogiej (Users). EF Core automatyzuje to (DbSet<User> Users { get; set; } → tabela Users).

Klucze - fundament relacyjności #

Primary Key (PK) - unikalny identyfikator wiersza:

CREATE TABLE Users (
    Id INT IDENTITY(1,1) PRIMARY KEY,
    Email NVARCHAR(255) NOT NULL,
    CreatedAt DATETIME2 NOT NULL
);

IDENTITY(1,1) mówi: pierwszy wiersz dostanie Id = 1, kolejne automatycznie rosną o 1. Baza sama zarządza unikalnością. Alternatywa: UNIQUEIDENTIFIER (Guid) - przydatne w rozproszonych systemach (UUID generowane na klientach nie kolidują między serwerami).

Foreign Key (FK) - kolumna wskazująca na PK innej tabeli:

CREATE TABLE Orders (
    Id INT IDENTITY(1,1) PRIMARY KEY,
    UserId INT NOT NULL,
    Amount DECIMAL(18, 2),
    FOREIGN KEY (UserId) REFERENCES Users(Id)
);

Tabela Orders ma kolumnę UserId wskazującą na Users.Id. Baza wymusza integralność referencyjną:

  • Nie wstawisz Order z UserId = 999, jeśli taki użytkownik nie istnieje
  • Nie usuniesz User, jeśli istnieją jego Orders (chyba że ustawisz CASCADE)

Czemu unikamy redundancji #

Anti-pattern: każde zamówienie zawiera pełne dane klienta.

-- ŹLE - redundancja
CREATE TABLE Orders (
    Id INT,
    Amount DECIMAL(18, 2),
    UserName NVARCHAR(100),
    UserEmail NVARCHAR(255),
    UserAddress NVARCHAR(500)
);

Problemy:

  • Anomalia aktualizacji: klient zmienia adres → update’ujesz tysiące rekordów Orders
  • Anomalia spójności: zapomnisz update’ować jednego → dwa różne adresy dla tego samego klienta
  • Marnowanie miejsca: te same 500 bajtów adresu w każdym z miliona zamówień

Rozwiązanie: normalizacja. Każde dane w jednym miejscu:

-- DOBRZE - normalizacja
CREATE TABLE Users (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    Email NVARCHAR(255),
    Address NVARCHAR(500)
);

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    UserId INT FOREIGN KEY REFERENCES Users(Id),
    Amount DECIMAL(18, 2)
);

Zmiana adresu = jeden UPDATE w Users. Pełna spójność, minimum miejsca.

3. Mapowanie typów SQL ↔ C# #

Każdy typ SQL ma swojego odpowiednika w C#. Dobranie właściwego typu to różnica między aplikacją szybką a wolną, między zajmującą gigabajty kontra megabajty.

Liczby całkowite #

C#SQL ServerRozmiarZakres
byteTINYINT1 B0 do 255
shortSMALLINT2 B±32 768
intINT4 B±2.1 miliarda
longBIGINT8 B±9.2 × 10^18

Reguła kciuka: użyj najmniejszego typu, który wystarczy. Dla PK tabeli z maks 1000 użytkowników - SMALLINT wystarcza (oszczędność 2B na wiersz). Ale w praktyce większość PK to INT - łatwiejsze do skalowania, gdy aplikacja urośnie.

Teksty #

C#SQL ServerRozmiar
stringVARCHAR(n)1 B/znak (ASCII/Windows-1250)
stringNVARCHAR(n)2 B/znak (Unicode UTF-16)
stringTEXT / NTEXTdeprecated, nie używaj
string (długi)VARCHAR(MAX) / NVARCHAR(MAX)do 2 GB

Krytyczna decyzja: VARCHAR kontra NVARCHAR.

  • VARCHAR('Łódź') w bazie z domyślnym collation → ‘?ódź’ lub ‘L?d?’. Utrata danych.
  • NVARCHAR('Łódź')‘Łódź’ bezbłędnie. Bezpieczne dla każdego języka.

Zasada: dla danych użytkowników (imiona, adresy, opisy, wiadomości) zawsze NVARCHAR. Dla danych technicznych zawsze ASCII (kody walut EUR/USD, status codes ACTIVE/PAUSED) - można VARCHAR dla oszczędności.

Wielkość n: dla email 100, dla imienia 50, dla opisu produktu 500. Nie używaj NVARCHAR(MAX) z lenistwa - bazy mają specjalne ścieżki dla MAX (kosztowne).

Liczby zmiennoprzecinkowe #

C#SQL ServerCharakterystyka
decimalDECIMAL(p, s)Dokładny - dla walut, finansów
float (System.Single)REAL32-bit IEEE 754, niedokładny
doubleFLOAT64-bit IEEE 754, niedokładny

Bardzo ważne: nigdy nie używaj FLOAT/REAL dla walut. Klasyczny bug:

double sum = 0.0;
for (int i = 0; i < 10; i++) sum += 0.1;
Console.WriteLine(sum);   // 0.9999999999999999 (!), nie 1.0

Dla 0.10 zł × 10 razy oczekujemy 1.00 zł. FLOAT da 0.9999… - po zaokrągleniu może być 0.99. Bug księgowy = sąd. Dla walut zawsze DECIMAL.

DECIMAL(p, s):

  • p (precision) = łączna liczba cyfr
  • s (scale) = cyfry po przecinku
  • DECIMAL(18, 2) standard dla PLN/USD: do 9_999_999_999_999_999.99

Daty i czasy #

C#SQL ServerCharakterystyka
DateTimeDATETIME2Data + czas, precyzja do 100 ns, 6-8 B - rekomendowane
DateTimeDATETIMEStary typ, precyzja 3.33 ms, 8 B - tylko dla legacy
DateOnly (.NET 6+)DATESama data, 3 B - dla dat urodzenia, dni roboczych
TimeOnly (.NET 6+)TIMESam czas, 3-5 B - dla godzin otwarcia
DateTimeOffsetDATETIMEOFFSETData + czas + strefa czasowa, 8-10 B

Rekomendacje:

  • Dla dat z czasem - DATETIME2 (Microsoft rekomenduje od lat)
  • Dla samej daty (urodziny, deadline) - DATE + DateOnly w C# (oszczędność miejsca)
  • Dla logów rozproszonych po strefach czasowych - DATETIMEOFFSET (przechowuje offset UTC)
  • Unikaj starego DATETIME w nowych projektach

Logiczne i inne #

C#SQL ServerRozmiar
boolBIT1 b (osiem BIT-ów upakowanych w 1 B)
GuidUNIQUEIDENTIFIER16 B
byte[]VARBINARY(n)n B
byte[] (duży)VARBINARY(MAX)do 2 GB
string (JSON)NVARCHAR(MAX) z CHECK ISJSON(...) = 1dowolny

Nullable #

W SQL kolumna może być NULL lub NOT NULL. W C# odpowiada to typowi T?:

public class User
{
    public int Id { get; set; }            // INT NOT NULL
    public string Name { get; set; }       // NVARCHAR(100) NOT NULL
    public string? Bio { get; set; }       // NVARCHAR(MAX) NULL
    public DateTime? DeletedAt { get; set; }  // DATETIME2 NULL
}

Zasada: NOT NULL domyślnie. NULL tylko gdy ma sens biznesowy (“ten user nie ma jeszcze opisu”, “nie został jeszcze usunięty”). NULL w SQL jest trójwartościową logiką (NULL != NULL!) - źródło bugów.

4. Praktyczne wzorce projektowe #

Wzorzec: tabela słownikowa #

Status zamówienia, kategoria produktu - wartości z zamkniętej listy. Dwa podejścia:

Wariant 1: kolumna z constraint

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    Status NVARCHAR(20) NOT NULL
        CHECK (Status IN ('Pending', 'Paid', 'Shipped', 'Cancelled'))
);

Wariant 2: tabela słownikowa + FK

CREATE TABLE OrderStatuses (
    Id INT PRIMARY KEY,
    Name NVARCHAR(20) UNIQUE NOT NULL
);

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    StatusId INT FOREIGN KEY REFERENCES OrderStatuses(Id)
);

Wariant 1 - mniej zapytań (nie trzeba JOIN-a), prostszy. Wariant 2 - łatwo dodać nowy status bez ALTER TABLE, czytelniejszy. Decyzja: jeśli statusy są stałe → Wariant 1. Jeśli mogą się zmieniać (np. konfiguracja admina) → Wariant 2.

Wzorzec: kolumny audytu #

W większości tabel dodaj kolumny śledzące historię:

CREATE TABLE Users (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
    DeletedAt DATETIME2 NULL    -- soft delete
);

Soft delete: zamiast DELETE FROM Users, ustawiasz DeletedAt = SYSUTCDATETIME(). Aplikacja filtruje WHERE DeletedAt IS NULL. Po co: możliwość przywrócenia, audyty, compliance (GDPR).

Wzorzec: indeksy #

Bez indeksu - każde WHERE to pełen skan tabeli. Z indeksem - logarithmiczne wyszukanie.

CREATE INDEX IX_Users_Email ON Users(Email);
-- Teraz SELECT * FROM Users WHERE Email = '[email protected]' jest błyskawiczne

Reguła: indeks dla każdej kolumny często używanej w WHERE, JOIN, ORDER BY. Ale uwaga: każdy indeks spowalnia INSERT/UPDATE/DELETE (baza musi też zaktualizować indeks). Mierz balans.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Architektura RDBMS - cztery warstwy: połączenia, Query Optimizer (deklaratywne SQL), Storage Engine ze stronami 8KB i Buffer Poolem w RAM, Transaction Log (Write-Ahead Log) gwarantujący ACID
  • Mapowanie OOP → SQL - klasa → tabela, właściwość → kolumna, obiekt → wiersz, lista → cała tabela
  • Klucze - PK jako unikalny identyfikator (zwykle INT IDENTITY lub Guid), FK jako wskazanie na PK innej tabeli
  • Normalizacja - unikaj redundancji, każde dane w jednym miejscu; relacje przez FK zamiast duplikowania
  • Mapowanie typów - int/INT, string/NVARCHAR (zawsze N dla Unicode/polskich znaków), decimal/DECIMAL(18, 2) dla walut (nigdy FLOAT!), DateTime/DATETIME2 (nie stary DATETIME)
  • NVARCHAR kontra VARCHAR - krytyczna decyzja; zawsze NVARCHAR dla danych użytkowników
  • DECIMAL kontra FLOAT - dla walut zawsze DECIMAL; FLOAT to klasyczny bug księgowy
  • DATETIME2 kontra DATETIME - DATETIME2 dla nowych projektów (precyzja, zakres, miejsce)
  • Nullable - T? w C# = NULL w SQL; domyślnie NOT NULL, NULL tylko gdy ma sens biznesowy
  • Wzorce produkcyjne - tabele słownikowe (CHECK constraint kontra FK), kolumny audytu (CreatedAt/UpdatedAt/DeletedAt jako soft delete), indeksy

W następnym wpisie SQL w praktyce - DDL i DML przechodzimy od teorii do praktyki. Podział SQL na cztery kategorie, CREATE TABLE z kluczami i ograniczeniami, Wielka Czwórka DML (INSERT/SELECT/UPDATE/DELETE) z klasycznymi pułapkami (UPDATE bez WHERE), INNER JOIN, mapowanie SQL na LINQ w C#.

Quiz i zadanie poniżej. Zadanie łączy dwa tematy z kursu: refleksję (z piętnastego wpisu) plus dzisiejsze mapowanie typów. Zbudujesz mini-generator schematu SQL, który czyta klasy C# i wypisuje CREATE TABLE - dokładnie ten sam mechanizm, który stoi za EF Core Migrations.

Podobne wpisy

🔗 Linkują tu