SQL Server, de la zero Lecția 13 / 40

UNION vs UNION ALL

De ce UNION ALL este mai rapid și de ce oamenii încă scriu UNION din greșeală. Plus INTERSECT, EXCEPT și combinarea corectă a seturilor de rezultate.

Lecție scurtă astăzi. Doar un cuvânt-cheie, un bug, o regulă.

Echipa de finanțe Runehold are două tabele: Sales.Orders pentru canalul online și Retail.Sale pentru magazinele fizice. Vor o vedere unificată pentru raportarea veniturilor. Query-ul? Două select-uri combinate cu UNION. Numai că… UNION este mai lent decât trebuie să fie în 99% dintre cazuri, iar jumătate dintre query-urile din producție care folosesc UNION ar trebui să folosească de fapt UNION ALL.

Să rezolvăm asta.

Cei patru operatori de mulțimi

T-SQL are patru operatori care combină seturi de rezultate:

  • UNION — rândurile din A + rândurile din B, cu duplicatele eliminate
  • UNION ALL — rândurile din A + rândurile din B, păstrând duplicatele
  • INTERSECT — rândurile care apar și în A, și în B
  • EXCEPT — rândurile din A care nu apar în B

Toți patru cer:

  • Ambele părți să aibă același număr de coloane
  • Tipurile coloanelor să fie compatibile (sau SQL Server va încerca o conversie implicită, care poate strica SARGability-ul)
  • Numele coloanelor din rezultatul final provin din primul query

UNION vs UNION ALL

-- UNION: îmbină + elimină duplicatele
SELECT ProductId, Quantity FROM Sales.OrderLine
UNION
SELECT ProductId, Quantity FROM Retail.SaleLine;

-- UNION ALL: îmbină, păstrează duplicatele
SELECT ProductId, Quantity FROM Sales.OrderLine
UNION ALL
SELECT ProductId, Quantity FROM Retail.SaleLine;

Singura diferență: UNION elimină duplicatele, UNION ALL nu.

Pentru a elimina duplicatele, SQL Server trebuie să facă muncă în plus: să sorteze sau să facă hash la fiecare rând combinat, să compare și să arunce duplicatele. Asta este măsurabil pe date mici și dureros pe date mari.

Regula:

  • Folosește UNION ALL cu excepția cazurilor în care ai un motiv anume să deduplici.
  • Folosește UNION doar când știi că pot exista duplicate și vrei să le elimini.

În majoritatea cazurilor în care combini date partiționate — comenzi online + comenzi retail, log-uri arhivate + log-uri live, datele tenantului A + datele tenantului B — prin construcție nu pot exista duplicate. UNION ALL este alegerea corectă.

A scrie UNION „pentru siguranță” este ca a sorta un array deja sortat „pentru siguranță”. Nu este gratis.

Singurul loc unde oamenii încă scriu UNION

Marketingul Runehold are o listă de email pentru toți, construită din trei surse: clienții care au optat in, angajații, partenerii. Unii oameni se suprapun — un angajat care este și client.

-- Combină trei liste, elimină duplicatele
SELECT Email FROM Sales.Customer WHERE EmailOptIn = 1
UNION
SELECT Email FROM HR.Employee
UNION
SELECT Email FROM Partners.Contact;

Acesta este un UNION legitim. Output-ul așteptat este o listă deduplicată, iar duplicatele între surse sunt cu adevărat posibile. În acest caz, UNION ALL ar trimite aceleiași persoane trei email-uri. UNION este alegerea corectă.

Când ai dubii: poate acest rând să existe legitim în ambele input-uri? Dacă da, trebuie să decizi ce faci cu duplicatul. Dacă nu, UNION ALL este mai sigur și mai rapid.

INTERSECT și EXCEPT

Mai puțin folosiți, dar frumoși când se potrivesc.

INTERSECT — ce se află în ambele?

-- Clienți care au comandat online ȘI au intrat într-un magazin
SELECT CustomerId FROM Sales.Orders
INTERSECT
SELECT CustomerId FROM Retail.Sale;

Returnează CustomerId-urile care apar în ambele seturi de rezultate. Echivalent cu un inner join / EXISTS, dar adesea mult mai lizibil pentru întrebările de tip „suprapunere”.

EXCEPT — în A, dar nu în B?

-- Clienți care au comandat online dar nu au intrat niciodată într-un magazin
SELECT CustomerId FROM Sales.Orders
EXCEPT
SELECT CustomerId FROM Retail.Sale;

Returnează rândurile din primul query care nu apar în al doilea. Identic ca efect cu NOT EXISTS.

Atât INTERSECT, cât și EXCEPT deduplică, la fel ca UNION. Pentru query-uri cu o singură coloană sunt curate și evidente. Pentru mai multe coloane se poate complica — fiecare coloană participă la comparația „se potrivește acest rând?”.

O utilizare clasică a lui EXCEPT: reconcilierea de date. „Ce rânduri sunt în tabelul de staging care nu sunt în producție?” O modalitate rapidă și evidentă de a compara două seturi de rezultate.

ORDER BY merge doar pe ultimul query

-- GREȘIT: ORDER BY pe un query intermediar
SELECT Email FROM Sales.Customer ORDER BY Email    -- eroare
UNION
SELECT Email FROM HR.Employee;

-- CORECT: ORDER BY după tot
SELECT Email FROM Sales.Customer
UNION
SELECT Email FROM HR.Employee
ORDER BY Email;

Se permite un singur ORDER BY, la final. Se aplică rezultatului combinat.

