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:
| Silnik | Producent | Charakterystyka |
|---|---|---|
| SQL Server | Microsoft | Standard w stacku .NET, najlepsza integracja z Visual Studio i Azure |
| PostgreSQL | Open Source | Najbogatsza funkcjonalność, JSON-native, świetna ekosystem |
| MySQL / MariaDB | Oracle / Open Source | Najprostszy, najpopularniejszy w stacku LAMP |
| SQLite | Open Source | Embedded - jeden plik, bez serwera; idealny do testów i małych aplikacji |
| Oracle | Oracle | Enterprise, 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
Ordersma indeks na kolumnieUserId? - 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:
- Read: cała strona (8 KB) z
Ordersląduje w Buffer Pool (RAM) - Modify: zmiana w pamięci
- 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:
- Czyta log od ostatniego checkpointa
- Powtarza (
redo) wszystkie zatwierdzone transakcje - 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# | SQL | Przykład |
|---|---|---|
| Klasa | Tabela | class User → CREATE TABLE Users |
| Właściwość | Kolumna | int Id { get; set; } → Id INT |
| Obiekt (instancja) | Wiersz (rekord) | new User { Id = 1, ... } → INSERT INTO Users |
| Lista obiektów | Cała tabela | List<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
OrderzUserId = 999, jeśli taki użytkownik nie istnieje - Nie usuniesz
User, jeśli istnieją jegoOrders(chyba że ustawiszCASCADE)
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 Server | Rozmiar | Zakres |
|---|---|---|---|
byte | TINYINT | 1 B | 0 do 255 |
short | SMALLINT | 2 B | ±32 768 |
int | INT | 4 B | ±2.1 miliarda |
long | BIGINT | 8 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 Server | Rozmiar |
|---|---|---|
string | VARCHAR(n) | 1 B/znak (ASCII/Windows-1250) |
string | NVARCHAR(n) | 2 B/znak (Unicode UTF-16) |
string | TEXT / NTEXT | deprecated, 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 Server | Charakterystyka |
|---|---|---|
decimal | DECIMAL(p, s) | Dokładny - dla walut, finansów |
float (System.Single) | REAL | 32-bit IEEE 754, niedokładny |
double | FLOAT | 64-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 cyfrs(scale) = cyfry po przecinkuDECIMAL(18, 2)standard dla PLN/USD: do9_999_999_999_999_999.99
Daty i czasy #
| C# | SQL Server | Charakterystyka |
|---|---|---|
DateTime | DATETIME2 | Data + czas, precyzja do 100 ns, 6-8 B - rekomendowane |
DateTime | DATETIME | Stary typ, precyzja 3.33 ms, 8 B - tylko dla legacy |
DateOnly (.NET 6+) | DATE | Sama data, 3 B - dla dat urodzenia, dni roboczych |
TimeOnly (.NET 6+) | TIME | Sam czas, 3-5 B - dla godzin otwarcia |
DateTimeOffset | DATETIMEOFFSET | Data + czas + strefa czasowa, 8-10 B |
Rekomendacje:
- Dla dat z czasem -
DATETIME2(Microsoft rekomenduje od lat) - Dla samej daty (urodziny, deadline) -
DATE+DateOnlyw C# (oszczędność miejsca) - Dla logów rozproszonych po strefach czasowych -
DATETIMEOFFSET(przechowuje offset UTC) - Unikaj starego
DATETIMEw nowych projektach
Logiczne i inne #
| C# | SQL Server | Rozmiar |
|---|---|---|
bool | BIT | 1 b (osiem BIT-ów upakowanych w 1 B) |
Guid | UNIQUEIDENTIFIER | 16 B |
byte[] | VARBINARY(n) | n B |
byte[] (duży) | VARBINARY(MAX) | do 2 GB |
string (JSON) | NVARCHAR(MAX) z CHECK ISJSON(...) = 1 | dowolny |
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 IDENTITYlubGuid), 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) NVARCHARkontraVARCHAR- krytyczna decyzja; zawsze NVARCHAR dla danych użytkownikówDECIMALkontraFLOAT- dla walut zawsze DECIMAL; FLOAT to klasyczny bug księgowyDATETIME2kontraDATETIME- DATETIME2 dla nowych projektów (precyzja, zakres, miejsce)- Nullable -
T?w C# =NULLw SQL; domyślnieNOT NULL,NULLtylko gdy ma sens biznesowy - Wzorce produkcyjne - tabele słownikowe (CHECK constraint kontra FK), kolumny audytu (
CreatedAt/UpdatedAt/DeletedAtjako 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
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
ArrayPool i MemoryPool w C# - wypożyczalnia tablic kontra LOH
Recykling dużych buforów dla hot path - LOH (Large Object Heap) i jego pułapki, ArrayPool<T>.Shared z Rent/Return, pułapka nadmiarowego rozmiaru, MemoryPool<T> z IMemoryOwner i using dla async. Dwudziesta pierwsza część kursu C#.
🔗 Linkują tu