Jeg har brug for at fortælle dig noget, og det tog mig lang tid at indrømme. I det meste af min karriere troede jeg, at skrive rå SQL var et tegn på fiasko. Det betød, at du ikke kunne få ORM til at gøre, hvad du ønskede. Det betød, at dine abstraktioner var brudt ned. Senior ingeniører brugte ORMs. Junior ingeniører skrev SQL. Jeg var forkert. Ikke bare forkert. Den tro var aktivt skadelig. Det førte mig til at skrive kode, der så rent og effektivt ud, men var faktisk en præstationskatastrofe. Kan du gætte, hvor mange SQL-forespørgsler det tog at gengive en enkelt side i en af vores applikationer? 47 runde ture til databasen og tilbage for at vise tyve rækker data. Koden, der producerede det, var absolut ren. Type-sikker. Fuldt testet. Med smukke abstraktioner. Og koden ødelagde stille vores responstider, og ingen havde bemærket det, fordi abstraktionen havde skjult forbrydelsen for os. En ting, som ingen sætter i ORM's markedsføringsmateriale, er, at det er meget godt at skjule, hvad det virkelig gør med databasen. Lad os tale om, hvad du rent faktisk har ORM bringer meget til bordet. Og det er en del af problemet. Nogle af de ting er så gode, at det kan skade dig uden at du bemærker det. ORM bringer skema management - versionerede migrationer, rollbacks og skema diffing. Og den del er fantastisk. Det bringer også en god API til enkle objektoperationer - 'findById', 'oprette', 'opdatere' og 'slette'. Og den del er også fantastisk. Næste ting - kortlægning mellem objekter og tabeller. Og dette er en kerne del af ORM. Og også, det er her, hvor problemer begynder. Denne kortlægning er perfekt, når det fungerer og skaber et stille problem, når det ikke gør. ORM bringer en illusion af database portabilitet. Og ja i en ideel verden kan du skifte fra MySQL til PostgreSQL uden at ændre en linje af kode. Men i den virkelige verden gør du det aldrig. Du vælger en database, og du holder dig til den. Og jagter den illusion får dig til at undgå de database-specifikke funktioner, der rent faktisk ville gøre din ansøgning hurtig. Hvis du forstår dem, vil du overleve. Hvis du ikke gør det, vil du ende med 47 (eller endda flere) forespørgsler for at vise 20 rækker. Problemet med N+1 er et symptom, ikke en sygdom Næsten alle udviklere, der har arbejdet med ORM, har hørt om problemet med N+1 forespørgsler. Lazy load relationship inde i en loop, og dette vil udløse en forespørgsel pr. iteration. Problemet N+1 er bare en synlig del af isbjerget af ORM-problemer - de gør det for nemt at skrive kode, der ser effektivt ud, men er en katastrofe under kappen. Mine 47 forespørgsler var ikke alle N+1 mønstre. Nogle var redundante søgninger. I nogle af dem indlæste ORM fulde entiteter, når der kun var brug for to felter. Nogle af dem var automatiske COUNT-forespørgsler sendt af paginationsbiblioteket. Og intet af dette er indlysende ved at læse applikationskoden! Du kan kun se det ved at se på forespørgselsloggen. Og raw SQL giver dig den modsatte oplevelse. Du ved præcis, hvilke forespørgsler der sendes til databasen. Tjek denne rå SQL. SELECT id, name, email FROM users WHERE account_id = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 20 OFFSET 0; Du kender forespørgslen. Og du kender de nøjagtige kolonner, der hentes. Du kan tænke på de indekser, du skal bruge til denne forespørgsel. Og også, du kan indsætte det direkte til din databasekonsol og køre EXPLAIN kommandoen på den. Den reelle implementering vil være senere på runtime.Og de reelle forespørgsler kan ændre sig over tid med din model ændringer, og du kan ikke have kontrol over dem. Hvor ORMs virkelig ikke kan følge dig Der er en klasse af problemer, hvor ORM ikke bare er langsom. Det kan simpelthen ikke håndtere dem. Vinduesfunktioner Vinduesfunktioner - `RANK()`, `ROW_NUMBER()`, `LAG()`, `LEAD()`, løbende totaler, glidende gennemsnit - er en af de mest kraftfulde funktioner i moderne SQL. SELECT user_id, amount, order_date, SUM(amount) OVER ( PARTITION BY user_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM orders WHERE account_id = ?; Denne forespørgsel vil producere en løbende total for kontoen i en enkelt anmodning. Hvis du forsøger at implementere det samme på ORMs forespørgselsbuilder, vil du ikke være i stand til at gøre det. Du trækker enten alle rækkerne ind i hukommelsen og beregner det på applikationsniveau, eller du skriver rå SQL. Bulk operationer Forestil dig scenariet. Du skal indsætte 10.000 poster fra en ekstern API og skal indsætte eller opdatere dem i din database. Nye poster skal indsættes, og eksisterende skal opdateres, hvis de indgående data er nyere. Orm’s tilgang er: foreach ($incomingRecords as $data) { $record = $this->repository->findOneBy(['external_id' => $data['id']]); if ($record === null) { $record = new SyncRecord(); $record->setExternalId($data['id']); } $record->setPayload($data['payload']); $record->setSyncedAt(new \DateTimeImmutable()); $this->em->persist($record); } $this->em->flush(); For at behandle 10 000 poster skal vi udføre 10.000 udvalgte forespørgsler og 10.000 udvalgte eller opdaterede forespørgsler. Den SQL tilgang: INSERT INTO sync_records (external_id, payload, synced_at) VALUES (?, ?, NOW()), (?, ?, NOW()), ... ON DUPLICATE KEY UPDATE payload = VALUES(payload), synced_at = VALUES(synced_at); Batch i stykker på 500-1000 rækker. Total udførelsestid: sekunder. 'ON DUPLICATE KEY UPDATE' er en MySQL-funktion. Den har ingen ORM-ækvivalenter. Abstraktionen kan simpelthen ikke dække dette. JSON operationer Hvis du gemmer JSON i din database, og efter MySQL 8 og PostgreSQL gjorde det ret nyttigt. Filtrering af en JSON-vej? Indexering på en JSON-feltværdi? Brug af 'JSON_TABLE' til at konvertere en JSON-array til rækker? Dette er virkelige operationer, som databaser håndterer godt. -- Find all products where attributes contain color = 'blue' SELECT * FROM products WHERE JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.color')) = 'blue'; -- This works because we created a functional index: -- CREATE INDEX idx_color ON products((JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.color')))); Teknisk set kan du konvertere det indre json-felt til et virtuelt felt i tabellen, men det er mere som et trick. Debugging: Den skjulte skat Her er et scenarie, du sikkert har levet igennem. Du husker nok en langsom forespørgsel i produktionen. ikke for langsomt. men langsomt nok til, at kunderne kan klage over det, eller for overvågning at begynde at flagge det. Det er en metodekæde: femten opkald dybt, filtre anvendes betinget baseret på anmodningsparametre, et par joins tilføjet et sted i en base repository klasse, ivrig indlæsning konfigureret i en modelfil, du ikke har åbnet i et stykke tid. Du aktiverer forespørgselslogging og indfanger SQL. Det er 60 linjer. Det har en underforespørgsel, du ikke kan huske at skrive. Der er en 'ORDER BY' på en kolonne, der ikke er indekseret. Nu skal du finde ud af, hvor den underforespørgsel kom fra. Hvilken af de femten metodekædeopkald producerede den? Dette er den skjulte skat af ORM kompleksitet. Når noget går galt, du debugger to lag på samme tid: applikationslogik og forespørgselsgenereringslogik. // What you see in code review $users = $this->userRepository ->createQueryBuilder('u') ->leftJoin('u.account', 'a') ->andWhere('u.isActive = :active') ->andWhere('a.plan = :plan') ->setParameter('active', true) ->setParameter('plan', $plan) ->getQuery() ->getResult(); // What actually runs. And nobody reads until something breaks SELECT u.id, u.name, u.email, u.created_at, u.updated_at, u.is_active, u.account_id, a.id AS a_id, a.name AS a_name, a.plan AS a_plan, a.is_active AS a_is_active, a.created_at AS a_created_at, a.updated_at AS a_updated_at, a.meta AS a_meta FROM users u LEFT JOIN accounts a ON a.id = u.account_id WHERE u.is_active = 1 AND a.plan = 'enterprise' Den 'SELECT *' adfærd - trækker hver kolonne fra hver tilsluttede tabel ind i hukommelsen - er, hvad du betaler for, når du bruger fuld entitet hydrering. Ofte, du behøvede kun tre af de tyve kolonner. ORM hentede alle tyve, fordi det ønskede at give dig et fuldt hydreret objekt. Den arkitektur, der rent faktisk virker Efter ti år med at lave og se disse fejl, her er den struktur, jeg anvender til de projekter, jeg arbejder på. ORM owns: Alle skema definitioner og migrationer Forvaltning af enhedens livscyklus (oprettelse, vedvarende, flush, fjernelse) - Enkle søgninger: Find efter ID, Find efter enkeltfilter, siderede lister over enheder - Relationship management inden for begrænsede, små resultatsæt Raw SQL owns: - Forespørgsler beregnet til rapporter, dashboards eller eksport - Alle bulk operationer: batch inserts, batch opdateringer, upserts Aggregation på tværs af flere tabeller Enhver forespørgsel, der bruger database-specifikke funktioner - Og ganske ofte, enhver forespørgsel, der vises i den langsomme forespørgselslog I Symfony er implementeringen af denne adskillelse ren. Entity repositories håndterer ORM-laget. Et separat sæt af forespørgselsklasser - jeg kalder dem "read repositories" eller "query repositories" - håndterer SQL-laget ved hjælp af DBAL direkte: // Entity repository - pure ORM, simple operations class UserRepository extends ServiceEntityRepository { public function findActiveById(int $id): ?User { return $this->findOneBy(['id' => $id, 'isActive' => true]); } public function save(User $user): void { $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } } // Query repository - raw SQL, complex reads class UserAnalyticsRepository { public function __construct(private readonly Connection $db) {} public function getRetentionBySignupCohort(\DateTimeImmutable $from, \DateTimeImmutable $to): array { $sql = <<<SQL SELECT DATE_FORMAT(u.created_at, '%Y-%m') AS cohort, COUNT(DISTINCT u.id) AS total_users, COUNT(DISTINCT CASE WHEN u.last_active_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN u.id END) AS active_last_30d, ROUND( COUNT(DISTINCT CASE WHEN u.last_active_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN u.id END) / COUNT(DISTINCT u.id) * 100, 1 ) AS retention_pct FROM users u WHERE u.created_at BETWEEN :from AND :to GROUP BY cohort ORDER BY cohort ASC SQL; return $this->db->fetchAllAssociative($sql, [ 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d'), ]); } } DBAL giver dig alt, hvad du behøver - forberedte udtalelser, forbindelse management, transaktion support. Du giver ikke op sikkerhed til at skrive SQL. Du giver op objekt hydrering, som du ikke behøvede for denne forespørgsel alligevel. Bemærk også, hvad den rå SQL-version giver dig, som ORM-versionen ikke kan: forespørgslen er der. Enhver udvikler på teamet kan læse den, forstå den, kopiere den til en databasekonsol, køre 'EXPLAIN' på den og justere den. Den færdighed, der aldrig var valgfri Nogle mennesker tror, at SQL er en arvede færdighed, noget du falder tilbage på, når moderne værktøjer fejler dig, som at vide, hvordan man bruger et fysisk kort, fordi din telefon døde. Jeg tror, det tager det baglæns. SQL er det sprog, som din database taler. Databasen gør mere arbejde end nogen anden komponent i de fleste backend-applikationer. At forstå, hvad du beder den om at gøre, er ikke valgfri ekspertise. De udviklere, jeg har respekteret mest i løbet af min karriere, har noget til fælles: De er komfortable på hvert lag. De bruger ORM, når det passer, og de skriver SQL, når det ikke gør. De føler ikke, at når for SQL er en indrømmelse af nederlag. De føler, at det bruger det rigtige værktøj. Og de kender det rigtige værktøj, fordi de forstår begge. Vi er ligeglade med, hvordan den endelige SQL-forespørgsel blev oprettet. Vi er ligeglade med, om den kan bruge et indeks, hvor mange rækker den skal undersøge, og hvor meget data den skal flytte.