Dacă chiar trebuie să sortezi în interiorul unei ramuri (să zicem că vrei TOP (10) din fiecare sursă combinată), folosește subquery-uri:

SELECT * FROM (
    SELECT TOP (10) Email, 'Customer' AS source
    FROM Sales.Customer
    ORDER BY CreatedAt DESC
) AS c
UNION ALL
SELECT * FROM (
    SELECT TOP (10) Email, 'Employee' AS source
    FROM HR.Employee
    ORDER BY HireDate DESC
) AS e
ORDER BY source, Email;

Fiecare subquery își face propriul TOP + ORDER BY; UNION ALL-ul exterior le combină pe cele două, iar ORDER BY-ul final sortează rezultatul combinat.

Numele și tipurile coloanelor

Numele coloanelor provin din primul query. Tipurile trebuie să fie compatibile sau SQL Server face conversie implicită.

SELECT CustomerId AS id, 'Sales' AS source FROM Sales.Orders
UNION ALL
SELECT EmployeeId, 'HR'  FROM HR.Employee;
-- Coloanele rezultatului: id, source

Dacă tipurile coloanelor nu se potrivesc, SQL Server va încerca să convertească la un tip comun sau va eșua cu o eroare explicită „types are incompatible”. Bună practică: cast-uri explicite când nu este evident:

SELECT CAST(CustomerId AS INT) AS id, 'Sales' AS source FROM Sales.Orders
UNION ALL
SELECT CAST(EmployeeId AS INT), 'HR' FROM HR.Employee;

Te scapă de o eroare confuză în producție când cineva schimbă un tip mai jos pe lanț.

Marcarea sursei: un tipar comun

Când combini date din surse multiple, adaugă o coloană literală care identifică de unde a venit fiecare rând:

SELECT 'online' AS channel,
       OrderDate, Total, CustomerId
FROM Sales.Orders
UNION ALL
SELECT 'retail',
       SaleDate,  Total, CustomerId
FROM Retail.Sale;

Dashboard-ul de venit al echipei de finanțe: un singur tabel, o singură serie de timp, marcat după canal. Ușor de grupat, ușor de pus pe grafic, ușor de depanat.

Rulează asta pe propriul tău server

USE Runehold;
GO

-- Pregătim un Retail.Sale mic pentru demo
IF SCHEMA_ID('Retail') IS NULL EXEC('CREATE SCHEMA Retail AUTHORIZATION dbo');

IF OBJECT_ID('Retail.Sale', 'U') IS NULL
    CREATE TABLE Retail.Sale (
        SaleId BIGINT IDENTITY(1,1) PRIMARY KEY,
        StoreCity NVARCHAR(50) NOT NULL,
        SaleDate DATETIME2(0) NOT NULL,
        Total DECIMAL(19,4) NOT NULL,
        CustomerId INT NULL   -- unele vânzări în magazin sunt anonime
    );

INSERT INTO Retail.Sale (StoreCity, SaleDate, Total, CustomerId)
VALUES ('Amsterdam', '2026-03-20', 45.00, 1),
       ('Milan',      '2026-03-21', 62.00, 2),
       ('Milan',      '2026-03-21', 18.50, NULL),
       ('Berlin',    '2026-03-22', 99.00, 3);

-- Query 1: vedere unificată a venitului cu marcarea canalului
SELECT 'online' AS channel,
       OrderDate AS tx_date,
       Total,
       CustomerId
FROM Sales.Orders
UNION ALL
SELECT 'retail',
       SaleDate,
       Total,
       CustomerId
FROM Retail.Sale
ORDER BY tx_date DESC;

-- Query 2: totaluri combinate per canal
WITH combined AS (
    SELECT 'online' AS channel, Total FROM Sales.Orders
    UNION ALL
    SELECT 'retail',            Total FROM Retail.Sale
)
SELECT channel, COUNT(*) AS tx_count, SUM(Total) AS revenue
FROM combined
GROUP BY channel;

-- Query 3: INTERSECT — clienți activi pe ambele canale
SELECT CustomerId FROM Sales.Orders WHERE CustomerId IS NOT NULL
INTERSECT
SELECT CustomerId FROM Retail.Sale  WHERE CustomerId IS NOT NULL;

-- Query 4: EXCEPT — clienți care au cumpărat online, dar niciodată în magazin
SELECT CustomerId FROM Sales.Orders WHERE CustomerId IS NOT NULL
EXCEPT
SELECT CustomerId FROM Retail.Sale  WHERE CustomerId IS NOT NULL;

-- Query 5: comparație de performanță — cronometrează asta față de UNION ALL pe un set mare
-- (date mici aici, dar principiul scalează)
SET STATISTICS TIME ON;

SELECT OrderDate AS d FROM Sales.Orders
UNION
SELECT SaleDate  FROM Retail.Sale;     -- dedupe implicit

SELECT OrderDate AS d FROM Sales.Orders
UNION ALL
SELECT SaleDate  FROM Retail.Sale;     -- mai rapid

SET STATISTICS TIME OFF;

Rulează cronometrările pe un dataset mai mare și vei vedea costul. Pe un tabel cu milioane de rânduri, UNION vs UNION ALL este diferența dintre un query de 4 secunde și unul de 400 ms.

Lecția următoare: funcții de string. STRING_AGG, STRING_SPLIT, TRIM, CONCAT_WS și moartea jumătății funcțiilor CLR pe care vechile companii încă le cară după ele.

Caută